//! 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(content: &mut Content) -> Cursor where R: text::Renderer, { 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; 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); } } }