diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 9ffdd194..a228b69a 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -2259,10 +2259,16 @@ impl App { if self.connection.take().is_some() { self.status = "Drawing cancelled · Select / move is active".into(); } - self.source_navigation.open = false; - self.source_symbols.open = false; - self.source_symbols.results = None; - self.source_symbols.notice = None; + if restore_source_focus && self.source_navigation.open { + // Dismiss the active Find surface first. Its underlying + // references remain available until a separate Escape. + self.source_navigation.open = false; + } else { + self.source_navigation.open = false; + self.source_symbols.open = false; + self.source_symbols.results = None; + self.source_symbols.notice = None; + } if restore_source_focus { self.restore_source_editor_focus(); } @@ -3261,10 +3267,12 @@ impl App { } } Message::SourceFile(path) => { - if self - .source_workspace - .switch(&path, self.source_editor.cursor()) + if path != self.source_workspace.active + && self.source_workspace.files.contains_key(&path) { + let departure = + crate::source_navigation::selection::capture(&mut self.source_editor); + self.source_workspace.switch(&path, departure); self.show_source_file(true); } } @@ -4178,17 +4186,14 @@ impl App { } fn jump_to_source(&mut self, location: SourceLocation) { - self.source_workspace - .switch(&location.path, self.source_editor.cursor()); - if self.source_workspace.active != location.path { + let Some(target) = self.source_workspace.files.get(&location.path) else { self.status = format!( "{} has not been loaded. Use Load includes or create the missing file.", location.path ); return; - } - self.show_source_file(false); - let source = self.source_workspace.text(); + }; + let source = &target.text; let mut offset = location.span.start.min(source.len()); while !source.is_char_boundary(offset) { offset -= 1; @@ -4198,6 +4203,9 @@ impl App { let column = prefix .rfind('\n') .map_or(offset, |start| offset - start - 1); + let departure = crate::source_navigation::selection::capture(&mut self.source_editor); + self.source_workspace.switch(&location.path, departure); + self.show_source_file(false); self.queue_source_reveal( text_editor::Cursor { position: text_editor::Position { line, column }, @@ -4224,6 +4232,7 @@ impl App { .is_ok_and(|includes| includes.iter().any(|include| include.target == path)) }); let mut changed = bundle.clone(); + let mut changed_entry = None; if !already_included { if self.source_dirty() { return Err( @@ -4243,16 +4252,30 @@ impl App { serde_json::to_string(&path).map_err(|e| e.to_string())? ), ); - self.source_workspace - .switch(&bundle.entry, self.source_editor.cursor()); - self.source_workspace - .edit(entry.clone(), self.source_editor.cursor()); + changed_entry = Some(entry.clone()); + } + // All fallible preparation is complete. The intermediate entry buffer + // is not displayed, so it must not receive the outgoing editor's cursor. + let original_path = self.source_workspace.active.clone(); + let departure = crate::source_navigation::selection::capture(&mut self.source_editor); + if let Some(entry) = changed_entry { + self.source_workspace.switch(&bundle.entry, departure); + let cursor = if self.source_workspace.active == original_path { + departure + } else { + self.source_workspace.files[&self.source_workspace.active].cursor + }; + self.source_workspace.edit(entry, cursor); } changed.files.insert(path.clone(), String::new()); self.source_workspace .discovered(&changed, changed.files.keys().cloned().collect()); - self.source_workspace - .switch(&path, self.source_editor.cursor()); + let cursor = if self.source_workspace.active == original_path { + departure + } else { + self.source_workspace.files[&self.source_workspace.active].cursor + }; + self.source_workspace.switch(&path, cursor); self.source_workspace.new_path.clear(); self.show_source_file(true); self.source_version += 1; diff --git a/crates/oa-desktop/src/app/source_symbols.rs b/crates/oa-desktop/src/app/source_symbols.rs index e9a7ecae..18d394f5 100644 --- a/crates/oa-desktop/src/app/source_symbols.rs +++ b/crates/oa-desktop/src/app/source_symbols.rs @@ -466,6 +466,9 @@ impl App { Some("That source location is no longer available. Run navigation again.".into()); return; } + // The destination is admitted. Capture effective Word/Line ranges only + // now, when this editor is actually about to leave its current position. + let departure = nav::selection::capture(&mut self.source_editor); if let Some(buffer) = self .source_workspace .files @@ -475,7 +478,7 @@ impl App { session: self.source_workspace.session(), path: self.source_workspace.active.clone(), generation: buffer.generation(), - cursor: self.source_editor.cursor(), + cursor: departure, }; self.source_symbols .back @@ -485,8 +488,7 @@ impl App { } self.source_symbols.back.push(origin); } - self.source_workspace - .switch(path, self.source_editor.cursor()); + self.source_workspace.switch(path, departure); self.show_source_file(false); self.queue_source_reveal(cursor, false); } @@ -501,7 +503,7 @@ impl App { return; }; let unchanged = origin.generation == buffer.generation(); - let cursor = if unchanged { + let mut cursor = if unchanged { origin.cursor } else if origin.path == self.source_workspace.active { self.source_editor.cursor() @@ -515,8 +517,11 @@ impl App { ); return; } - self.source_workspace - .switch(&origin.path, self.source_editor.cursor()); + let departure = nav::selection::capture(&mut self.source_editor); + if !unchanged && origin.path == self.source_workspace.active { + cursor = departure; + } + self.source_workspace.switch(&origin.path, departure); self.show_source_file(false); self.queue_source_reveal(cursor, false); self.source_symbols.notice = diff --git a/crates/oa-desktop/src/app/source_symbols/tests.rs b/crates/oa-desktop/src/app/source_symbols/tests.rs index 28cbefb3..b78df953 100644 --- a/crates/oa-desktop/src/app/source_symbols/tests.rs +++ b/crates/oa-desktop/src/app/source_symbols/tests.rs @@ -4,6 +4,8 @@ use oa_core::source_project::SourceBundle; use std::collections::BTreeMap; use std::time::Instant; +mod departure_selection; + const DEFINITIONS: &str = "defs/活動.arcadia"; const USES: &str = "uses.arcadia"; @@ -430,8 +432,17 @@ fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources send(&mut app, Message::SourceSymbolsReady(ready.clone())); assert!(app.source_symbols.results.is_some()); } + let results = app.source_symbols.results.clone(); app.source_navigation.open = true; send(&mut app, Message::Escape); + assert!(app.source_symbols.open && !app.source_navigation.open); + match (&results, &app.source_symbols.results) { + (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), + (None, None) => {} + _ => panic!("Find Escape must retain underlying results"), + } + assert!(app.source_symbols.request.is_none()); + send(&mut app, Message::Escape); assert!(!app.source_symbols.open && !app.source_navigation.open); assert!(app.source_symbols.results.is_none()); assert!(app.source_symbols.notice.is_none()); @@ -895,6 +906,16 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi assert!(matches!(messages[0], Message::Escape)); send(&mut app, messages.remove(0)); } + assert!(app.source_symbols.open && !app.source_navigation.open); + match (&results, &app.source_symbols.results) { + (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), + (None, None) => {} + _ => panic!("Find Escape must retain underlying results"), + } + let mut messages = captured_input_subscription_messages(&app); + assert_eq!(messages.len(), 1, "references still own the next Escape"); + assert!(matches!(messages[0], Message::Escape)); + send(&mut app, messages.remove(0)); assert!(!app.source_symbols.open && !app.source_navigation.open); assert!(app.source_symbols.results.is_none() && app.source_symbols.request.is_none()); if pending { diff --git a/crates/oa-desktop/src/app/source_symbols/tests/departure_selection.rs b/crates/oa-desktop/src/app/source_symbols/tests/departure_selection.rs new file mode 100644 index 00000000..1709d891 --- /dev/null +++ b/crates/oa-desktop/src/app/source_symbols/tests/departure_selection.rs @@ -0,0 +1,279 @@ +use super::*; + +#[test] +fn scoped_escape_retains_references_then_closes_them_without_losing_effective_selection() { + for line in [false, true] { + let mut app = fixture(); + let selected = select_effective_pump(&mut app, line); + let results = finish(&mut app, query::Action::References); + app.source_symbols.notice = Some("Keep this reference notice".into()); + send(&mut app, Message::SourceNavigation(nav::Event::Open(false))); + app.source_navigation.query = "retain 雪".into(); + let cursor = app.source_editor.cursor(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let revision = app.source_workspace.revision(); + + let mut messages = captured_input_subscription_messages(&app); + assert_eq!(messages.len(), 1); + assert!(matches!(messages[0], Message::Escape)); + send(&mut app, messages.remove(0)); + assert!(!app.source_navigation.open); + assert_eq!(app.source_navigation.query, "retain 雪"); + assert!(app.source_symbols.open); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); + assert_eq!( + app.source_symbols.notice.as_deref(), + Some("Keep this reference notice") + ); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + + let mut messages = captured_input_subscription_messages(&app); + assert_eq!(messages.len(), 1); + assert!(matches!(messages[0], Message::Escape)); + send(&mut app, messages.remove(0)); + assert!(!app.source_symbols.open); + assert!(app.source_symbols.results.is_none() && app.source_symbols.notice.is_none()); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + assert!(captured_input_subscription_messages(&app).is_empty()); + assert_eq!(app.source_workspace.bundle(), raw); + assert_eq!(app.source_workspace.revision(), revision); + assert!(app.source_symbols.back.is_empty()); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + + let expected = if line { + raw.files[USES].replacen(&selected, "x", 1) + } else { + raw.files[USES].replacen("source @pump", "source @x", 1) + }; + send( + &mut app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))), + ); + assert_eq!(app.source_editor.text(), expected); + assert_eq!(app.source_workspace.text(), expected); + send(&mut app, Message::SourceUndo(false)); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } +} + +fn select_effective_pump(app: &mut App, line: bool) -> String { + let source = app.source_workspace.text(); + let offset = source.find("source @pump").unwrap() + "source @pu".len(); + app.source_editor.move_to(text_editor::Cursor { + position: position(source, offset), + selection: None, + }); + send( + app, + Message::SourceEdit(if line { + text_editor::Action::SelectLine + } else { + text_editor::Action::SelectWord + }), + ); + let selected = app.source_editor.selection().unwrap(); + assert!(selected.contains("pump")); + let raw = app.source_editor.cursor(); + assert_eq!(raw.selection, Some(raw.position)); + selected +} + +#[test] +fn effective_selection_survives_file_roundtrip_and_definition_back() { + for line in [false, true] { + for definition in [false, true] { + let mut app = fixture(); + let selected = select_effective_pump(&mut app, line); + let accepted = app.document.snapshot(); + let bundle = app.source_workspace.bundle(); + let revision = app.source_workspace.revision(); + if definition { + let results = finish(&mut app, query::Action::Definition); + assert_eq!(results.disposition, query::Disposition::Direct); + reveal(&mut app); + assert_eq!(app.source_workspace.active, DEFINITIONS); + symbol(&mut app, nav::SymbolEvent::Back); + reveal(&mut app); + assert!(app.source_symbols.back.is_empty()); + } else { + show(&mut app, DEFINITIONS); + show(&mut app, USES); + } + assert_eq!(app.source_workspace.active, USES); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + assert_eq!(app.source_workspace.bundle(), bundle); + assert_eq!(app.source_workspace.revision(), revision); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.source_workspace.undo(false) && !app.source_workspace.undo(true)); + assert!(!app.document.can_undo() && !app.document.can_redo()); + let expected = if line { + bundle.files[USES].replacen(&selected, "x", 1) + } else { + bundle.files[USES].replacen("source @pump", "source @x", 1) + }; + send( + &mut app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))), + ); + assert_eq!(app.source_workspace.text(), expected); + assert_eq!(app.source_editor.text(), expected); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } + } +} + +#[test] +fn effective_selection_is_untouched_by_noop_missing_invalid_or_cancelled_navigation() { + for variant in 0..4 { + let mut app = fixture(); + let selected = select_effective_pump(&mut app, false); + let raw_cursor = app.source_editor.cursor(); + let bundle = app.source_workspace.bundle(); + let revision = app.source_workspace.revision(); + let accepted = app.document.snapshot(); + match variant { + 0 => send(&mut app, Message::SourceFile(USES.into())), + 1 => send(&mut app, Message::SourceFile("missing.arcadia".into())), + 2 => app.source_symbol_jump( + DEFINITIONS, + query::Position { + line: 999, + byte_column: 0, + }, + query::Position { + line: 999, + byte_column: 1, + }, + ), + _ => { + let request = begin(&mut app, query::Action::Definition); + let ready = completion(&app, request); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + send(&mut app, Message::SourceFile(USES.into())); + send(&mut app, Message::SourceSymbolsReady(ready)); + reveal(&mut app); + } + } + assert_eq!(app.source_workspace.active, USES); + assert_eq!( + app.source_editor.cursor(), + raw_cursor, + "live selection mode must not normalize" + ); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + assert_eq!(app.source_workspace.bundle(), bundle); + assert_eq!(app.source_workspace.revision(), revision); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } +} + +#[test] +fn changed_active_origin_back_keeps_current_effective_selection() { + let mut app = fixture(); + let old_generation = app.source_workspace.files[USES].generation(); + let old_cursor = app.source_editor.cursor(); + let original = app.source_workspace.text().to_owned(); + app.source_editor + .perform(text_editor::Action::Move(text_editor::Motion::Left)); + app.source_editor.move_to(text_editor::Cursor { + position: text_editor::Position { line: 0, column: 3 }, + selection: None, + }); + send( + &mut app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))), + ); + let mut expected_edit = original; + expected_edit.insert(3, 'x'); + assert_eq!(app.source_workspace.text(), expected_edit); + let selected = select_effective_pump(&mut app, true); + let bundle = app.source_workspace.bundle(); + let revision = app.source_workspace.revision(); + app.source_symbols.back.push(nav::HistoryEntry { + session: app.source_workspace.session(), + path: USES.into(), + generation: old_generation, + cursor: old_cursor, + }); + symbol(&mut app, nav::SymbolEvent::Back); + reveal(&mut app); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + assert!( + app.source_symbols + .notice + .as_deref() + .unwrap() + .contains("draft changed") + ); + assert_eq!(app.source_workspace.bundle(), bundle); + assert_eq!(app.source_workspace.revision(), revision); +} + +#[test] +fn source_creation_preserves_outgoing_selection_and_undisplayed_entry_cursor() { + let mut app = fixture(); + let selected = select_effective_pump(&mut app, true); + let bundle = app.source_workspace.bundle(); + let entry_cursor = app.source_workspace.files[&bundle.entry].cursor; + let accepted = app.document.snapshot(); + send(&mut app, Message::SourceNewPath("new.arcadia".into())); + send(&mut app, Message::SourceCreateFile); + assert_eq!(app.source_workspace.active, "new.arcadia"); + assert_eq!( + app.source_workspace.files[&bundle.entry].cursor, + entry_cursor + ); + assert_eq!(app.source_workspace.files[USES].text, bundle.files[USES]); + assert!( + app.source_workspace.files[&bundle.entry] + .text + .contains("include \"new.arcadia\";") + ); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + show(&mut app, USES); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + select_effective_pump(&mut app, true); + let raw = app.source_editor.cursor(); + send( + &mut app, + Message::SourceNewPath("../invalid.arcadia".into()), + ); + send(&mut app, Message::SourceCreateFile); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), raw); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); +} diff --git a/crates/oa-desktop/src/source_navigation.rs b/crates/oa-desktop/src/source_navigation.rs index ced9ac1b..70e1b8c8 100644 --- a/crates/oa-desktop/src/source_navigation.rs +++ b/crates/oa-desktop/src/source_navigation.rs @@ -2,6 +2,7 @@ mod controller; pub mod interaction; pub mod query; +pub mod selection; pub mod symbols; pub use controller::*; use iced::widget::text_editor::{Cursor, Position}; diff --git a/crates/oa-desktop/src/source_navigation/selection.rs b/crates/oa-desktop/src/source_navigation/selection.rs new file mode 100644 index 00000000..40f24b00 --- /dev/null +++ b/crates/oa-desktop/src/source_navigation/selection.rs @@ -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(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); + } + } +}