diff --git a/src/documents/document/apart.rs b/src/documents/document/apart.rs index 3740f12..c0d1de4 100644 --- a/src/documents/document/apart.rs +++ b/src/documents/document/apart.rs @@ -132,10 +132,10 @@ impl MarkdownDocument { // A cell reads as it writes back. let typed = match open.cell { Some(_) => { - let closed = cells::closed(text, open.range.end); - let shift = |at: usize| cells::kept_at(typed, at, closed); + let edges = cells::edges(text, open.range.clone()); + let shift = |at: usize| cells::kept_at(typed, at, edges); picked = shift(picked.start)..shift(picked.end); - cells::kept(typed, closed) + cells::kept(typed, edges) } None => typed.to_string(), }; @@ -157,8 +157,8 @@ impl MarkdownDocument { if let Some(open) = &self.open { let typed = open.input.read(cx).text(); // Offsets read the escaped cell; its field holds it raw. - let closed = cells::closed(self.editor.read(cx).text(), open.range.end); - let raw = open.cell.is_some() && cells::kept(typed, closed) != typed; + let edges = cells::edges(self.editor.read(cx).text(), open.range.clone()); + let raw = open.cell.is_some() && cells::kept(typed, edges) != typed; let end = offset + typed.len(); let grown = (edits.iter().map(|(_, with)| with.len()).sum::() + end) .checked_sub(edits.iter().map(|(range, _)| range.len()).sum::()); diff --git a/src/documents/document/cells.rs b/src/documents/document/cells.rs index d6d3c7e..48a90da 100644 --- a/src/documents/document/cells.rs +++ b/src/documents/document/cells.rs @@ -157,32 +157,55 @@ fn guarded(typed: &str) -> String { out } -/// Whether a pipe follows the cell source ending at `end`. -pub(super) fn closed(text: &str, end: usize) -> bool { - text[end..].starts_with('|') +/// What borders a cell's source in its row. +#[derive(Clone, Copy, Debug)] +pub(super) struct Edges { + /// A pipe follows at once. + closed: bool, + /// No pipe before it on its line. + first: bool, + /// No pipe after it on its line. + last: bool, } -/// `typed` as a cell keeps it, a `closed` pipe intact. -pub(super) fn kept(typed: &str, closed: bool) -> String { +/// The borders of the cell source at `range` of `text`. +pub(super) fn edges(text: &str, range: Range) -> Edges { + let line = text[..range.start].rfind('\n').map_or(0, |at| at + 1); + let end = (text[range.end..].find('\n')).map_or(text.len(), |at| range.end + at); + let (before, after) = (&text[line..range.start], &text[range.end..end]); + Edges { + closed: after.starts_with('|'), + first: !before.contains('|'), + last: !after.contains('|'), + } +} + +/// `typed` as a cell keeps it, its row's pipes intact. +pub(super) fn kept(typed: &str, edges: Edges) -> String { let mut out = guarded(typed); let slashes = out.len() - out.trim_end_matches('\\').len(); - if closed && slashes % 2 == 1 { + if edges.closed && slashes % 2 == 1 { out.push('\\'); } + // An emptied bare edge cell keeps its column. + if out.is_empty() && (edges.first || edges.last) { + out.push('|'); + } out } -/// Byte `at` of `typed` within `kept(typed, closed)`. -pub(super) fn kept_at(typed: &str, at: usize, closed: bool) -> usize { +/// Byte `at` of `typed` within `kept(typed, edges)`. +pub(super) fn kept_at(typed: &str, at: usize, edges: Edges) -> usize { match at == typed.len() { - true => kept(typed, closed).len(), + true if typed.is_empty() && !edges.first => 0, + true => kept(typed, edges).len(), false => guarded(&typed[..at]).len(), } } #[cfg(test)] mod tests { - use super::{Aim, CellAt, Move, beside, closed, find, guarded, kept, kept_at}; + use super::{Aim, CellAt, Edges, Move, beside, edges, find, guarded, kept, kept_at}; const TABLE: &str = "intro\n\n| a | **b** |\n| --- | --- |\n| c | |\n| d | e |\n"; @@ -242,36 +265,74 @@ mod tests { assert_eq!(guarded("a\\\\|b"), "a\\\\\\|b"); } + /// Cell `(row, column)` of `text`'s one table, written as `typed`. + fn write(text: &str, row: usize, column: usize, typed: &str) -> String { + let block = 0..text.len() - 1; + let cell = find(text, block, Aim::At(at(row, column))).expect("a cell"); + let kept = kept(typed, edges(text, cell.range.clone())); + [&text[..cell.range.start], &kept, &text[cell.range.end..]].concat() + } + + /// The source of each cell of `text`'s one table. + fn grid(text: &str) -> Vec> { + let block = 0..text.len() - 1; + (0..) + .map_while(|row| { + let cells: Vec = (0..) + .map_while(|column| { + let cell = find(text, block.clone(), Aim::At(at(row, column)))?; + Some(text[cell.range].to_string()) + }) + .collect(); + (!cells.is_empty()).then_some(cells) + }) + .collect() + } + #[test] fn a_closing_backslash_keeps_the_next_pipe() { - assert_eq!(kept("a\\", true), "a\\\\"); - assert_eq!(kept("a\\\\", true), "a\\\\"); - assert_eq!(kept_at("a\\", 2, true), 3); - assert_eq!(kept_at("a\\b", 2, true), 2); - let text = "|a|b|\n|---|---|\n|c|d|\n"; - let cell = find(text, 0..text.len() - 1, Aim::First).expect("a cell"); - let shut = closed(text, cell.range.end); - let written = [ - &text[..cell.range.start], - &kept("a\\", shut), - &text[cell.range.end..], - ] - .concat(); - let b = find(&written, 0..written.len() - 1, Aim::At(at(0, 1))).expect("still a table"); - assert_eq!(&written[b.range], "b"); + let shut = Edges { + closed: true, + first: false, + last: false, + }; + assert_eq!(kept("a\\", shut), "a\\\\"); + assert_eq!(kept("a\\\\", shut), "a\\\\"); + assert_eq!(kept_at("a\\", 2, shut), 3); + assert_eq!(kept_at("a\\b", 2, shut), 2); + let written = write("|a|b|\n|---|---|\n|c|d|\n", 0, 0, "a\\"); + assert_eq!(grid(&written)[0], ["a\\\\", "b"]); } #[test] fn a_backslash_before_padding_stays_single() { let text = "| a\\ | b |\n| --- | --- |\n"; - let cell = find(text, 0..text.len() - 1, Aim::First).expect("a cell"); - assert_eq!(&text[cell.range.clone()], "a\\"); - assert!(!closed(text, cell.range.end)); - assert_eq!(kept("a\\", false), "a\\"); + assert_eq!(write(text, 0, 0, "a\\"), text); + } + + #[test] + fn an_emptied_bare_edge_cell_keeps_its_column() { + let text = "alpha | beta\n--- | ---\nc | d\n"; + let rows = |written: &str| grid(written).iter().map(Vec::len).collect::>(); + for (row, column) in [(0, 0), (0, 1), (1, 0), (1, 1)] { + let written = write(text, row, column, ""); + assert_eq!(rows(&written), [2, 2], "{written:?}"); + assert_eq!(grid(&written)[row][column], "", "{written:?}"); + } + assert_eq!(grid(&write(text, 1, 0, ""))[1], ["", "d"]); + let empty = edges(text, 0..5); + assert_eq!(kept_at("", 0, empty), 1, "past its new pipe"); + let last = edges(text, 8..12); + assert_eq!(kept_at("", 0, last), 0, "before its new pipe"); } #[test] fn typed_pipes_shift_later_bytes() { - assert_eq!(kept_at("||x", 2, false), 4); + let open = Edges { + closed: false, + first: false, + last: false, + }; + assert_eq!(kept_at("||x", 2, open), 4); } } diff --git a/src/documents/document/edit.rs b/src/documents/document/edit.rs index a16f738..0fc0295 100644 --- a/src/documents/document/edit.rs +++ b/src/documents/document/edit.rs @@ -27,6 +27,17 @@ pub(super) enum Landing { Cell(Aim), } +impl Landing { + /// The same landing after `edit`, if it survives. + fn moved(self, edit: &Option<(Range, usize)>) -> Option { + Some(match self { + Self::At(at) => Self::At(moved(at, edit)?), + Self::Cell(Aim::Byte(at)) => Self::Cell(Aim::Byte(moved(at, edit)?)), + other => other, + }) + } +} + /// Where byte `at` lies after `edit`, if it survives. pub(super) fn moved(at: usize, edit: &Option<(Range, usize)>) -> Option { match edit { @@ -69,6 +80,7 @@ impl MarkdownDocument { window: &mut Window, cx: &mut Context, ) { + self.waiting = None; let pressed = match ix.map(|ix| self.parsed.blocks.get(ix)) { Some(None) => return log::warn!("markdown document: a press on a block read away"), pressed => pressed.flatten().map(|block| block.range.start), @@ -77,6 +89,19 @@ impl MarkdownDocument { if written.is_some() { self.reparse(true, cx); } + if !self.read_now(cx) && written.is_some() { + // A long write-back reads first; it opens after. + let start = match pressed { + None => Some(None), + Some(start) => moved(start, &written).map(Some), + }; + let (Some(start), Some(landing)) = (start, landing.moved(&written)) else { + return log::warn!("markdown document: the pressed block was written over"); + }; + log::info!("markdown document: a block opens once its text is read"); + self.waiting = Some((start, landing, self.edits + self.writes as u64)); + return; + } if !self.read_now(cx) { return log::warn!("markdown document: pressed while the text is read again"); } @@ -188,6 +213,25 @@ impl MarkdownDocument { cx.notify(); } + /// A block asked open before its text was read, now opened. + pub(super) fn open_waiting(&mut self, window: &mut Window, cx: &mut Context) { + if self.waiting.is_none() || !self.read_now(cx) { + return; + } + let (start, landing, expected) = self.waiting.take().expect("checked above"); + if self.edits != expected { + return log::info!("markdown document: a waiting block lapsed with its text"); + } + let ix = match start { + None => None, + Some(start) => match (self.parsed.blocks.iter()).position(|b| b.range.start == start) { + Some(ix) => Some(ix), + None => return log::warn!("markdown document: the waiting block was read away"), + }, + }; + self.open(ix, landing, window, cx); + } + /// Takes the focus a dropped block still holds. pub(super) fn take_orphan(&mut self, window: &mut Window, cx: &mut Context) { let Some(orphan) = self.orphan.take() else { @@ -231,15 +275,15 @@ impl MarkdownDocument { let typed = open.input.read(cx).text().to_string(); let caret = open.input.read(cx).cursor(); let text = self.editor.read(cx).text(); - let closed = cells::closed(text, open.range.end); + let edges = cells::edges(text, open.range.clone()); let (lead, written) = match open.append { true if typed.is_empty() => return None, true => (joint(text).len(), format!("{}{typed}", joint(text))), - false if open.cell.is_some() => (0, cells::kept(&typed, closed)), + false if open.cell.is_some() => (0, cells::kept(&typed, edges)), false => (0, typed.clone()), }; let caret = match open.cell { - Some(_) => cells::kept_at(&typed, caret, closed), + Some(_) => cells::kept_at(&typed, caret, edges), None => caret, }; let at = open.range.start + lead + caret; diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 7b0e9a8..b81700e 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -109,6 +109,8 @@ pub struct MarkdownDocument { writes: usize, /// A match asked before its text was read; its version. showing: Option<(Range, u64)>, + /// A block asked open before its text was read. + waiting: Option<(Option, edit::Landing, u64)>, look: DocumentLook, /// Typing in the open block, counted. typed: u64, @@ -181,6 +183,7 @@ impl MarkdownDocument { asks: 0, writes: 0, showing: None, + waiting: None, look: DocumentLook::default(), typed: 0, centered: None, @@ -246,7 +249,9 @@ impl MarkdownDocument { let written = match open.append { true if typed.is_empty() => String::new(), true => format!("{}{typed}", joint(text)), - false if open.cell.is_some() => cells::kept(typed, cells::closed(text, open.range.end)), + false if open.cell.is_some() => { + cells::kept(typed, cells::edges(text, open.range.clone())) + } false => typed.to_string(), }; [&text[..open.range.start], &written, &text[open.range.end..]].concat() diff --git a/src/documents/document/place.rs b/src/documents/document/place.rs index e0c0a16..b63ecbb 100644 --- a/src/documents/document/place.rs +++ b/src/documents/document/place.rs @@ -1,6 +1,6 @@ use gpui::{App, Context, Focusable as _, ListOffset, ScrollStrategy, Window, px}; -use super::{MarkdownDocument, Placement}; +use super::{MarkdownDocument, Placement, cells}; use crate::documents::{MarkdownMode, modes::joint}; /// Line and column, from zero, of byte `at` in `text`. @@ -25,7 +25,15 @@ impl MarkdownDocument { true => joint(editor.text()).len(), false => 0, }; - point(&self.text(cx), open.range.start + joined + input.cursor()) + // A cell's caret reads its escaped write-back. + let cursor = match open.cell { + Some(_) => { + let edges = cells::edges(editor.text(), open.range.clone()); + cells::kept_at(input.text(), input.cursor(), edges) + } + None => input.cursor(), + }; + point(&self.text(cx), open.range.start + joined + cursor) } /// The selected Markdown: the source's, or the open block's. diff --git a/src/documents/document/point.rs b/src/documents/document/point.rs index 204e5a6..9ba2617 100644 --- a/src/documents/document/point.rs +++ b/src/documents/document/point.rs @@ -187,7 +187,9 @@ impl MarkdownDocument { let now = (self.parsed.blocks.iter()) .find(|block| block.range.start <= at && at <= block.range.end) .map(|block| self.drawn(block)); - if written.is_some() && pressed.is_some() && now != pressed { + // A cell beside the written one keeps its place. + let beside = pointed.table && now.as_ref().is_some_and(|now| now.0 == TargetBlock::Table); + if written.is_some() && pressed.is_some() && now != pressed && !beside { return log::warn!("markdown document: the write-back changed the pressed block"); } self.editor @@ -220,8 +222,8 @@ impl MarkdownDocument { // A cell's typed pipes shift its bytes. let pressed = match open.cell { Some(_) => { - let closed = cells::closed(self.editor.read(cx).text(), open.range.end); - cells::kept_at(input.text(), pressed, closed) + let edges = cells::edges(self.editor.read(cx).text(), open.range.clone()); + cells::kept_at(input.text(), pressed, edges) } None => pressed, }; diff --git a/src/documents/document/tests/cells.rs b/src/documents/document/tests/cells.rs index cf0e45f..88d4c13 100644 --- a/src/documents/document/tests/cells.rs +++ b/src/documents/document/tests/cells.rs @@ -241,3 +241,62 @@ fn a_right_press_on_the_open_cells_padding_points_at_its_end(cx: &mut TestAppCon ); assert_eq!(opened(&document, cx), "alpha||", "the cell stays open"); } + +#[gpui::test] +fn the_caret_after_a_typed_pipe_reads_the_escaped_cell(cx: &mut TestAppContext) { + for (typed, column) in [("|δΈ­", 5), ("|x", 5)] { + let (document, cx) = open(WORDS, MarkdownMode::Visual, cx); + press_cell(0, 0, cx); + cx.simulate_keystrokes("backspace backspace backspace backspace backspace"); + cx.simulate_input(typed); + let caret = document.read_with(cx, |document, cx| document.caret(cx)); + assert_eq!(caret, (0, column), "{typed}"); + } +} + +#[gpui::test] +fn emptied_bare_edge_cells_keep_the_table(cx: &mut TestAppContext) { + let cases = [ + (0, 0, 5, "| | beta\n--- | ---\nc | d\n"), + (0, 1, 4, "alpha | |\n--- | ---\nc | d\n"), + (1, 0, 1, "alpha | beta\n--- | ---\n| | d\n"), + ]; + for (row, column, chars, written) in cases { + let (document, cx) = open(BARE, MarkdownMode::Visual, cx); + press_cell(row, column, cx); + for _ in 0..chars { + cx.simulate_keystrokes("backspace"); + } + cx.simulate_keystrokes("escape"); + assert_eq!(text(&document, cx), written); + press_cell(1, 1, cx); + assert_eq!(opened(&document, cx), "d", "{written:?} is still a table"); + } +} + +#[gpui::test] +fn a_right_press_beside_an_edited_cell_points(cx: &mut TestAppContext) { + let (document, cx) = open(WORDS, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_cell(0, 0, cx); + cx.simulate_input("X"); + press_cell_with(0, 1, MouseButton::Right, cx); + let at = heard.borrow().last().expect("heard").at; + let written = "| alphaX | beta |\n| --- | --- |\n| c | d |\n"; + assert_eq!(text(&document, cx), written); + assert_eq!(at, written.find("beta").expect("the cell") + 4); +} + +#[gpui::test] +fn tab_in_a_long_document_opens_the_next_cell_once_read(cx: &mut TestAppContext) { + let long = format!("{WORDS}\n```\n{}\n```\n", "x".repeat(super::super::AT_ONCE)); + let (document, cx) = open(&long, MarkdownMode::Visual, cx); + document.update_in(cx, |document, window, cx| document.reveal(0, window, cx)); + super::frames(cx); + press_cell(0, 0, cx); + cx.simulate_input("X"); + cx.simulate_keystrokes("tab"); + cx.run_until_parked(); + assert_eq!(opened(&document, cx), "beta"); + assert!(text(&document, cx).starts_with("| alphaX | beta |")); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 85521b7..9d2b99d 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -230,6 +230,7 @@ impl Render for MarkdownDocument { // A top line asked before its text was read. self.place_top(window, cx); self.show_waiting(window, cx); + self.open_waiting(window, cx); let colors = cx.theme().colors.clone(); let root = div() .size_full()