diff --git a/src/documents/document/apart.rs b/src/documents/document/apart.rs index 7f9f8ae..d42b4ac 100644 --- a/src/documents/document/apart.rs +++ b/src/documents/document/apart.rs @@ -132,9 +132,9 @@ impl MarkdownDocument { // A cell reads as it writes back. let typed = match open.cell { Some(_) => { - let shift = |at: usize| cells::guarded(&typed[..at]).len(); + let shift = |at: usize| cells::kept_at(typed, at); picked = shift(picked.start)..shift(picked.end); - cells::guarded(typed) + cells::kept(typed) } None => typed.to_string(), }; @@ -156,7 +156,7 @@ 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 raw = open.cell.is_some() && cells::guarded(typed) != typed; + let raw = open.cell.is_some() && cells::kept(typed) != 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 2201c00..c870435 100644 --- a/src/documents/document/cells.rs +++ b/src/documents/document/cells.rs @@ -9,7 +9,7 @@ use crate::documents::{ render::CellAt, }; -/// A cell of the text: which, its source, the caret in it. +/// A cell: which, its source, its caret. #[derive(Debug, PartialEq, Eq)] pub(super) struct Cell { pub at: CellAt, @@ -101,7 +101,7 @@ pub(super) fn find(text: &str, block: Range, aim: Aim) -> Option { }) } -/// The cell `by` reaches from `at`; none past the table's edge. +/// The cell `by` reaches from `at`; none past the edge. pub(super) fn beside(text: &str, block: Range, at: CellAt, by: Move) -> Option { let root = file_tree(&text[block]); let tables = tables(&root); @@ -126,7 +126,7 @@ pub(super) fn beside(text: &str, block: Range, at: CellAt, by: Move) -> O }) } -/// A cell's source between its pipes; empty, a place after the opening one. +/// A cell's source; empty, just past its opening pipe. fn written(text: &str, cell: &Node) -> Range { let inside = cell.children().expect("a cell holds inlines"); if let (Some(first), Some(last)) = (inside.first(), inside.last()) { @@ -143,8 +143,8 @@ fn written(text: &str, cell: &Node) -> Range { at..at } -/// `typed` with each bare `|` escaped, as a cell keeps it. -pub(super) fn guarded(typed: &str) -> String { +/// `typed` with each bare `|` escaped. +fn guarded(typed: &str) -> String { let mut out = String::with_capacity(typed.len()); let mut slashes = 0; for ch in typed.chars() { @@ -157,9 +157,27 @@ pub(super) fn guarded(typed: &str) -> String { out } +/// `typed` as a cell keeps it, its closing pipe intact. +pub(super) fn kept(typed: &str) -> String { + let mut out = guarded(typed); + let slashes = out.len() - out.trim_end_matches('\\').len(); + if slashes % 2 == 1 { + out.push('\\'); + } + out +} + +/// Byte `at` of `typed` within `kept(typed)`. +pub(super) fn kept_at(typed: &str, at: usize) -> usize { + match at == typed.len() { + true => kept(typed).len(), + false => guarded(&typed[..at]).len(), + } +} + #[cfg(test)] mod tests { - use super::{Aim, CellAt, Move, beside, find, guarded}; + use super::{Aim, CellAt, Move, beside, find, guarded, kept, kept_at}; const TABLE: &str = "intro\n\n| a | **b** |\n| --- | --- |\n| c | |\n| d | e |\n"; @@ -218,4 +236,27 @@ mod tests { assert_eq!(guarded("a\\|b"), "a\\|b"); assert_eq!(guarded("a\\\\|b"), "a\\\\\\|b"); } + + #[test] + fn a_closing_backslash_keeps_the_next_pipe() { + assert_eq!(kept("a\\"), "a\\\\"); + assert_eq!(kept("a\\\\"), "a\\\\"); + assert_eq!(kept_at("a\\", 2), 3); + assert_eq!(kept_at("a\\b", 2), 2); + let text = "|a|b|\n|---|---|\n|c|d|\n"; + let cell = find(text, 0..text.len() - 1, Aim::First).expect("a cell"); + let written = [ + &text[..cell.range.start], + &kept("a\\"), + &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"); + } + + #[test] + fn typed_pipes_shift_later_bytes() { + assert_eq!(kept_at("||x", 2), 4); + } } diff --git a/src/documents/document/edit.rs b/src/documents/document/edit.rs index 84c0135..11ff397 100644 --- a/src/documents/document/edit.rs +++ b/src/documents/document/edit.rs @@ -234,11 +234,11 @@ impl MarkdownDocument { 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::guarded(&typed)), + false if open.cell.is_some() => (0, cells::kept(&typed)), false => (0, typed.clone()), }; let caret = match open.cell { - Some(_) => cells::guarded(&typed[..caret]).len(), + Some(_) => cells::kept_at(&typed, caret), None => caret, }; let at = open.range.start + lead + caret; diff --git a/src/documents/document/finding.rs b/src/documents/document/finding.rs index feafb7b..11d9ffb 100644 --- a/src/documents/document/finding.rs +++ b/src/documents/document/finding.rs @@ -4,7 +4,11 @@ use std::ops::Range; use gpui::{Context, Window}; -use super::{MarkdownDocument, edit::Landing}; +use super::{ + MarkdownDocument, + cells::{self, Aim}, + edit::Landing, +}; use crate::documents::MarkdownMode; impl MarkdownDocument { @@ -41,12 +45,22 @@ impl MarkdownDocument { // Outside one block: its line. return self.reveal(line(self, cx), window, cx); }; - let start = block.start; - self.open(Some(ix), Landing::End, window, cx); + // A match in a table opens its cell. + let text = self.editor.read(cx).text(); + let landing = match cells::find(text, block.clone(), Aim::Byte(range.start)) { + Some(cell) + if cell.range.start <= range.start && range.end <= cell.range.end => + { + Landing::Cell(Aim::At(cell.at)) + } + Some(_) => return self.reveal(line(self, cx), window, cx), + None => Landing::End, + }; + self.open(Some(ix), landing, window, cx); self.list.scroll_to_reveal_item(ix); if let Some(open) = &self.open { let input = open.input.clone(); - let inside = range.start - start..range.end - start; + let inside = range.start - open.range.start..range.end - open.range.start; input.update(cx, |input, cx| input.select(inside, cx)); } } diff --git a/src/documents/document/lists.rs b/src/documents/document/lists.rs index b9ab5b5..becdcb4 100644 --- a/src/documents/document/lists.rs +++ b/src/documents/document/lists.rs @@ -222,7 +222,7 @@ impl MarkdownDocument { true } - /// Tab or Shift-Tab in the open block; in a cell, the next or previous. + /// Tab or Shift-Tab; in a cell, moves between cells. pub(super) fn block_shift( &mut self, deeper: bool, diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 11b2077..9bb5f88 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -246,7 +246,7 @@ 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::guarded(typed), + false if open.cell.is_some() => cells::kept(typed), false => typed.to_string(), }; [&text[..open.range.start], &written, &text[open.range.end..]].concat() diff --git a/src/documents/document/point.rs b/src/documents/document/point.rs index df93eaa..e8e7754 100644 --- a/src/documents/document/point.rs +++ b/src/documents/document/point.rs @@ -4,7 +4,11 @@ use gpui::{AppContext as _, Context, MouseButton, Pixels, Point, Window}; use markdown::mdast; use super::{ - DocumentEvent, MarkdownDocument, cells::Aim, edit::Landing, edit::moved, parse::Block, + DocumentEvent, MarkdownDocument, + cells::{self, Aim}, + edit::Landing, + edit::moved, + parse::Block, }; use crate::documents::{ markdown::{file_tree, span, walk}, @@ -72,7 +76,8 @@ impl MarkdownDocument { /// Presses on block `ix`'s drawing, read as it was drawn. pub(super) fn on_point(&self, ix: usize, cx: &mut Context) -> OnPoint { let (document, parsed) = (cx.entity().downgrade(), self.parsed.clone()); - let start = parsed.blocks[ix].range.start; + let block = parsed.blocks[ix].range.clone(); + let start = block.start; Rc::new(move |pointed, button, window, cx| { // The drawing counts from its block's start. let at = match &pointed.place { @@ -82,7 +87,10 @@ impl MarkdownDocument { Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from)) } Place::At(at) => Some(*at), - Place::Cell(_) | Place::Block => None, + // A cell's padding lands at its end. + Place::Cell(cell) => cells::find(&parsed.text, block.clone(), Aim::At(*cell)) + .map(|found| found.range.end - start), + Place::Block => None, } .map(|at| start + at); // A press in a table opens only its cell. @@ -175,7 +183,13 @@ impl MarkdownDocument { let Some(open) = &self.open else { return log::warn!("markdown document: a press on a block closed since"); }; - let pressed = open.input.read(cx).offset_for_point(position); + let input = open.input.read(cx); + let pressed = input.offset_for_point(position); + // A cell's typed pipes shift its bytes. + let pressed = match open.cell { + Some(_) => cells::kept_at(input.text(), pressed), + None => pressed, + }; let taken = self.snapshot(cx); let (full, offset, _) = taken.clone(); self.pressed += 1; diff --git a/src/documents/document/tests/cells.rs b/src/documents/document/tests/cells.rs index 3d45268..56fbd8f 100644 --- a/src/documents/document/tests/cells.rs +++ b/src/documents/document/tests/cells.rs @@ -1,24 +1,62 @@ //! A press in a table opens only its cell (R6.1). -use gpui::{Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px}; +use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px}; -use super::{open, opened, text}; -use crate::documents::MarkdownMode; +use super::{open, opened, point::heard, text}; +use crate::documents::{MarkdownDocument, MarkdownMode}; const TABLE: &str = "| a | **bold** |\n| --- | --- |\n| c | |\n"; -/// Presses cell `row`, `column` of the first table near its left. +/// Presses cell `row`, `column` of the first table. fn press_cell(row: usize, column: usize, cx: &mut VisualTestContext) { + press_cell_with(row, column, MouseButton::Left, cx); +} + +fn press_cell_with(row: usize, column: usize, button: MouseButton, cx: &mut VisualTestContext) { let name = format!("markdown-cell-0-{row}-{column}"); let bounds = cx .debug_bounds(Box::leak(name.into_boxed_str())) .expect("the cell is drawn"); let at = point(bounds.left() + px(2.0), bounds.top() + px(2.0)); - cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none()); - cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_down(at, button, Modifiers::none()); + cx.simulate_mouse_up(at, button, Modifiers::none()); cx.run_until_parked(); } +/// Presses the open cell's field `x` past its left. +fn press_field(x: f32, button: MouseButton, cx: &mut VisualTestContext) { + let field = cx + .debug_bounds("markdown-open-cell") + .expect("a cell is open"); + let at = point(field.left() + px(x), field.top() + px(10.0)); + cx.simulate_mouse_down(at, button, Modifiers::none()); + cx.simulate_mouse_up(at, button, Modifiers::none()); + cx.run_until_parked(); +} + +fn caret(document: &Entity, cx: &mut VisualTestContext) -> usize { + document.read_with(cx, |document, cx| { + document + .open + .as_ref() + .expect("open") + .input + .read(cx) + .cursor() + }) +} + +/// Where byte `k` of the open cell lies. +fn x_of(document: &Entity, k: usize, cx: &mut VisualTestContext) -> f32 { + (0..400) + .map(|step| step as f32) + .find(|&x| { + press_field(x, MouseButton::Left, cx); + caret(document, cx) == k + }) + .expect("the byte is drawn") +} + #[gpui::test] fn a_press_opens_its_cell_and_writes_one_undo_step(cx: &mut TestAppContext) { let (document, cx) = open(TABLE, MarkdownMode::Visual, cx); @@ -56,3 +94,80 @@ fn tab_and_arrows_walk_the_cells(cx: &mut TestAppContext) { assert!(document.read_with(cx, |document, _| document.open.is_none())); assert_eq!(text(&document, cx), TABLE); } + +const WORDS: &str = "| alpha | beta |\n| --- | --- |\n| c | d |\n"; + +#[gpui::test] +fn a_press_in_the_open_cell_moves_its_caret_and_keeps_one_step(cx: &mut TestAppContext) { + let (document, cx) = open(WORDS, MarkdownMode::Visual, cx); + press_cell(0, 0, cx); + assert_eq!(caret(&document, cx), 5); + cx.simulate_input("X"); + let x = x_of(&document, 1, cx); + press_field(x, MouseButton::Left, cx); + assert_eq!(caret(&document, cx), 1, "the cell stays open"); + cx.simulate_input("Y"); + cx.simulate_keystrokes("escape"); + assert_eq!( + text(&document, cx), + "| aYlphaX | beta |\n| --- | --- |\n| c | d |\n" + ); + let editor = document.read_with(cx, |document, _| document.editor().clone()); + editor.update(cx, |editor, cx| editor.undo(cx)); + assert_eq!(text(&document, cx), WORDS); +} + +#[gpui::test] +fn a_match_in_a_table_opens_its_cell_selected(cx: &mut TestAppContext) { + let table = "| needle | B |\n| --- | --- |\n| C | tail |\n"; + let (document, cx) = open(table, MarkdownMode::Visual, cx); + let at = table.find("needle").expect("the word"); + document.update_in(cx, |document, window, cx| { + document.show_match(at..at + 6, window, cx) + }); + assert_eq!(opened(&document, cx), "needle"); + let picked = document.read_with(cx, |document, cx| { + document + .open + .as_ref() + .expect("open") + .input + .read(cx) + .selection() + }); + assert_eq!(picked, 0..6); +} + +#[gpui::test] +fn a_closing_backslash_keeps_the_table(cx: &mut TestAppContext) { + let table = "|a|b|\n|---|---|\n|c|d|\n"; + let (document, cx) = open(table, MarkdownMode::Visual, cx); + press_cell(0, 0, cx); + cx.simulate_input("\\"); + cx.simulate_keystrokes("escape"); + assert_eq!(text(&document, cx), "|a\\\\|b|\n|---|---|\n|c|d|\n"); + press_cell(0, 1, cx); + assert_eq!(opened(&document, cx), "b", "still its own cell"); +} + +#[gpui::test] +fn a_right_press_after_typed_pipes_finds_the_link(cx: &mut TestAppContext) { + let (document, cx) = open(WORDS, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_cell(1, 1, cx); + cx.simulate_keystrokes("backspace"); + cx.simulate_input("||[go](https://ely.dev)"); + let x = x_of(&document, 3, cx); + press_field(x, MouseButton::Right, cx); + let target = heard.borrow().last().cloned().expect("heard"); + assert_eq!(target.link.as_deref(), Some("https://ely.dev")); +} + +#[gpui::test] +fn a_right_press_on_a_cell_points_at_it(cx: &mut TestAppContext) { + let (document, cx) = open(WORDS, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_cell_with(1, 1, MouseButton::Right, cx); + let at = heard.borrow().last().expect("heard").at; + assert_eq!(at, WORDS.rfind(" d").expect("the cell") + 2); +} diff --git a/src/documents/render/table.rs b/src/documents/render/table.rs index a27eb36..d6a84c1 100644 --- a/src/documents/render/table.rs +++ b/src/documents/render/table.rs @@ -1,6 +1,8 @@ use std::cell::RefCell; -use gpui::{AnyElement, App, FontWeight, IntoElement, ParentElement, Styled, div, prelude::*}; +use gpui::{ + AnyElement, App, FontWeight, IntoElement, MouseButton, ParentElement, Styled, div, prelude::*, +}; use markdown::mdast::AlignKind; use super::Draw; @@ -9,7 +11,7 @@ use crate::{ theme::{ActiveTheme, Radius}, }; -/// A cell: its table in drawing order, row from the head, column. +/// A cell: table in drawing order, row, column. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct CellAt { pub table: usize, @@ -56,17 +58,25 @@ impl Draw<'_> { .when(row == 0, |cell| { cell.font_weight(FontWeight::MEDIUM) .text_color(colors.fg_muted) - }) - .child(match open { - Some(field) => div().w_full().child(field), - None => div() - .max_w_full() - .flex() - .flex_col() - .gap_1() - .children(self.nodes(blocks, cx)), }); - self.pressed_cell(drawn, at) + match open { + // Presses in the open cell stay with its field. + Some(field) => drawn + .child(div().w_full().child(field)) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation()), + None => self.pressed_cell( + drawn.child( + div() + .max_w_full() + .flex() + .flex_col() + .gap_1() + .children(self.nodes(blocks, cx)), + ), + at, + ), + } }; div() .flex()