T21a round 3 fix, after the cap

- TextInput records the selection each edit replaces, where every edit passes;
  the block editor's undo goes back to it, for typing, delete, cut and paste
- TextInput's layout geometry moves to forms/text/geometry.rs
- Reviews: T21a done, fixed after the cap, no fourth review
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 04:03:43 -04:00
1 parent 187f54253e
commit 14a8990793
7 files changed
+109 -74

No files matched your search

+1 -1
View File
@@ -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 |
-8
View File
@@ -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<Self>) {
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>) {
self.settle(true, cx);
+3 -1
View File
@@ -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<usize>)>,
pub(crate) people: Vec<SharedString>,
pub(crate) diagram: Option<RenderDiagram>,
@@ -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 => {
-1
View File
@@ -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<Choice>, Pick) {
+27
View File
@@ -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"
);
}
+68
View File
@@ -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<Bounds<Pixels>> {
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<Bounds<Pixels>> {
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<Point<Pixels>> {
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<Pixels>) -> 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()
}
}
+10 -63
View File
@@ -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<Highlighter>,
disabled: bool,
history: History<Snapshot>,
/// The selection the last edit replaced.
edited_from: Range<usize>,
pub(crate) layout: Option<Layout>,
pub(crate) scroll: Point<Pixels>,
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<Bounds<Pixels>> {
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<Bounds<Pixels>> {
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<usize> {
self.edited_from.clone()
}
pub(crate) fn replace(
&mut self,
range: Range<usize>,
@@ -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<Point<Pixels>> {
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<Pixels>) -> 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<usize>, Highlight)> {
match (&self.highlighter, self.masked) {
(Some(highlight), false) => highlight(&self.text, cx),