Files
ARCADIA-Studio/crates/oa-desktop/src/source_navigation/selection.rs
T

315 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}
}