diff --git a/crates/oa-desktop/src/app/source_symbols.rs b/crates/oa-desktop/src/app/source_symbols.rs index c6fef7cc..e9a7ecae 100644 --- a/crates/oa-desktop/src/app/source_symbols.rs +++ b/crates/oa-desktop/src/app/source_symbols.rs @@ -529,3 +529,6 @@ impl App { #[cfg(test)] mod tests; + +#[cfg(all(test, debug_assertions))] +mod focus_selection_tests; diff --git a/crates/oa-desktop/src/app/source_symbols/focus_selection_tests.rs b/crates/oa-desktop/src/app/source_symbols/focus_selection_tests.rs new file mode 100644 index 00000000..90a9d8db --- /dev/null +++ b/crates/oa-desktop/src/app/source_symbols/focus_selection_tests.rs @@ -0,0 +1,481 @@ +//! The mounted CPU renderer and App use the same cosmic-text editor engine, but +//! Iced's Content is parameterized by renderer and cannot be shared between them. +//! We hit-test the initial click in the laid-out CPU widget, seed that raw caret +//! in App, and relay the real subsequent SelectWord/SelectLine and editing actions. +//! App's own Content is never replaced or cursor-normalized after selection. This +//! tests the actual controller/content boundary without a window or GPU; native +//! replay remains the check of the complete production renderer/layout path. +use crate::app::Page; +use crate::app::{App, Message}; +use crate::document::Document; +use crate::source_navigation as nav; +use crate::source_workspace::{LIMITS, Workspace}; +use crate::views::source_tests::FontLayout; +use iced::advanced::{Layout, Shell, layout, widget as core_widget}; +use iced::futures::StreamExt; +use iced::keyboard::{self, Key, Modifiers}; +use iced::widget::{button, container, row, text_editor, text_input}; +use iced::{Element, Event, Font, Size, mouse}; +use iced::{Point, Task, widget}; +use oa_core::{Project, source_project::SourceBundle}; +use std::{collections::BTreeMap, sync::Arc, sync::atomic::Ordering}; + +const SOURCE: &str = concat!( + "// oa_contact tail\r\n", + "arcadia 7;\r\n", + "project model \"Selection\" id \"9e8f0000-0000-4000-8000-000000000091\" {}\r\n", +); +const HOST: Size = Size::new(620., 260.); + +fn fixture() -> App { + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]), + }; + let mut app = App::with_project(Project::empty()); + app.document = Document::from_bundle(bundle, LIMITS).unwrap(); + app.source_workspace = Workspace::new(app.document.bundle(), true); + app.selected = app.project().id; + app.refresh_draft(); + app.page = Page::Text; + app.show_source_file(false); + app +} + +fn ui<'a>( + app: &App, + content: &'a text_editor::Content, +) -> Element<'a, Message, iced::Theme, FontLayout> { + let find: Element<'a, Message, iced::Theme, FontLayout> = if app.source_navigation.open { + row![ + text_input("Find", "") + .id("source-find") + .on_input(|s| Message::SourceNavigation(nav::Event::Query(s))), + container(button("Close").on_press(app.source_find_close_message())) + .id("selection-find-close"), + ] + .into() + } else { + widget::Space::new().into() + }; + // Column::push drops zero-height children. Keep both paths mounted even + // while Find is hidden, as the production retained-slot layout does. + widget::Column::from_vec(vec![ + container(find) + .height(if app.source_navigation.open { 32. } else { 0. }) + .into(), + widget::text_editor(content) + .id(app.source_editor_id()) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .height(iced::Fill) + .on_action(Message::SourceEdit) + .into(), + ]) + .width(iced::Fill) + .height(iced::Fill) + .into() +} + +struct Mounted { + content: text_editor::Content, + tree: core_widget::Tree, +} + +// Operations follow widget-provided traversal instead of assuming a fixed +// relationship between layout nodes, transparent containers and state trees. +struct Inspection { + editor: core_widget::Id, + focused: Option, + close_bounds: Option, +} + +impl core_widget::Operation for Inspection { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn core_widget::Operation)) { + operate(self); + } + + fn focusable( + &mut self, + id: Option<&core_widget::Id>, + _: iced::Rectangle, + state: &mut dyn core_widget::operation::Focusable, + ) { + if id == Some(&self.editor) { + assert!( + self.focused.replace(state.is_focused()).is_none(), + "duplicate editor ID" + ); + } + } + + fn container(&mut self, id: Option<&core_widget::Id>, bounds: iced::Rectangle) { + if id == Some(&core_widget::Id::new("selection-find-close")) { + assert!( + self.close_bounds.replace(bounds).is_none(), + "duplicate Close target ID" + ); + } + } +} + +impl Mounted { + fn new() -> Self { + Self { + content: text_editor::Content::with_text(SOURCE), + tree: core_widget::Tree::empty(), + } + } + + fn events(&mut self, app: &App, events: &[(Event, mouse::Cursor)]) -> Vec { + let mut view = ui(app, &self.content); + self.tree.diff(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut self.tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, HOST), + ); + let mut messages = Vec::new(); + for (event, cursor) in events { + let mut shell = Shell::new(&mut messages); + view.as_widget_mut().update( + &mut self.tree, + event, + Layout::new(&node), + *cursor, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::with_size(HOST), + ); + if !matches!(event, Event::Mouse(mouse::Event::ButtonReleased(_))) { + assert!( + shell.is_event_captured(), + "mounted input must be handled: {event:?}" + ); + } + } + messages + } + + fn operation(&mut self, app: &App, mut op: Box) { + let mut view = ui(app, &self.content); + self.tree.diff(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut self.tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, HOST), + ); + loop { + view.as_widget_mut().operate( + &mut self.tree, + Layout::new(&node), + &FontLayout, + op.as_mut(), + ); + match op.finish() { + core_widget::operation::Outcome::Chain(next) => op = next, + core_widget::operation::Outcome::None => break, + core_widget::operation::Outcome::Some(()) => panic!("focus does not emit messages"), + } + } + } + + async fn task(&mut self, app: &App, task: Task) { + if let Some(mut actions) = iced_runtime::task::into_stream(task) { + while let Some(action) = actions.next().await { + let iced_runtime::Action::Widget(op) = action else { + panic!("expected a widget operation") + }; + self.operation(app, op); + } + } + } + + fn inspect(&mut self, app: &App) -> Inspection { + let mut inspection = Inspection { + editor: app.source_editor_id(), + focused: None, + close_bounds: None, + }; + let mut view = ui(app, &self.content); + self.tree.diff(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut self.tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, HOST), + ); + view.as_widget_mut().operate( + &mut self.tree, + Layout::new(&node), + &FontLayout, + &mut inspection, + ); + inspection + } + + fn editor_focused(&mut self, app: &App) -> bool { + self.inspect(app) + .focused + .expect("exactly one editor with the requested ID") + } + + fn close_message(&mut self, app: &App, escape: bool) -> Message { + if escape { + let event = Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(keyboard::key::Named::Escape), + modified_key: Key::Named(keyboard::key::Named::Escape), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), + location: keyboard::Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }); + assert!( + self.events(app, &[(event, mouse::Cursor::Unavailable)]) + .is_empty() + ); + // The existing subscription tests cover the captured-Escape bridge. + // Deliver its App message after this actual input captured the key. + Message::Escape + } else { + let center = self + .inspect(app) + .close_bounds + .expect("the mounted Find Close target exists") + .center(); + let cursor = mouse::Cursor::Available(center); + let mut messages = self.events( + app, + &[ + ( + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + cursor, + ), + ( + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + cursor, + ), + ], + ); + assert_eq!(messages.len(), 1); + let close = messages.remove(0); + assert!(matches!(close, Message::SourceFindClose(..))); + close + } + } + + fn select(&mut self, app: &mut App, line: bool) { + // One mounted update batch avoids delays between Iced's consecutive-click + // timestamps; content actions are applied in their emitted order below. + let at = mouse::Cursor::Available(Point::new(65., 25.)); + let events = (0..if line { 3 } else { 2 }) + .flat_map(|_| { + [ + ( + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + at, + ), + ( + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + at, + ), + ] + }) + .collect::>(); + let messages = self.events(app, &events); + assert_eq!(messages.len(), if line { 3 } else { 2 }); + for (index, message) in messages.into_iter().enumerate() { + let Message::SourceEdit(action) = message else { + panic!("editor action") + }; + if index == 0 { + assert!(matches!(action, text_editor::Action::Click(_))); + self.content.perform(action); + let caret = self.content.cursor(); + assert_eq!(caret.position.line, 0); + assert!( + (4..13).contains(&caret.position.column), + "click must hit inside oa_contact: {caret:?}" + ); + // App's GPU-typed Content has no public layout API. Copy only the + // initial hit-tested caret before any Word/Line selection exists. + app.source_editor.move_to(caret); + } else { + assert_eq!( + action, + if index == 1 { + text_editor::Action::SelectWord + } else { + text_editor::Action::SelectLine + } + ); + self.content.perform(action.clone()); + let _ = app.update(Message::SourceEdit(action)); + } + } + assert_eq!( + self.content.selection().as_deref(), + Some(if line { + "// oa_contact tail" + } else { + "oa_contact" + }) + ); + assert_eq!(app.source_editor.selection(), self.content.selection()); + assert!(self.editor_focused(app)); + } + + fn type_replacement(&mut self, app: &mut App) { + let event = Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Character("x".into()), + modified_key: Key::Character("x".into()), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyX), + location: keyboard::Location::Standard, + modifiers: Modifiers::empty(), + text: Some("x".into()), + repeat: false, + }); + let messages = self.events(app, &[(event, mouse::Cursor::Unavailable)]); + assert_eq!(messages.len(), 1); + let Message::SourceEdit(action) = messages.into_iter().next().unwrap() else { + panic!("typed edit") + }; + assert_eq!( + action, + text_editor::Action::Edit(text_editor::Edit::Insert('x')) + ); + self.content.perform(action.clone()); + let _ = app.update(Message::SourceEdit(action)); + } +} + +#[tokio::test] +async fn word_and_line_selection_survive_find_close_and_escape_then_replace_exact_bytes() { + for line in [false, true] { + for escape in [false, true] { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let mut mounted = Mounted::new(); + mounted.select(&mut app, line); + let selected = app.source_editor.selection(); + let cursor = app.source_editor.cursor(); + // Word/Line selection has coincident raw endpoints. Merely checking + // cursor equality after a move_to roundtrip cannot detect the defect. + assert_eq!(cursor.selection, Some(cursor.position)); + let task = app.update(Message::SourceNavigation(nav::Event::Open(false))); + mounted.task(&app, task).await; + assert!(!mounted.editor_focused(&app)); + assert_eq!(app.source_editor.selection(), selected); + let close = mounted.close_message(&app, escape); + let _ = app.update(close); + let ticket = app + .source_reveal_ticket() + .expect("guarded restoration ticket"); + let task = app.update(Message::SourceViewport(Some(ticket))); + mounted.task(&app, task).await; + assert!(mounted.editor_focused(&app)); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!( + app.source_editor.selection(), + selected, + "selected bytes must survive focus restoration" + ); + assert_eq!(app.source_editor.text(), SOURCE); + mounted.type_replacement(&mut app); + let expected = SOURCE.replacen(selected.as_deref().unwrap(), "x", 1); + assert_eq!(app.source_editor.text(), expected); + assert_eq!(app.source_workspace.text(), expected); + assert_eq!(mounted.content.text(), expected); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + } +} + +#[test] +fn mounted_direct_typing_control_and_old_cursor_roundtrip_discriminate_selection_loss() { + for line in [false, true] { + for old_roundtrip in [false, true] { + let mut app = fixture(); + let mut mounted = Mounted::new(); + mounted.select(&mut app, line); + let selected = app.source_editor.selection().unwrap(); + let interior = app.source_editor.cursor(); + if old_roundtrip { + app.source_editor.move_to(interior); + assert_ne!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + } + mounted.type_replacement(&mut app); + let expected = if old_roundtrip { + let mut text = SOURCE.to_owned(); + text.insert(interior.position.column, 'x'); + text + } else { + SOURCE.replacen(&selected, "x", 1) + }; + assert_eq!(app.source_editor.text(), expected); + assert_eq!(app.source_workspace.text(), expected); + assert_eq!(mounted.content.text(), SOURCE.replacen(&selected, "x", 1)); + } + } +} + +#[tokio::test] +async fn stale_focus_ticket_and_later_raw_input_cannot_steal_focus_or_change_selected_bytes() { + for later_raw_input in [false, true] { + let mut app = fixture(); + let mut mounted = Mounted::new(); + mounted.select(&mut app, false); + let selected = app.source_editor.selection(); + let open = app.update(Message::SourceNavigation(nav::Event::Open(false))); + mounted.task(&app, open).await; + let close = app.source_find_close_message(); + let _ = app.update(close); + let ticket = app.source_reveal_ticket().unwrap(); + let focus = if later_raw_input { + app.update(Message::SourceViewport(Some(ticket))) + } else { + Task::none() + }; + let reopen = app.update(Message::SourceNavigation(nav::Event::Open(false))); + mounted.task(&app, reopen).await; + if later_raw_input { + app.source_symbols + .raw_interaction + .fetch_add(1, Ordering::SeqCst); + mounted.task(&app, focus).await; + } else { + let stale = app.update(Message::SourceViewport(Some(ticket))); + mounted.task(&app, stale).await; + } + assert!(app.source_navigation.open); + assert!(!mounted.editor_focused(&app)); + assert_eq!(app.source_editor.selection(), selected); + assert_eq!(app.source_editor.text(), SOURCE); + } +} + +#[tokio::test] +async fn explicit_cursor_navigation_still_moves_and_selects_requested_line() { + let mut app = fixture(); + let mut mounted = Mounted::new(); + mounted.select(&mut app, false); + let target = text_editor::Cursor { + position: text_editor::Position { line: 1, column: 3 }, + selection: None, + }; + // Clear the previous semantic selection as actual show_source_file does when + // moving to another source buffer, then exercise intentional navigation. + app.show_source_file(false); + app.queue_source_reveal(target, true); + let ticket = app.source_reveal_ticket().unwrap(); + let task = app.update(Message::SourceViewport(Some(ticket))); + mounted.task(&app, task).await; + assert_eq!(app.source_editor.cursor().position, target.position); + assert_eq!(app.source_editor.selection().as_deref(), Some("arcadia 7;")); +}