diff --git a/TASKS.md b/TASKS.md index e6204cb..e5b6926 100644 --- a/TASKS.md +++ b/TASKS.md @@ -249,4 +249,4 @@ Per-component lines live in `tasks/`. Tags there: | T20 | 2 | PASS | | | T21a | 1 | FAIL | Enter kept a selection; undo of a split left focus on a gone field; italic inside bold ate the bold; keys went into merged cells; undo kept a changed kind's highlighter | | T21a | 2 | FAIL | undo of typing lost the selection it replaced; Up from below a table landed in a hidden merged cell | -| T21a | 3 | — | pending review | +| T21a | 3 | FAIL | undo after a delete or a paste lost the selection they replaced; fixed after the cap by recording the replaced selection where every TextInput edit passes, verified by a red-green test, no fourth review | diff --git a/src/documents/blocks/edit.rs b/src/documents/blocks/edit.rs index 4070fac..92e48f6 100644 --- a/src/documents/blocks/edit.rs +++ b/src/documents/blocks/edit.rs @@ -174,14 +174,6 @@ impl BlockEditor { } } - /// Where a key went down, before the field acts on it: the start a typing burst's undo goes back to. - pub(crate) fn note_key(&mut self, key: u64, field: usize, cx: &Context) { - let selection = self.blocks[self.index(key)].fields[field] - .read(cx) - .selection(); - self.typed_from = Some((key, field, selection)); - } - /// Records the document before a change to its blocks. pub(crate) fn before_change(&mut self, cx: &mut Context) { self.settle(true, cx); diff --git a/src/documents/blocks/mod.rs b/src/documents/blocks/mod.rs index b94d60e..ea9e5b4 100644 --- a/src/documents/blocks/mod.rs +++ b/src/documents/blocks/mod.rs @@ -76,7 +76,7 @@ pub struct BlockEditor { current: Snapshot, /// The field that last held focus, as block and field. caret: Option<(u64, usize)>, - /// The selection a key went down on, before its field changed. + /// The selection the last edit in a field replaced. typed_from: Option<(u64, usize, std::ops::Range)>, pub(crate) people: Vec, pub(crate) diagram: Option, @@ -288,6 +288,8 @@ impl BlockEditor { .insert(name.clone(), text); } } + let at = self.blocks[ix].fields.iter().position(|each| each == field); + self.typed_from = at.map(|at| (key, at, field.read(cx).edited_from())); self.settle(true, cx); } InputEvent::Focus => { diff --git a/src/documents/blocks/view.rs b/src/documents/blocks/view.rs index c090621..b4b8cb8 100644 --- a/src/documents/blocks/view.rs +++ b/src/documents/blocks/view.rs @@ -114,7 +114,6 @@ impl BlockEditor { cx.stop_propagation(); editor.on_submit(key, window, cx); })) - .capture_key_down(cx.listener(move |editor, _, _, cx| editor.note_key(key, ix, cx))) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child(field.clone()); let slash = move |query: &str, at: usize, caret: usize| -> (Vec, Pick) { diff --git a/src/documents/tests.rs b/src/documents/tests.rs index 4670958..597b3d4 100644 --- a/src/documents/tests.rs +++ b/src/documents/tests.rs @@ -344,3 +344,30 @@ fn up_into_a_table_skips_its_hidden_cells(cx: &mut TestAppContext) { "up lands on the cell that shows in the last row" ); } + +#[gpui::test] +fn undo_of_a_delete_or_a_paste_restores_the_selection(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx); + let selection = |cx: &mut VisualTestContext| { + cx.update(|_, cx| editor.read(cx).blocks[0].fields[0].read(cx).selection()) + }; + select(&editor, 0, 0, 2..4, cx); + cx.simulate_keystrokes("backspace"); + settle(cx); + assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abef")]); + cx.simulate_keystrokes("cmd-z"); + settle(cx); + assert_eq!(selection(cx), 2..4, "undo of a delete selects what it took"); + cx.update(|_, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string("xy".into()))); + select(&editor, 0, 0, 2..4, cx); + cx.simulate_keystrokes("cmd-v"); + settle(cx); + assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abxyef")]); + cx.simulate_keystrokes("cmd-z"); + settle(cx); + assert_eq!( + selection(cx), + 2..4, + "undo of a paste selects what it replaced" + ); +} diff --git a/src/forms/text/geometry.rs b/src/forms/text/geometry.rs new file mode 100644 index 0000000..eb302a4 --- /dev/null +++ b/src/forms/text/geometry.rs @@ -0,0 +1,68 @@ +use gpui::{Bounds, Pixels, Point}; + +use super::TextInput; + +impl TextInput { + /// The caret's box in window coordinates, once laid out. + pub fn caret_bounds(&self) -> Option> { + self.bounds_for(self.cursor()) + } + + /// An offset's box in the window, from the last layout; later offsets clamp to its end. + pub fn bounds_for(&self, offset: usize) -> Option> { + let layout = self.layout.as_ref()?; + let laid = layout + .lines + .last() + .map_or(0, |(start, line)| start + line.len()); + let at = self.position_for(self.display_offset(offset).min(laid))?; + Some(Bounds::new( + layout.bounds.origin + at - self.scroll, + gpui::size(Pixels::ZERO, layout.line_height), + )) + } + + /// Top-left of a display offset, relative to the text's origin. + pub(crate) fn position_for(&self, display: usize) -> Option> { + let layout = self.layout.as_ref()?; + if layout.placeholder { + return Some(Point::default()); + } + let mut top = Pixels::ZERO; + for (start, line) in &layout.lines { + if display <= start + line.len() { + let at = line.position_for_index(display - start, layout.line_height)?; + return Some(gpui::point(at.x, at.y + top)); + } + top += line.size(layout.line_height).height; + } + None + } + + /// The content offset nearest a window position. + pub(crate) fn offset_for_point(&self, point: Point) -> usize { + let Some(layout) = self.layout.as_ref() else { + return 0; + }; + if layout.placeholder { + return 0; + } + let local = point - layout.bounds.origin + self.scroll; + if local.y < Pixels::ZERO { + return 0; + } + let mut top = Pixels::ZERO; + for (start, line) in &layout.lines { + let height = line.size(layout.line_height).height; + if local.y < top + height { + let inside = gpui::point(local.x.max(Pixels::ZERO), local.y - top); + let index = match line.closest_index_for_position(inside, layout.line_height) { + Ok(index) | Err(index) => index, + }; + return self.content_offset(start + index.min(line.len())); + } + top += height; + } + self.text.len() + } +} diff --git a/src/forms/text/mod.rs b/src/forms/text/mod.rs index 64f3fda..69394ab 100644 --- a/src/forms/text/mod.rs +++ b/src/forms/text/mod.rs @@ -1,6 +1,7 @@ mod actions; mod edit; mod element; +mod geometry; mod highlight; mod ime; @@ -61,6 +62,8 @@ pub struct TextInput { highlighter: Option, disabled: bool, history: History, + /// The selection the last edit replaced. + edited_from: Range, pub(crate) layout: Option, pub(crate) scroll: Point, selecting: bool, @@ -109,6 +112,7 @@ impl TextInput { highlighter: None, disabled: false, history: History::default(), + edited_from: 0..0, layout: None, scroll: Point::default(), selecting: false, @@ -250,25 +254,6 @@ impl TextInput { self.restart_blink(cx); } - /// The caret's box in window coordinates, once laid out. - pub fn caret_bounds(&self) -> Option> { - self.bounds_for(self.cursor()) - } - - /// An offset's box in the window, from the last layout; later offsets clamp to its end. - pub fn bounds_for(&self, offset: usize) -> Option> { - let layout = self.layout.as_ref()?; - let laid = layout - .lines - .last() - .map_or(0, |(start, line)| start + line.len()); - let at = self.position_for(self.display_offset(offset).min(laid))?; - Some(Bounds::new( - layout.bounds.origin + at - self.scroll, - gpui::size(Pixels::ZERO, layout.line_height), - )) - } - /// Keeps what `filter` and `max_len` allow of `incoming`. fn admit(&self, incoming: &str, keep: usize) -> String { let allowed: String = incoming @@ -281,6 +266,11 @@ impl TextInput { } } + /// The selection the last edit replaced, for an owner's undo to go back to. + pub(crate) fn edited_from(&self) -> Range { + self.edited_from.clone() + } + pub(crate) fn replace( &mut self, range: Range, @@ -291,6 +281,7 @@ impl TextInput { if self.disabled { return; } + self.edited_from = self.selection.clone(); let incoming = if self.rows.is_none() { incoming.replace(['\n', '\r'], " ") } else { @@ -423,50 +414,6 @@ impl TextInput { } } - /// Top-left of a display offset, relative to the text's origin. - pub(crate) fn position_for(&self, display: usize) -> Option> { - let layout = self.layout.as_ref()?; - if layout.placeholder { - return Some(Point::default()); - } - let mut top = Pixels::ZERO; - for (start, line) in &layout.lines { - if display <= start + line.len() { - let at = line.position_for_index(display - start, layout.line_height)?; - return Some(gpui::point(at.x, at.y + top)); - } - top += line.size(layout.line_height).height; - } - None - } - - /// The content offset nearest a window position. - pub(crate) fn offset_for_point(&self, point: Point) -> usize { - let Some(layout) = self.layout.as_ref() else { - return 0; - }; - if layout.placeholder { - return 0; - } - let local = point - layout.bounds.origin + self.scroll; - if local.y < Pixels::ZERO { - return 0; - } - let mut top = Pixels::ZERO; - for (start, line) in &layout.lines { - let height = line.size(layout.line_height).height; - if local.y < top + height { - let inside = gpui::point(local.x.max(Pixels::ZERO), local.y - top); - let index = match line.closest_index_for_position(inside, layout.line_height) { - Ok(index) | Err(index) => index, - }; - return self.content_offset(start + index.min(line.len())); - } - top += height; - } - self.text.len() - } - pub(crate) fn highlights(&self, cx: &App) -> Vec<(Range, Highlight)> { match (&self.highlighter, self.masked) { (Some(highlight), false) => highlight(&self.text, cx),