Files
ARCADIA-Studio/crates/oa-desktop/src/source_navigation/selection.rs
T
sorokin.oleg.edu 2df7f580aa Make the Text workspace compact and preserve editing context
Replace the oversized header and reference rail with stable editor slots and a retained supporting drawer. Bind diagnostic, result and menu actions to their original publication and visit; preserve effective selections on navigation and dismiss Actions, Find and the drawer one surface per Escape.

Retain exact Windows source captures, eight final gates (871 desktop passes), independent reviews, native selection/rename/recovery trials and the original failures. All 526 tested inputs and staged evidence were byte-checked before commit. Combined preview integration and packaging remain with issue 27; Linux remains paused.
2026-09-12 22:00:36 -07:00

296 lines
11 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 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);
}
}
}