fix(text): preserve departure selections and scope Escape dismissal

This commit is contained in:
2026-09-12 22:34:16 -07:00
parent 3412d4f369
commit 7f2947d651
6 changed files with 649 additions and 25 deletions
@@ -0,0 +1,295 @@
//! Effective selected-byte ranges for a committed departure from a source view.
//!
//! Iced's `Cursor` omits cosmic-text's Word/Line selection kind. Saving its raw
//! endpoints can therefore save an empty range for a visibly selected word/line.
//! Capture only after navigation admission and destination validation succeed,
//! immediately before replacing/leaving the live editor. Never use this to inspect
//! pending queries, failed/no-op navigation, or restore focus to the same editor.
use iced::advanced::{graphics, text};
use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position};
/// Capture the effective range for restoration on fresh Content via `move_to`.
///
/// A nonempty selection is collapsed to its start in the departing Content; text
/// is not edited. The returned Normal range preserves selected bytes and typing
/// replacement, not Word/Line gesture-extension mode or an interior click point.
/// Unequal raw endpoint direction is preserved; coincident Word/Line endpoints
/// canonically restore with the active cursor at the effective end.
///
/// The renderer constraint is intentional: the inspected Iced graphics editor
/// collapses any selection to its effective start for `Move(Left)`, and its copied
/// selection joins editor lines with LF regardless of physical CRLF/LF endings.
/// Only the selected text is temporarily copied; retained state is one Cursor.
pub fn capture<R>(content: &mut Content<R>) -> Cursor
where
R: text::Renderer<Editor = graphics::text::Editor>,
{
let original = content.cursor();
let Some(selected) = content.selection().filter(|selected| !selected.is_empty()) else {
// Moving an unselected caret Left would be a user-visible navigation.
return original;
};
let active_start = original.selection.is_some_and(|anchor| {
(original.position.line, original.position.column) < (anchor.line, anchor.column)
});
content.perform(Action::Move(Motion::Left));
let start = content.cursor().position;
let end = match selected.rsplit_once('\n') {
Some((prefix, suffix)) => Position {
line: start.line + prefix.bytes().filter(|byte| *byte == b'\n').count() + 1,
column: suffix.len(),
},
None => Position {
line: start.line,
column: start.column + selected.len(),
},
};
// These sums describe an existing selection in this same allocated buffer;
// neither can exceed its line count / existing UTF-8 line length. A checked
// error after collapsing would leave a failed departure with lost selection.
debug_assert!(content.cursor().selection.is_none());
debug_assert!(start.line < content.line_count() && end.line < content.line_count());
debug_assert!((start.line, start.column) < (end.line, end.column));
Cursor {
position: if active_start { start } else { end },
selection: Some(if active_start { end } else { start }),
}
}
#[cfg(test)]
mod tests {
use super::*;
use iced::widget::text_editor::Edit;
use std::sync::Arc;
type EditorContent = Content<iced::Renderer>;
fn at(line: usize, column: usize) -> Position {
Position { line, column }
}
fn range(start: Position, end: Position, active_start: bool) -> Cursor {
Cursor {
position: if active_start { start } else { end },
selection: Some(if active_start { end } else { start }),
}
}
fn caret(content: &mut EditorContent, position: Position) {
content.move_to(Cursor {
position,
selection: None,
});
}
fn roundtrip(
source: &str,
prepare: impl Fn(&mut EditorContent),
selected: &str,
expected: Cursor,
lossy_raw: bool,
) {
let mut departing = EditorContent::with_text(source);
prepare(&mut departing);
assert_eq!(departing.text(), source);
assert_eq!(departing.selection().as_deref(), Some(selected));
let raw = departing.cursor();
// Independent direct-typing control retains the original semantic mode.
let replacement = Action::Edit(Edit::Paste(Arc::new("替🚀".into())));
let mut direct = EditorContent::with_text(source);
prepare(&mut direct);
direct.perform(replacement.clone());
let captured = capture(&mut departing);
assert_eq!(captured, expected);
assert_eq!(
departing.text(),
source,
"capture cannot edit physical source bytes"
);
assert!(departing.cursor().selection.is_none());
let mut restored = EditorContent::with_text(source);
restored.move_to(captured);
assert_eq!(restored.selection().as_deref(), Some(selected));
assert_eq!(restored.text(), source);
restored.perform(replacement);
assert_eq!(
restored.text(),
direct.text(),
"restored range must replace exactly the original selected bytes"
);
assert_eq!(restored.cursor(), direct.cursor());
if lossy_raw {
let mut old = EditorContent::with_text(source);
old.move_to(raw);
assert_ne!(
old.selection().as_deref(),
Some(selected),
"negative control must discriminate raw cursor storage"
);
} else {
assert_eq!(
captured, raw,
"ordinary directional ranges are already canonical"
);
}
}
#[test]
fn unicode_normal_ranges_preserve_forward_and_reverse_direction() {
let source = "先 αβ 救助 🚀 tail\r\n";
for active_start in [false, true] {
let expected = range(
at(0, "先 ".len()),
at(0, "先 αβ 救助 🚀".len()),
active_start,
);
roundtrip(
source,
|content| content.move_to(expected),
"αβ 救助 🚀",
expected,
false,
);
}
}
#[test]
fn repeated_unicode_words_capture_the_selected_occurrence_without_searching() {
let source = "éclair éclair éclair\r\n";
let start = "éclair ".len();
roundtrip(
source,
|content| {
caret(content, at(0, start + "écl".len()));
content.perform(Action::SelectWord);
},
"éclair",
range(at(0, start), at(0, start + "éclair".len()), false),
true,
);
}
#[test]
fn line_selection_expands_past_its_interior_unicode_caret() {
let source = "first\r\n救助 🚀 tail\r\nlast";
roundtrip(
source,
|content| {
caret(content, at(1, "救助 ".len()));
content.perform(Action::SelectLine);
},
"救助 🚀 tail",
range(at(1, 0), at(1, "救助 🚀 tail".len()), false),
true,
);
}
#[test]
fn multiline_ranges_use_copied_lf_and_utf8_columns_for_physical_crlf_and_mixed_endings() {
for source in ["pré α\r\n中 🚀\r\nfin Ω", "pré α\n中 🚀\r\nfin Ω"] {
for active_start in [false, true] {
let expected = range(at(0, "pré ".len()), at(2, "fin ".len()), active_start);
roundtrip(
source,
|content| content.move_to(expected),
"α\n中 🚀\nfin ",
expected,
false,
);
}
}
}
#[test]
fn expanded_word_and_line_modes_capture_multiline_ranges_in_both_directions() {
let source = "one alpha\r\ntwo beta\nthree gamma";
for line in [false, true] {
for reverse in [false, true] {
let (start, end, selected) = match (line, reverse) {
(false, false) => (at(0, 4), at(2, 11), "alpha\ntwo beta\nthree gamma"),
(false, true) => (at(0, 0), at(2, 11), "one alpha\ntwo beta\nthree gamma"),
(true, _) => (at(0, 0), at(2, 11), "one alpha\ntwo beta\nthree gamma"),
};
roundtrip(
source,
|content| {
caret(content, if reverse { at(2, 8) } else { at(0, 6) });
content.perform(if line {
Action::SelectLine
} else {
Action::SelectWord
});
// Iced preserves existing Word/Line mode for Select motions.
content.perform(Action::Select(if reverse {
Motion::DocumentStart
} else {
Motion::DocumentEnd
}));
},
selected,
range(start, end, reverse),
true,
);
}
}
}
#[test]
fn trailing_newline_selection_restores_endpoint_on_the_empty_last_line() {
let source = "雨\r\n🚀\r\n";
let expected = range(at(0, 0), at(2, 0), false);
roundtrip(
source,
|content| content.perform(Action::SelectAll),
"雨\n🚀\n",
expected,
false,
);
}
#[test]
fn absent_and_empty_selection_do_not_move_the_departing_caret() {
for source in ["", "\r\n", "alpha\r\n\r\ntail"] {
for empty_selection in [false, true] {
let mut content = EditorContent::with_text(source);
let position = if source.starts_with("alpha") {
at(1, 0)
} else {
at(0, 0)
};
caret(&mut content, position);
if empty_selection {
content.perform(Action::SelectLine);
assert_eq!(content.selection().as_deref(), Some(""));
} else {
assert!(content.selection().is_none());
}
let raw = content.cursor();
let selected = content.selection();
assert_eq!(capture(&mut content), raw);
assert_eq!(content.cursor(), raw);
assert_eq!(content.selection(), selected);
assert_eq!(content.text(), source);
}
}
for empty_normal in [false, true] {
let mut content = EditorContent::with_text("éclair");
let position = at(0, "écl".len());
content.move_to(Cursor {
position,
selection: empty_normal.then_some(position),
});
let raw = content.cursor();
let selected = content.selection();
assert_eq!(capture(&mut content), raw);
assert_eq!(
content.cursor(),
raw,
"an unselected interior caret must not move left"
);
assert_eq!(content.selection(), selected);
}
}
}