315 lines
12 KiB
Rust
315 lines
12 KiB
Rust
//! Effective selected-byte ranges for committed source departures and edits.
|
||
//!
|
||
//! 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.
|
||
//! Departure capture requires navigation admission and destination validation,
|
||
//! immediately before replacing/leaving the live editor. Edit capture requires
|
||
//! an admitted edit with a nonempty patch plan. Neither is a passive query:
|
||
//! never use these 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>,
|
||
{
|
||
effective_range(content)
|
||
}
|
||
|
||
/// Capture immediately before a committed indentation edit. Like departure,
|
||
/// this converts Word/Line selections to their effective directional byte range.
|
||
/// The caller restores the transformed range after editing the same Content.
|
||
pub fn capture_for_edit<R>(content: &mut Content<R>) -> Cursor
|
||
where
|
||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||
{
|
||
effective_range(content)
|
||
}
|
||
|
||
fn effective_range<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);
|
||
}
|
||
}
|
||
}
|