diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index e12ccd37..50b38d20 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -20,6 +20,7 @@ mod reference_authoring; mod review_navigation_tests; pub(crate) mod source_rename; mod source_symbols; +pub(crate) mod text_workspace; #[cfg(test)] mod transition_context_tests; #[cfg(test)] @@ -440,8 +441,7 @@ pub enum Message { IncludesLoaded(u64, Result), Lint(u64), Linted(u64, Result, String>), - SourceDiagnostic(usize), - SourceRelated(SourceLocation), + TextWorkspace(text_workspace::Event), SourceViewport(Option), SourceSymbols( crate::source_navigation::SymbolContext, @@ -537,6 +537,7 @@ pub struct App { pub source_navigation: crate::source_navigation::State, source_find_instance: Id, pub source_symbols: crate::source_navigation::SymbolState, + pub text_workspace: text_workspace::State, pub source_rename: source_rename::State, pub(crate) diagram_usages: Option, pub(crate) diagram_navigation_focus: Option, @@ -659,6 +660,7 @@ impl App { source_navigation: crate::source_navigation::State::default(), source_find_instance: Id::new_v4(), source_symbols: crate::source_navigation::SymbolState::default(), + text_workspace: text_workspace::State::default(), source_rename: source_rename::State::default(), diagram_usages: None, diagram_navigation_focus: None, @@ -713,10 +715,35 @@ impl App { fn sync_source(&mut self) { self.sync_source_with_alias_editor(None); } + fn publish_source_diagnostics(&mut self, diagnostics: Vec) { + self.source_diagnostics = diagnostics; + self.text_workspace.publish_diagnostics(self.source_key()); + } + /// A different source destination owns a new navigation/presentation session. + /// Only diagnostics proven current before this identity-only change may be + /// republished for it; stale source locations must not become fresh again. + fn reset_source_navigation_session(&mut self) { + let diagnostics_current = self + .text_workspace + .diagnostic_publication() + .is_some_and(|publication| publication.source() == self.source_key()); + self.source_workspace.reset_navigation_session(); + self.clear_source_presentation(); + if diagnostics_current { + self.publish_source_diagnostics(self.source_diagnostics.clone()); + } + } + fn clear_source_presentation(&mut self) { + let in_flight = self.source_symbols.in_flight; + self.source_symbols = crate::source_navigation::SymbolState::default(); + self.source_symbols.in_flight = in_flight; + self.source_jump = None; + self.text_workspace.reset(); + } fn sync_source_with_alias_editor(&mut self, hint: Option>) { self.source_workspace.accept(self.document.bundle()); self.show_source_file_with_alias_editor(false, hint); - self.source_diagnostics = self.document.compiled().diagnostics.clone(); + self.publish_source_diagnostics(self.document.compiled().diagnostics.clone()); self.source_version += 1; self.lint_requested = None; } @@ -857,7 +884,7 @@ impl App { } } Err(issues) => { - self.source_diagnostics = issues; + self.publish_source_diagnostics(issues); self.status = "Source contains errors. Your draft is preserved.".into(); self.page = Page::Text; false @@ -1013,6 +1040,7 @@ impl App { let navigation_in_flight = self.source_symbols.in_flight; let rename_in_flight = self.source_rename.in_flight; let diagram_lookup_in_flight = self.diagram_lookup_in_flight; + let menu_sequence = self.menu_sequence; let guide = std::mem::take(&mut self.guide); let mut hosted = std::mem::take(&mut self.hosted); hosted.leave_document(); @@ -1027,6 +1055,7 @@ impl App { self.source_symbols.in_flight = navigation_in_flight; self.source_rename.in_flight = rename_in_flight; self.diagram_lookup_in_flight = diagram_lookup_in_flight; + self.menu_sequence = menu_sequence; self.guide = guide; self.path = path; self.status = "Project ready".into(); @@ -1124,7 +1153,7 @@ impl App { pub fn view(&self) -> Element<'_, Message> { let base = crate::context_menu::Popup::new( crate::views::view(self), - crate::model_menu::popup(self), + crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()), ) .into(); crate::source_navigation::interaction::observe( @@ -1192,14 +1221,15 @@ impl App { // Use one captured-Escape bridge: a focused Find input consumes // Escape, while modal priority remains in the ordinary handler. behavior::creation::subscription( - self.behavior.creation.has_pending() - || self.diagram_usages.is_some() - || self.behavior.source_active() - || (self.page == Page::Text - && (self.source_symbols.open - || self.source_navigation.open - || self.source_rename.open()) - && self.architecture.is_none()), + self.text_workspace.menu().is_none() + && (self.behavior.creation.has_pending() + || self.diagram_usages.is_some() + || self.behavior.source_active() + || (self.page == Page::Text + && (self.text_workspace.drawer().is_some() + || self.source_navigation.open + || self.source_rename.open()) + && self.architecture.is_none())), ) .map(|()| Message::Escape), iced::event::listen_with(|event, status, _| { @@ -1249,6 +1279,11 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + // Captured Text commands validate their origin before they can + // invalidate that same origin or pass into existing command handlers. + if let Message::TextWorkspace(event) = message { + return self.text_workspace_event(event); + } // These handlers reject old owners/tokens without changing state. Apply // the same admission before shared camera and allocation boundaries. if !self.source_input_envelope(&message) @@ -1411,6 +1446,7 @@ impl App { } message => message, }; + // Internal results and geometry do not constitute a new user intent. // Process query completion even behind a modal to release its worker slot; // its handler independently refuses navigation outside the live editor. @@ -1423,6 +1459,38 @@ impl App { if let Message::DiagramUsagesReady(completion) = message { return self.diagram_usages_ready(completion); } + if self.text_workspace.menu().is_some() { + if matches!( + message, + Message::Escape | Message::Request(Pending::Close(_)) + ) { + self.text_workspace.dismiss_menu(); + self.source_interaction(); + return Task::none(); + } + // Global subscriptions can receive captured keyboard input. + // A Text menu owns those commands while passive work can finish. + if !matches!( + message, + Message::CheckpointWritten(..) + | Message::CheckpointLoaded(..) + | Message::CheckpointTick + | Message::WindowResized(_) + | Message::CanvasResized(_) + | Message::SourceViewport(_) + | Message::Lint(_) + | Message::Linted(..) + | Message::Saved(_) + | Message::Hosted(crate::hosted::Event::Completed(..)) + | Message::Loaded(_) + | Message::IncludesLoaded(..) + | Message::SavePath(_) + | Message::SourceRecovered(_) + | Message::Exported(_) + ) { + return Task::none(); + } + } if !matches!( &message, Message::CheckpointTick @@ -2190,11 +2258,11 @@ impl App { // Validate the originating canvas before advancing its input // epoch. Actions are then generated with the refreshed context, // while pointer history hidden behind the menu becomes stale. - let Some(opened) = crate::model_menu::Opened::new( - self, - request, - self.menu_sequence.wrapping_add(1), - ) else { + let Some(token) = self.menu_sequence.checked_add(1) else { + self.status = "Reopen the application to start a new menu session.".into(); + return Task::none(); + }; + let Some(opened) = crate::model_menu::Opened::new(self, request, token) else { return Task::none(); }; self.connection = None; @@ -2248,7 +2316,8 @@ impl App { } Message::Escape => { let restore_source_focus = self.source_navigation_available() - && (self.source_navigation.open || self.source_symbols.open); + && (self.source_navigation.open || self.text_workspace.drawer().is_some()); + let dismiss_find_only = restore_source_focus && self.source_navigation.open; if self.behavior.source_back() { self.status = "Returned to the unchanged parent form; no source was created.".into(); @@ -2264,15 +2333,11 @@ impl App { if self.connection.take().is_some() { self.status = "Drawing cancelled · Select / move is active".into(); } - 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; + self.source_navigation.open = false; + // Popup/modal priority was handled above. In the available Text + // workspace, one Escape dismisses Find before the drawer beneath it. + if !dismiss_find_only { + self.text_workspace.show_drawer(None); } if restore_source_focus { self.restore_source_editor_focus(); @@ -3260,7 +3325,7 @@ impl App { .edit(after.clone(), self.source_editor.cursor()); self.source_snapshot = Arc::from(after); self.source_version += 1; - self.source_diagnostics.clear(); + self.publish_source_diagnostics(Vec::new()); let version = self.source_version; return Task::perform( async move { @@ -3325,8 +3390,13 @@ impl App { } self.invalidate_source_rename("Disk recovery changed this source context. Your replacement is retained; close and invoke Rename again."); self.pending_recovery = None; - self.source_diagnostics - .retain(|issue| issue.diagnostic.code != "pending_save"); + let diagnostics = self + .source_diagnostics + .iter() + .filter(|issue| issue.diagnostic.code != "pending_save") + .cloned() + .collect(); + self.publish_source_diagnostics(diagnostics); match receipt.disk { Ok(disk) if !self.source_workspace.applied => { self.open_source_project(disk) @@ -3338,7 +3408,7 @@ impl App { || self.path.as_ref() == Some(&receipt.target) { if self.path.as_ref() != Some(&receipt.target) { - self.source_workspace.reset_navigation_session(); + self.reset_source_navigation_session(); } self.path = Some(receipt.target); self.document.bind_repair_baseline(disk.reachable_bundle()); @@ -3372,7 +3442,7 @@ impl App { self.source_workspace .discovered(&disk.bundle, disk.reachable.clone()); self.source_version += 1; - self.source_diagnostics = disk.diagnostics.clone(); + self.publish_source_diagnostics(disk.diagnostics.clone()); self.disk_project = Some(disk); self.status = "Included files loaded; existing drafts retained.".into(); return self.request_lint(); @@ -3393,21 +3463,13 @@ impl App { self.lint_running = None; if version == self.source_version { match result { - Ok(diagnostics) => self.source_diagnostics = diagnostics, + Ok(diagnostics) => self.publish_source_diagnostics(diagnostics), Err(error) => self.status = format!("Source checks failed: {error}"), } } return self.start_pending_lint(); } - Message::SourceDiagnostic(index) => { - if let Some(issue) = self.source_diagnostics.get(index) { - self.jump_to_source(SourceLocation { - path: issue.path.clone(), - span: issue.diagnostic.span, - }); - } - } - Message::SourceRelated(location) => self.jump_to_source(location), + Message::TextWorkspace(_) => return Task::none(), // checked before generic input Message::SourceViewport(ticket) => return self.source_viewport(ticket), Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it Message::SourceSymbols(_, event) => return self.source_symbol_event(event), @@ -3449,7 +3511,7 @@ impl App { if self.source_workspace.undo(redo) { self.show_source_file(true); self.source_version += 1; - self.source_diagnostics.clear(); + self.publish_source_diagnostics(Vec::new()); return self.request_lint(); } } @@ -3597,7 +3659,7 @@ impl App { self.sync_source(); } if self.path.as_ref() != Some(&receipt.path) { - self.source_workspace.reset_navigation_session(); + self.reset_source_navigation_session(); } self.disk_project = Some(disk); self.path = Some(receipt.path); @@ -4139,7 +4201,7 @@ impl App { self.source_workspace .discovered(&disk.bundle, disk.reachable.clone()); self.show_source_file(false); - self.source_diagnostics = self.document.compiled().diagnostics.clone(); + self.publish_source_diagnostics(self.document.compiled().diagnostics.clone()); self.migration = self.document.compiled().decoded.migration.clone(); if self.migration.is_some() { self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into(); @@ -4159,9 +4221,10 @@ impl App { self.path = Some(path); self.document.bind_repair_baseline(disk.reachable_bundle()); self.source_workspace = Workspace::new(&disk.bundle, false); + self.clear_source_presentation(); self.source_workspace.set_reachable(disk.reachable.clone()); self.show_source_file(false); - self.source_diagnostics = error.diagnostics().to_vec(); + self.publish_source_diagnostics(error.diagnostics().to_vec()); self.source_version += 1; self.lint_requested = None; self.page = Page::Text; @@ -5226,7 +5289,16 @@ mod source_project_tests { assert_eq!(app.project(), &project); assert!(!app.apply_source()); assert_eq!(app.source_diagnostics[0].path, fragment); - send(&mut app, Message::SourceDiagnostic(0)); + send(&mut app, Message::Page(Page::Text)); + let _ = app.show_text_drawer(Some(text_workspace::DrawerTab::Problems)); + let issue = &app.source_diagnostics[0]; + let message = app + .text_diagnostic_message(SourceLocation { + path: issue.path.clone(), + span: issue.diagnostic.span, + }) + .unwrap(); + send(&mut app, message); assert_eq!(app.source_workspace.active, fragment); assert_eq!(app.source_editor.text(), "? invalid source"); send(&mut app, Message::RevertSource); diff --git a/crates/oa-desktop/src/app/hosted.rs b/crates/oa-desktop/src/app/hosted.rs index 7cea3328..41fff3f6 100644 --- a/crates/oa-desktop/src/app/hosted.rs +++ b/crates/oa-desktop/src/app/hosted.rs @@ -219,7 +219,7 @@ impl App { || session.connection.base() != request.connection.base() }) { - self.source_workspace.reset_navigation_session(); + self.reset_source_navigation_session(); } self.hosted.session = Some(Session { connection: request.connection.clone(), @@ -359,7 +359,7 @@ impl App { || session.connection.base() != connection.base() }) { - self.source_workspace.reset_navigation_session(); + self.reset_source_navigation_session(); } self.hosted.session = Some(Session { connection, diff --git a/crates/oa-desktop/src/app/source_rename.rs b/crates/oa-desktop/src/app/source_rename.rs index 131976a9..00e3d620 100644 --- a/crates/oa-desktop/src/app/source_rename.rs +++ b/crates/oa-desktop/src/app/source_rename.rs @@ -215,7 +215,7 @@ impl App { } } - fn rename_admission(&self) -> Result<(), String> { + pub(super) fn rename_admission(&self) -> Result<(), String> { if self.page != Page::Text || self.busy || self.pending.is_some() diff --git a/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs b/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs index 94f5426a..053b0cc1 100644 --- a/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs +++ b/crates/oa-desktop/src/app/source_rename/navigation_union_tests.rs @@ -359,7 +359,7 @@ fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arriv assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job)); send(&mut app, Message::SourceSymbolsReady(source_ready)); assert!(app.source_symbols.in_flight.is_none()); - assert!(!app.source_symbols.open); + assert!(app.text_workspace.drawer().is_none()); send(&mut app, Message::DiagramUsagesReady(old_diagram)); assert_eq!(app.diagram_lookup_in_flight, Some(new_token)); assert!(app.diagram_usages.as_ref().unwrap().loading); diff --git a/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs b/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs index 7362e330..be5f7e0b 100644 --- a/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs +++ b/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs @@ -164,3 +164,112 @@ fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() { assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); first_measurement(&mut app, &found); } + +#[test] +fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() { + use crate::app::text_workspace::{DrawerTab, Event as TextEvent}; + for changed in 0..3 { + let mut app = navigation_fixture(); + app.text_workspace.show_drawer(Some(DrawerTab::Status)); + let origin = app.text_drawer_context(); + let captured = [ + Message::TextWorkspace(TextEvent::ShowDrawer(origin, DrawerTab::Problems)), + Message::TextWorkspace(TextEvent::HideDrawer(origin)), + crate::views::captured_text_drawer_resize(&app), + ]; + match changed { + 0 => app.canvas_document = Id::new_v4(), + 1 => app.source_workspace.reset_navigation_session(), + _ => app.text_workspace.show_drawer(Some(DrawerTab::Status)), + } + let found = navigate(&mut app); + // Keep command availability true to isolate the stale owner check from + // the page guard, while a newer measured navigation is still pending. + app.page = Page::Text; + assert!(app.source_navigation_available()); + let destination = navigation(&app); + let allocation = app.allocation_boundary(); + let camera = app.camera_boundary(); + let allocation_epoch = app.allocation_drag.epoch; + let interaction = app.source_symbols.interaction; + let drawer = app.text_workspace.drawer(); + let height = app.text_workspace.drawer_height(); + let visit = app.text_workspace.drawer_visit(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let status = app.status.clone(); + for message in captured { + send(&mut app, message); + assert_eq!(navigation(&app), destination); + assert!(app.allocation_boundary() == allocation); + assert!(app.camera_boundary() == camera); + assert_eq!(app.allocation_drag.epoch, allocation_epoch); + assert_eq!(app.source_symbols.interaction, interaction); + assert_eq!(app.text_workspace.drawer(), drawer); + assert_eq!(app.text_workspace.drawer_height(), height); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert_eq!(app.status, status); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + app.page = Page::Diagram; + first_measurement(&mut app, &found); + } +} + +#[test] +fn current_compact_drawer_controls_and_mounted_resize_remain_live() { + use crate::app::text_workspace::{DrawerTab, Event as TextEvent}; + let mut app = navigation_fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let show = Message::TextWorkspace(TextEvent::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Status, + )); + send(&mut app, show); + assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status)); + let height = app.text_workspace.drawer_height(); + let resize = crate::views::captured_text_drawer_resize(&app); + send(&mut app, resize); + assert_eq!(app.text_workspace.drawer_height(), height + 60.); + let hide = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())); + send(&mut app, hide); + assert!(app.text_workspace.drawer().is_none()); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn compact_diagram_usages_command_uses_captured_query_and_existing_chooser() { + let mut app = navigation_fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let command = app.text_command_message(crate::app::text_workspace::Command::ShowDiagramUsages); + send(&mut app, command); + let request = app + .source_symbols + .request + .clone() + .expect("actual source query started"); + assert_eq!(app.source_diagram_job, Some(request.job)); + let completion = source_completion(&app, request); + send(&mut app, Message::SourceSymbolsReady(completion)); + let panel = app + .diagram_usages + .as_ref() + .expect("existing guarded chooser opened"); + assert_eq!(panel.context.subject, model_id(2)); + assert_eq!(panel.context.document, app.canvas_document); + assert_eq!(panel.context.revision, app.document.revision()); + assert_eq!(app.diagram_lookup_in_flight, Some(panel.token)); + assert!(!app.source_navigation_available()); + let (_, found) = finish_lookup(&mut app); + assert_eq!(found.surface, usage::Surface::Saved(model_id(8))); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(app.source_symbols.back.is_empty()); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} diff --git a/crates/oa-desktop/src/app/source_rename/tests.rs b/crates/oa-desktop/src/app/source_rename/tests.rs index 60157ff5..afc3fca7 100644 --- a/crates/oa-desktop/src/app/source_rename/tests.rs +++ b/crates/oa-desktop/src/app/source_rename/tests.rs @@ -287,7 +287,7 @@ fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_hist assert_ne!(app.source_navigation.label(), "0 matches"); let mut invalid = original_source.clone(); invalid.files.insert(path.clone(), "?".into()); - app.source_diagnostics = source_workspace::lint(&invalid); + app.publish_source_diagnostics(source_workspace::lint(&invalid)); assert!(!app.source_diagnostics.is_empty()); let version = app.source_version; app.lint_requested = Some(version); @@ -839,7 +839,8 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() { send(&mut app, Message::SourceRenameReady(ready.clone())); } app.source_navigation.open = true; - app.source_symbols.open = true; + app.text_workspace + .show_drawer(Some(crate::app::text_workspace::DrawerTab::References)); match other_modal { 1 => app.hosted.visible = true, 2 => { @@ -866,7 +867,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() { } assert!(!app.source_rename.open()); assert!( - app.source_symbols.open && app.source_navigation.open, + app.text_workspace.drawer().is_some() && app.source_navigation.open, "rename Escape does not fall through" ); if pending { @@ -875,7 +876,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() { assert!(!app.source_rename.open()); } assert_eq!(evidence(&app), before); - app.source_symbols.open = false; + app.text_workspace.show_drawer(None); app.source_navigation.open = false; assert!(captured_escape(&app).is_empty()); } diff --git a/crates/oa-desktop/src/app/source_symbols.rs b/crates/oa-desktop/src/app/source_symbols.rs index 18d394f5..fa44e2ea 100644 --- a/crates/oa-desktop/src/app/source_symbols.rs +++ b/crates/oa-desktop/src/app/source_symbols.rs @@ -33,9 +33,29 @@ fn admission_notice(error: query::AdmissionError) -> String { impl App { pub(super) fn source_input_envelope(&self, message: &Message) -> bool { match message { - Message::SourceSymbols(context, _) => { + Message::SourceSymbols(context, event) => { context.session == self.source_workspace.session() && context.path == self.source_workspace.active + && match event { + nav::SymbolEvent::Choose { stamp, visit, .. } => { + self.text_workspace.drawer() + == Some(text_workspace::DrawerTab::References) + && self.source_navigation_available() + && stamp.snapshot == self.source_key() + && *visit == self.text_workspace.drawer_visit() + && self + .source_symbols + .results + .as_ref() + .is_some_and(|results| results.stamp == *stamp) + && self + .source_symbols + .cache + .as_ref() + .is_some_and(|cache| self.source_snapshot_matches(cache)) + } + _ => true, + } } Message::SourceFindClose(context, instance) => { context == &self.source_symbol_context() @@ -118,6 +138,7 @@ impl App { && self.model_menu.is_none() && self.diagram_usages.is_none() && !self.reference_input_active() + && self.text_workspace.menu().is_none() && self.architecture.is_none() && !self.behavior.has_pending() } @@ -133,6 +154,12 @@ impl App { )) } + /// Capture the same source-owned query used by the keyboard/menu adapter. + pub(super) fn text_diagram_usages_message(&self) -> Option { + self.source_navigation_available() + .then(|| self.source_symbol_message(nav::SymbolEvent::DiagramUsages)) + } + pub fn source_reveal_ticket(&self) -> Option { self.source_symbols .reveal @@ -215,7 +242,8 @@ impl App { match event { nav::SymbolEvent::DiagramUsages => { if self.source_dirty() || !self.source_workspace.applied { - self.source_symbols.open = true; + self.text_workspace + .show_drawer(Some(text_workspace::DrawerTab::References)); self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into()); return Task::none(); } @@ -228,20 +256,30 @@ impl App { return task; } nav::SymbolEvent::Close => { - self.source_symbols.open = false; - self.source_symbols.results = None; - self.source_symbols.notice = None; + return self.show_text_drawer(None); } nav::SymbolEvent::Back => { self.source_symbol_back(); // A successful Back needs no empty results panel. Retain an // already open panel, or reveal an actionable return notice. - self.source_symbols.open |= self.source_symbols.notice.is_some(); + if self.source_symbols.notice.is_some() { + self.text_workspace + .show_drawer(Some(text_workspace::DrawerTab::References)); + let refs = self.text_workspace.references(); + return iced::widget::operation::scroll_to( + self.text_workspace.reference_scroll_id(), + iced::widget::scrollable::AbsoluteOffset { + x: 0., + y: refs.scroll_y(), + }, + ); + } } nav::SymbolEvent::Choose { stamp, reference, index, + .. } => { let Some(results) = self.source_symbols.results.clone() else { return Task::none(); @@ -279,7 +317,8 @@ impl App { } fn start_source_query(&mut self, action: query::Action) -> Task { - self.source_symbols.open = true; + self.text_workspace + .show_drawer(Some(text_workspace::DrawerTab::References)); if !nav::interaction::is_current( &self.source_symbols.raw_interaction, self.source_symbols.raw_interaction.load(Ordering::SeqCst), @@ -300,7 +339,7 @@ impl App { self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) { Some(serial) => serial, None => { - self.source_workspace.reset_navigation_session(); + self.reset_source_navigation_session(); self.source_symbols.back.clear(); self.source_symbols.cache = None; 0 @@ -340,9 +379,12 @@ impl App { }; self.source_symbols.in_flight = Some(request.job); self.source_symbols.request = Some(request.clone()); + self.source_symbols.display_request = Some(request.clone()); + self.text_workspace + .begin_references(request.stamp, request.path.clone()); self.source_symbols.results = None; self.source_symbols.notice = None; - Task::perform( + let worker = Task::perform( async move { let worker_request = request.clone(); let result = tokio::task::spawn_blocking(move || { @@ -373,7 +415,14 @@ impl App { nav::Completion { request, result } }, Message::SourceSymbolsReady, - ) + ); + Task::batch([ + worker, + iced::widget::operation::scroll_to( + self.text_workspace.reference_scroll_id(), + iced::widget::scrollable::AbsoluteOffset { x: 0., y: 0. }, + ), + ]) } pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task { @@ -386,11 +435,11 @@ impl App { if diagram_query { self.source_diagram_job = None; } - let wanted = self + let wanted_navigation = self .source_symbols .request .take() - .is_some_and(|current| current.job == request.job) + .is_some_and(|current| current == request) && request.interaction == self.source_symbols.interaction && request.stamp.snapshot == self.source_key() && nav::interaction::is_current( @@ -400,15 +449,34 @@ impl App { && request.path == self.source_workspace.active && request.cursor == self.source_editor.cursor() && self.source_navigation_available(); - if !wanted { + let wanted_display = self + .source_symbols + .display_request + .as_ref() + .is_some_and(|current| current == &request) + && request.stamp.snapshot == self.source_key(); + if !wanted_display { return Task::none(); } match completion.result { - Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => { + Ok((snapshot, results)) + if self.source_snapshot_matches(&snapshot) + && results.stamp == request.stamp + && results.action == request.action => + { self.source_symbols.cache = Some(snapshot); self.source_symbols.results = Some(results.clone()); - if diagram_query { - if results.disposition == query::Disposition::Direct + self.text_workspace.install_reference_files( + results.stamp, + results + .definitions + .iter() + .map(|row| row.path.clone()) + .chain(results.references.iter().map(|row| row.path.clone())), + ); + if diagram_query && wanted_navigation { + if wanted_navigation + && results.disposition == query::Disposition::Direct && results.scope.validated && results.omitted_definitions == 0 && let [row] = results.definitions.as_slice() @@ -419,7 +487,7 @@ impl App { && crate::diagram_usage::alias(self.project(), identity.id()) == row.declaration.alias { - self.source_symbols.open = false; + self.text_workspace.show_drawer(None); return self.diagram_usage_event(crate::diagram_usage::Event::Open( crate::diagram_usage::Context { document: self.canvas_document, @@ -431,7 +499,8 @@ impl App { self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into()); return Task::none(); } - if results.disposition == query::Disposition::Direct + if wanted_navigation + && results.disposition == query::Disposition::Direct && let Some(row) = results.definitions.first() { self.source_symbol_jump(&row.path, row.position, row.end_position); diff --git a/crates/oa-desktop/src/app/source_symbols/tests.rs b/crates/oa-desktop/src/app/source_symbols/tests.rs index 92b9ed5f..973ee556 100644 --- a/crates/oa-desktop/src/app/source_symbols/tests.rs +++ b/crates/oa-desktop/src/app/source_symbols/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app::text_workspace::{DrawerTab, Event as TextEvent}; use crate::source_workspace::Workspace; use oa_core::source_project::SourceBundle; use std::collections::BTreeMap; @@ -37,6 +38,7 @@ fn fixture() -> App { let mut app = App::with_project(Project::empty()); app.document = Document::from_bundle(fixture_bundle(), LIMITS).unwrap(); app.source_workspace = Workspace::new(app.document.bundle(), true); + app.publish_source_diagnostics(app.document.compiled().diagnostics.clone()); app.selected = app.project().id; app.refresh_draft(); app.page = Page::Text; @@ -50,6 +52,212 @@ fn fixture() -> App { app } +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()) + ); +} + fn send(app: &mut App, message: Message) { // Dropping the returned Task does not poll its worker/lint future. Completion // below is deterministic and still traverses the real App message handler. @@ -61,6 +269,18 @@ fn symbol(app: &mut App, event: nav::SymbolEvent) { send(app, message); } +fn choice(app: &App, stamp: query::QueryStamp, reference: bool, index: usize) -> Message { + app.source_symbol_message(nav::SymbolEvent::Choose { + stamp, + visit: app.text_workspace.drawer_visit(), + reference, + index, + }) +} +fn references_visible(app: &App) -> bool { + app.text_workspace.drawer() == Some(DrawerTab::References) +} + fn show(app: &mut App, path: &str) { send(app, Message::SourceFile(path.into())); reveal(app); @@ -99,6 +319,9 @@ fn begin(app: &mut App, action: query::Action) -> nav::Request { .clone() .expect("request admitted"); assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request)); + assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp)); + assert!(references_visible(app)); request } @@ -165,14 +388,8 @@ fn valid_definition_and_reference_choice_keep_whole_model_and_source_unchanged() "references require a choice" ); let second = uses.references[1].clone(); - symbol( - &mut app, - nav::SymbolEvent::Choose { - stamp: uses.stamp, - reference: true, - index: 1, - }, - ); + let message = choice(&app, uses.stamp, true, 1); + send(&mut app, message); reveal(&mut app); assert_eq!(app.source_workspace.active, USES); assert_eq!( @@ -213,14 +430,8 @@ fn detached_duplicate_requires_an_explicit_choice_even_when_included_source_is_v results.definitions[index].membership, query::Membership::Detached ); - symbol( - &mut app, - nav::SymbolEvent::Choose { - stamp: results.stamp, - reference: false, - index, - }, - ); + let message = choice(&app, results.stamp, false, index); + send(&mut app, message); reveal(&mut app); assert_eq!(app.source_workspace.active, "detached.arcadia"); assert_eq!(app.source_workspace.bundle(), loaded); @@ -268,14 +479,8 @@ fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() { .iter() .position(|row| row.declaration.alias == "pump") .unwrap(); - symbol( - &mut app, - nav::SymbolEvent::Choose { - stamp: results.stamp, - reference: false, - index, - }, - ); + let message = choice(&app, results.stamp, false, index); + send(&mut app, message); reveal(&mut app); symbol(&mut app, nav::SymbolEvent::Back); reveal(&mut app); @@ -300,11 +505,12 @@ fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() { } #[test] -fn completion_rejects_file_edit_session_cursor_interaction_and_raw_input_changes() { +fn completion_retains_current_display_but_rejects_navigation_after_context_changes() { for change in 0..7 { let mut app = fixture(); let request = begin(&mut app, query::Action::Definition); let ready = completion(&app, request.clone()); + let expected = ready.result.as_ref().unwrap().1.clone(); let accepted = app.document.snapshot(); match change { 0 => show(&mut app, "project.arcadia"), @@ -328,23 +534,38 @@ fn completion_rejects_file_edit_session_cursor_interaction_and_raw_input_changes let after = app.source_workspace.bundle(); let active = app.source_workspace.active.clone(); let cursor = app.source_editor.cursor(); + let drawer = app.text_workspace.drawer(); send(&mut app, Message::SourceSymbolsReady(ready)); assert!( app.source_symbols.in_flight.is_none(), "case {change}: release worker slot" ); - assert!( - app.source_symbols.results.is_none(), - "case {change}: stale result surfaced" + if matches!(change, 1 | 2) { + assert!( + app.source_symbols.results.is_none(), + "case {change}: changed source/session cannot own display" + ); + } else { + assert!( + Arc::ptr_eq(app.source_symbols.results.as_ref().unwrap(), &expected), + "case {change}: canceled navigation still owns current-source display" + ); + } + assert_eq!( + app.text_workspace.drawer(), + drawer, + "completion cannot select a tab" ); assert!(app.source_symbols.back.is_empty()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); assert_eq!(app.source_workspace.active, active); assert_eq!(app.source_editor.cursor(), cursor); assert_eq!(app.source_workspace.bundle(), after); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.revision(), 0); + assert!(!app.document.can_undo() && !app.document.can_redo()); } } - #[test] fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_new_results() { let mut app = fixture(); @@ -368,25 +589,41 @@ fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_ne } #[test] -fn close_during_busy_hides_panel_and_completion_does_not_reopen_it() { +fn close_during_busy_retains_hidden_completion_without_reopening_or_navigation() { let mut app = fixture(); let request = begin(&mut app, query::Action::Definition); let ready = completion(&app, request.clone()); - assert!(app.source_symbols.open); + let expected = ready.result.as_ref().unwrap().1.clone(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + assert!(references_visible(&app)); symbol(&mut app, nav::SymbolEvent::Close); - assert!(!app.source_symbols.open); + assert!(app.text_workspace.drawer().is_none()); assert_eq!(app.source_symbols.in_flight, Some(request.job)); assert!(app.source_symbols.request.is_none()); + assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request)); send(&mut app, Message::SourceSymbolsReady(ready)); - assert!(!app.source_symbols.open); + assert!(app.text_workspace.drawer().is_none()); assert!(app.source_symbols.in_flight.is_none()); - assert!(app.source_symbols.results.is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); + assert!(app.source_symbols.back.is_empty()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); let next = begin(&mut app, query::Action::References); assert_ne!(next.job, request.job); - assert!(app.source_symbols.open); + assert!(references_visible(&app)); + assert!( + app.source_symbols.results.is_none(), + "explicit new query replaces prior display ownership" + ); } - #[test] fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal() { let mut app = fixture(); @@ -420,7 +657,7 @@ fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal() } #[test] -fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources() { +fn escape_hides_results_and_cancels_navigation_but_retains_latest_display() { for pending in [false, true] { let mut app = fixture(); let accepted = app.document.snapshot(); @@ -428,32 +665,42 @@ fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources let cursor = app.source_editor.cursor(); let request = begin(&mut app, query::Action::References); let ready = completion(&app, request.clone()); + let expected = ready.result.as_ref().unwrap().1.clone(); if !pending { 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; + let visit = app.text_workspace.drawer_visit(); send(&mut app, Message::Escape); - assert!(app.source_symbols.open && !app.source_navigation.open); + assert!(references_visible(&app) && !app.source_navigation.open); + assert_eq!(app.text_workspace.drawer_visit(), visit); 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()); + reveal(&mut app); 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()); + assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open); + assert_eq!(app.source_symbols.results.is_some(), !pending); assert!(app.source_symbols.request.is_none()); + // Explicit Escape may restore editor focus; consume that ticket before + // delivering completion so any later focus request is discriminated. + reveal(&mut app); if pending { assert_eq!(app.source_symbols.in_flight, Some(request.job)); send(&mut app, Message::SourceSymbolsReady(ready)); } assert!(app.source_symbols.in_flight.is_none()); - assert!(!app.source_symbols.open); - assert!(app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); assert_eq!(app.source_workspace.active, USES); assert_eq!(app.source_editor.cursor(), cursor); assert_eq!(app.source_workspace.bundle(), raw); @@ -462,7 +709,6 @@ fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources assert!(!app.document.can_undo() && !app.document.can_redo()); } } - #[test] fn escape_dismisses_modal_before_underlying_source_results() { let mut app = fixture(); @@ -476,14 +722,17 @@ fn escape_dismisses_modal_before_underlying_source_results() { app.source_reveal_ticket().is_none(), "modal dismissal must not focus the editor" ); - assert!(app.source_symbols.open); + assert!(references_visible(&app)); assert!(Arc::ptr_eq( app.source_symbols.results.as_ref().unwrap(), &results )); send(&mut app, Message::Escape); - assert!(!app.source_symbols.open); - assert!(app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); assert_eq!(app.source_workspace.bundle(), raw); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); } @@ -497,7 +746,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice( finish(&mut app, query::Action::Definition); reveal(&mut app); symbol(&mut app, nav::SymbolEvent::Close); - assert!(!app.source_symbols.open && app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some()); if changed { // Use the workspace edit API to give the origin a new generation. show(&mut app, USES); @@ -517,7 +766,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice( let raw = app.source_workspace.bundle(); symbol(&mut app, nav::SymbolEvent::Back); assert_eq!(app.source_workspace.active, USES); - assert_eq!(app.source_symbols.open, changed); + assert_eq!(references_visible(&app), changed); assert_eq!(app.source_symbols.notice.is_some(), changed); reveal(&mut app); if !changed { @@ -529,7 +778,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice( } let mut app = fixture(); symbol(&mut app, nav::SymbolEvent::Back); - assert!(app.source_symbols.open && app.source_symbols.notice.is_some()); + assert!(references_visible(&app) && app.source_symbols.notice.is_some()); } #[test] @@ -540,12 +789,12 @@ fn find_only_captured_escape_closes_once_and_preserves_raw_query_and_selection() let cursor = app.source_editor.cursor(); app.source_navigation.open = true; app.source_navigation.query = "送水 🚀".into(); - assert!(!app.source_symbols.open); + assert!(!references_visible(&app)); 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 && !app.source_symbols.open); + assert!(!app.source_navigation.open && !references_visible(&app)); assert_eq!(app.source_navigation.query, "送水 🚀"); assert!(app.source_reveal_ticket().is_some()); reveal(&mut app); @@ -790,6 +1039,10 @@ async fn find_close_releases_removed_input_and_guarded_focus_respects_later_inpu /// file dialog or App task is executed by this helper. Require the canvas keyboard /// notification separately, then return every command routed to an Escape owner. fn captured_input_subscription_messages(app: &App) -> Vec { + input_subscription_messages(app, true) +} + +fn input_subscription_messages(app: &App, focused: bool) -> Vec { use iced::advanced::{Layout, Shell, layout, subscription, widget}; use iced::futures::{StreamExt, stream, task::noop_waker_ref}; use iced::keyboard::{self, key::Named}; @@ -805,9 +1058,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec { input .as_widget_mut() .layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size)); - tree.state - .downcast_mut::>() - .focus(); + if focused { + tree.state + .downcast_mut::>() + .focus(); + } let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(Named::Escape), modified_key: keyboard::Key::Named(Named::Escape), @@ -829,7 +1084,8 @@ fn captured_input_subscription_messages(app: &App) -> Vec { &mut shell, &iced::Rectangle::new(iced::Point::ORIGIN, size), ); - assert!(shell.is_event_captured()); + let captured = shell.is_event_captured(); + assert_eq!(captured, focused); assert!(input_messages.is_empty()); assert!( !tree @@ -840,7 +1096,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec { let event = subscription::Event::Interaction { window: iced::window::Id::unique(), event, - status: iced::event::Status::Captured, + status: if captured { + iced::event::Status::Captured + } else { + iced::event::Status::Ignored + }, }; let runtime = tokio::runtime::Builder::new_current_thread() .enable_time() @@ -888,7 +1148,9 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi send(&mut app, Message::SourceSymbolsReady(ready.clone())); } let results = app.source_symbols.results.clone(); + let expected = ready.result.as_ref().unwrap().1.clone(); app.source_navigation.open = true; + let visit = app.text_workspace.drawer_visit(); match modal { 0 => {} 1 => app.hosted.visible = true, @@ -908,7 +1170,12 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi send(&mut app, messages.remove(0)); if modal != 0 { assert!(!app.hosted.visible && app.file_prompt.is_none() && app.pending.is_none()); - assert!(app.source_symbols.open && app.source_navigation.open); + assert!(references_visible(&app) && app.source_navigation.open); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert!( + app.source_reveal_ticket().is_none(), + "modal Escape cannot steal editor focus" + ); match (&results, &app.source_symbols.results) { (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), (None, None) => {} @@ -919,24 +1186,38 @@ 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); + assert!(references_visible(&app) && !app.source_navigation.open); + assert_eq!(app.text_workspace.drawer_visit(), visit); 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_eq!(app.source_symbols.results.is_some(), !pending); + assert!(app.source_reveal_ticket().is_some()); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor); + // One more actual Escape hides the remaining drawer. Both status + // routes emit exactly one App message; no second bridge is needed. + let mut messages = input_subscription_messages(&app, false); + assert_eq!(messages.len(), 1); 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()); + assert!(!references_visible(&app) && !app.source_navigation.open); + assert!(app.source_symbols.request.is_none()); + reveal(&mut app); if pending { assert_eq!(app.source_symbols.in_flight, Some(request.job)); send(&mut app, Message::SourceSymbolsReady(ready)); } assert!(app.source_symbols.in_flight.is_none()); - assert!(!app.source_symbols.open && app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); assert_eq!(app.source_workspace.active, USES); assert_eq!(app.source_editor.cursor(), cursor); assert_eq!(app.source_workspace.bundle(), raw); @@ -953,7 +1234,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi #[test] fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() { let mut app = fixture(); - app.source_symbols.open = true; + app.text_workspace.show_drawer(Some(DrawerTab::References)); let project = app.project().clone(); assert!( app.behavior @@ -983,7 +1264,7 @@ fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_ro assert!(app.architecture.as_ref().unwrap().inline()); // A retained Text panel must not add a second route to inline architecture. app.page = Page::Text; - app.source_symbols.open = true; + app.text_workspace.show_drawer(Some(DrawerTab::References)); let messages = captured_input_subscription_messages(&app); assert_eq!(messages.len(), 1); assert!(matches!( @@ -1203,7 +1484,16 @@ fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message]) .collect(); let results = app.source_symbols.results.clone(); let cache = app.source_symbols.cache.clone(); - let panel = (app.source_symbols.open, app.source_symbols.notice.clone()); + let panel = ( + app.text_workspace.drawer(), + app.source_symbols.notice.clone(), + ); + let display_request = app.source_symbols.display_request.clone(); + let reference_presentation = ( + app.text_workspace.references().stamp(), + app.text_workspace.drawer_visit(), + app.text_workspace.references().scroll_y(), + ); let key = app.source_key(); let active = app.source_workspace.active.clone(); let cursor = app.source_editor.cursor(); @@ -1268,9 +1558,21 @@ fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message]) back ); assert_eq!( - (app.source_symbols.open, app.source_symbols.notice.clone()), + ( + app.text_workspace.drawer(), + app.source_symbols.notice.clone() + ), panel ); + assert_eq!(app.source_symbols.display_request, display_request); + assert_eq!( + ( + app.text_workspace.references().stamp(), + app.text_workspace.drawer_visit(), + app.text_workspace.references().scroll_y() + ), + reference_presentation + ); match (&results, &app.source_symbols.results) { (Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)), (None, None) => {} @@ -1316,11 +1618,7 @@ fn stale_envelope_trial(reset: bool) { app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)), app.source_symbol_message(nav::SymbolEvent::Back), app.source_symbol_message(nav::SymbolEvent::Close), - app.source_symbol_message(nav::SymbolEvent::Choose { - stamp: old_results.stamp, - reference: false, - index: 0, - }), + choice(&app, old_results.stamp, false, 0), ]; if reset { let project = app.project().clone(); @@ -1382,7 +1680,7 @@ fn stale_envelope_trial(reset: bool) { let interaction = app.source_symbols.interaction; symbol(&mut app, nav::SymbolEvent::Close); assert_ne!(app.source_symbols.interaction, interaction); - assert!(!app.source_symbols.open && app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some()); let next = begin(&mut app, query::Action::References); assert_ne!( next.job, current.job, @@ -1535,6 +1833,325 @@ fn process_memory() -> Option<(usize, usize)> { (ok != 0).then_some((counters.working_set, counters.peak_working_set)) } +#[test] +fn delayed_definition_after_hide_or_tab_change_retains_data_without_reopening_or_focus() { + for destination in [None, Some(DrawerTab::Problems), Some(DrawerTab::Status)] { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let request = begin(&mut app, query::Action::Definition); + let ready = completion(&app, request.clone()); + let expected = ready.result.as_ref().unwrap().1.clone(); + assert_eq!(expected.disposition, query::Disposition::Direct); + let event = destination.map_or(TextEvent::HideDrawer(app.text_drawer_context()), |tab| { + TextEvent::ShowDrawer(app.text_drawer_context(), tab) + }); + send(&mut app, Message::TextWorkspace(event)); + assert_eq!(app.text_workspace.drawer(), destination); + assert!(app.source_symbols.request.is_none()); + assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request)); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + let visit = app.text_workspace.drawer_visit(); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); + assert_eq!(app.text_workspace.drawer(), destination); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp)); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + { + let captured = Message::TextWorkspace(TextEvent::ShowDrawer( + app.text_drawer_context(), + DrawerTab::References, + )); + send(&mut app, captured); + }; + assert!(references_visible(&app)); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &expected + )); + assert!(app.source_symbols.in_flight.is_none() && app.source_symbols.request.is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert_eq!(app.source_editor.cursor(), cursor); + } +} + +#[test] +fn same_worker_with_changed_request_envelope_cannot_publish_or_navigate() { + for changed in 0..7 { + let mut app = fixture(); + let request = begin(&mut app, query::Action::Definition); + let mut ready = completion(&app, request.clone()); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + match changed { + 0 => ready.request.stamp.serial += 1, + 1 => ready.request.path = DEFINITIONS.into(), + 2 => ready.request.offset += 1, + 3 => ready.request.cursor.selection = None, + 4 => ready.request.interaction = Id::new_v4(), + 5 => ready.request.raw_interaction += 1, + _ => ready.request.action = query::Action::References, + } + assert_eq!(ready.request.job, request.job); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!( + app.source_symbols.in_flight.is_none(), + "worker completed in case {changed}" + ); + assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request)); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } +} + +#[test] +fn matching_old_worker_cannot_replace_a_newer_display_owner() { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let old = begin(&mut app, query::Action::Definition); + let ready = completion(&app, old.clone()); + let mut latest = old.clone(); + latest.stamp.serial += 1; + latest.job = Id::new_v4(); + // Isolate display ownership from both worker ownership and navigation intent. + app.source_interaction(); + app.source_symbols.display_request = Some(latest.clone()); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert_eq!(app.source_symbols.display_request.as_ref(), Some(&latest)); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.active, USES); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.revision(), 0); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn hidden_completion_checks_exact_bytes_even_when_snapshot_key_was_not_advanced() { + let mut app = fixture(); + let request = begin(&mut app, query::Action::Definition); + let ready = completion(&app, request.clone()); + let accepted = app.document.snapshot(); + { + let captured = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())); + send(&mut app, captured); + }; + // Deliberately violate the normal edit API's key advancement: the exact + // source witness must still reject stale bytes instead of trusting the key. + app.source_workspace + .files + .get_mut(USES) + .unwrap() + .text + .push_str("\r\n// changed bytes"); + assert_eq!(app.source_key(), request.stamp.snapshot); + let changed = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(app.source_symbols.results.is_none()); + assert!(app.text_workspace.drawer().is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.bundle(), changed); + assert_eq!(app.source_editor.cursor(), cursor); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); +} + +#[test] +fn popup_escape_has_no_duplicate_bridge_and_dismissal_preserves_underlying_find_and_drawer() { + for line in [false, true] { + for captured_find_escape in [false, true] { + let mut app = fixture(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let results = finish(&mut app, query::Action::References); + let selected = select_effective_pump(&mut app, line); + let cursor = app.source_editor.cursor(); + let group = TextEvent::ToggleFile { + stamp: results.stamp, + visit: app.text_workspace.drawer_visit(), + path: USES.into(), + }; + send(&mut app, Message::TextWorkspace(group)); + let visit = app.text_workspace.drawer_visit(); + send( + &mut app, + Message::TextWorkspace(TextEvent::ReferenceScrolled { + stamp: results.stamp, + visit, + y: 96., + }), + ); + assert!(app.text_workspace.references().collapsed(USES)); + assert_eq!(app.text_workspace.references().scroll_y(), 96.); + send(&mut app, Message::SourceNavigation(nav::Event::Open(false))); + app.source_navigation.query = "Retain Find 雨".into(); + let open = app.text_menu_message(iced::Point::new(30., 30.)); + send(&mut app, open); + let menu = app.text_workspace_menu().expect("Text popup opened"); + assert!( + captured_input_subscription_messages(&app).is_empty(), + "Popup owns captured Escape; a bridge Escape would also close its underlying UI" + ); + // The popup itself publishes this tokened dismissal on Escape. + send(&mut app, menu.on_close); + assert!(app.text_workspace.menu().is_none()); + assert!(references_visible(&app) && app.source_navigation.open); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + + let mut next = input_subscription_messages(&app, captured_find_escape); + assert_eq!( + next.len(), + 1, + "exactly one message for either Escape status" + ); + assert!(matches!(next[0], Message::Escape)); + send(&mut app, next.remove(0)); + assert!(references_visible(&app) && !app.source_navigation.open); + assert_eq!( + app.text_workspace.drawer_visit(), + visit, + "closing Find does not replace the drawer visit" + ); + assert_eq!(app.source_navigation.query, "Retain Find 雨"); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); + assert!(app.text_workspace.references().collapsed(USES)); + assert_eq!(app.text_workspace.references().scroll_y(), 96.); + assert!(app.source_reveal_ticket().is_some() && app.source_jump.is_none()); + reveal(&mut app); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!( + app.source_editor.selection().as_deref(), + Some(selected.as_str()) + ); + + let mut last = input_subscription_messages(&app, false); + assert_eq!(last.len(), 1); + assert!(matches!(last[0], Message::Escape)); + send(&mut app, last.remove(0)); + assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open); + assert_ne!(app.text_workspace.drawer_visit(), visit); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); + assert!(app.text_workspace.references().collapsed(USES)); + assert_eq!(app.text_workspace.references().scroll_y(), 96.); + assert_eq!(app.source_navigation.query, "Retain Find 雨"); + 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!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(app.source_symbols.back.is_empty()); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + } +} + +#[test] +fn retained_choices_cannot_replay_after_hide_or_references_problems_references() { + for reference in [false, true] { + for hide in [false, true] { + let mut app = fixture(); + let results = finish(&mut app, query::Action::References); + let index = usize::from(reference); + let old = choice(&app, results.stamp, reference, index); + let old_visit = app.text_workspace.drawer_visit(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let event = if hide { + TextEvent::HideDrawer(app.text_drawer_context()) + } else { + TextEvent::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems) + }; + send(&mut app, Message::TextWorkspace(event)); + assert_ne!(app.text_workspace.drawer_visit(), old_visit); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); + assert_stale_envelopes_preserve_current(&mut app, std::slice::from_ref(&old)); + { + let captured = Message::TextWorkspace(TextEvent::ShowDrawer( + app.text_drawer_context(), + DrawerTab::References, + )); + send(&mut app, captured); + }; + assert_ne!(app.text_workspace.drawer_visit(), old_visit); + assert_eq!( + app.source_key(), + results.stamp.snapshot, + "same source isolates the visit guard" + ); + assert_stale_envelopes_preserve_current(&mut app, &[old]); + let fresh = choice(&app, results.stamp, reference, index); + send(&mut app, fresh); + assert!( + app.source_symbols.reveal.is_some(), + "freshly captured reopened choice remains usable" + ); + assert_eq!(app.source_symbols.back.len(), 1); + reveal(&mut app); + let (path, span) = if reference { + let row = &results.references[index]; + (&row.path, row.span) + } else { + let row = &results.definitions[index]; + (&row.path, row.span) + }; + assert_eq!(&app.source_workspace.active, path); + assert_eq!( + app.source_editor.cursor(), + selection(&raw.files[path], span.start, span.end, false) + ); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.revision(), 0); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + } +} + #[test] fn combined_modal_completion_releases_worker_without_navigation_or_model_changes() { for modal in 0..5 { 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 index 0112759c..8fef4ca2 100644 --- a/crates/oa-desktop/src/app/source_symbols/tests/departure_selection.rs +++ b/crates/oa-desktop/src/app/source_symbols/tests/departure_selection.rs @@ -20,7 +20,7 @@ fn scoped_escape_retains_references_then_closes_them_without_losing_effective_se send(&mut app, messages.remove(0)); assert!(!app.source_navigation.open); assert_eq!(app.source_navigation.query, "retain 雪"); - assert!(app.source_symbols.open); + assert!(references_visible(&app)); assert!(Arc::ptr_eq( app.source_symbols.results.as_ref().unwrap(), &results @@ -40,7 +40,7 @@ fn scoped_escape_retains_references_then_closes_them_without_losing_effective_se assert_eq!(messages.len(), 1); assert!(matches!(messages[0], Message::Escape)); send(&mut app, messages.remove(0)); - assert!(!app.source_symbols.open); + assert!(app.text_workspace.drawer().is_none()); assert!(app.source_symbols.results.is_none() && app.source_symbols.notice.is_none()); reveal(&mut app); assert_eq!(app.source_editor.cursor(), cursor); diff --git a/crates/oa-desktop/src/app/text_workspace.rs b/crates/oa-desktop/src/app/text_workspace.rs new file mode 100644 index 00000000..439420d3 --- /dev/null +++ b/crates/oa-desktop/src/app/text_workspace.rs @@ -0,0 +1,643 @@ +//! Text-only presentation and captured command envelopes. Source/model authority +//! remains in the existing App controllers; dispatch this envelope before the +//! generic source_interaction invalidation in App::update. +use super::{App, Message}; +use crate::{context_menu, source_navigation as nav}; +use iced::{ + Point, Task, + widget::{self, text_editor::Cursor}, +}; +use nav::query::{QueryStamp, SnapshotKey}; +use oa_core::{Id, source_project::SourceLocation}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DrawerTab { + References, + Problems, + Status, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Command { + Find, + GoToLine, + Definition, + References, + RenameAlias, + Back, + UndoText, + RedoText, + UndoModel, + RedoModel, + UndoRename, + RevertFile, + ShowProblems, + ShowStatus, + ShowDiagramUsages, +} +impl Command { + fn shortcut(self) -> Option<&'static str> { + match self { + Self::Find => Some("Ctrl F"), + Self::GoToLine => Some("Ctrl G"), + Self::Definition => Some("F12"), + Self::References => Some("Shift F12"), + Self::RenameAlias => Some("F2"), + Self::Back => Some("Alt ←"), + Self::UndoText => Some("Ctrl Z"), + Self::RedoText => Some("Ctrl Y"), + _ => None, + } + } + fn label(self) -> &'static str { + match self { + Self::Find => "Find in file", + Self::GoToLine => "Go to line", + Self::Definition => "Go to definition", + Self::References => "Find references", + Self::RenameAlias => "Rename source alias", + Self::Back => "Back in source", + Self::UndoText => "Undo text edit", + Self::RedoText => "Redo text edit", + Self::UndoModel => "Undo model change", + Self::RedoModel => "Redo model change", + Self::UndoRename => "Undo source rename", + Self::RevertFile => "Revert current file", + Self::ShowProblems => "Source problems", + Self::ShowStatus => "Source status", + Self::ShowDiagramUsages => "Show diagram usages", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CommandContext { + document: Id, + document_revision: u64, + source: SnapshotKey, + path: String, + cursor: Cursor, + interaction: Id, +} +#[derive(Debug, Clone)] +pub struct CommandRequest { + context: CommandContext, + command: Command, +} +#[derive(Debug, Clone)] +pub struct MenuRequest { + context: CommandContext, + anchor: Point, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiagnosticPublication { + generation: Id, + source: SnapshotKey, +} +impl DiagnosticPublication { + pub fn generation(self) -> Id { + self.generation + } + pub fn source(self) -> SnapshotKey { + self.source + } + pub fn target(self, visit: Id, location: SourceLocation) -> DiagnosticTarget { + DiagnosticTarget { + publication: self, + visit, + location, + } + } +} +#[derive(Debug, Clone)] +pub struct DiagnosticTarget { + publication: DiagnosticPublication, + visit: Id, + location: SourceLocation, +} + +/// Authority captured by the rendered drawer, independent of its source rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DrawerContext { + pub document: Id, + pub session: Id, + pub visit: Id, +} + +#[derive(Debug, Clone)] +pub enum Event { + ShowDrawer(DrawerContext, DrawerTab), + HideDrawer(DrawerContext), + ResizeDrawer(DrawerContext, f32), + ToggleFile { + stamp: QueryStamp, + visit: Id, + path: String, + }, + ReferenceScrolled { + stamp: QueryStamp, + visit: Id, + y: f32, + }, + Command(CommandRequest), + OpenMenu(MenuRequest), + DismissMenu(Id), + Invoke { + token: Id, + command: Command, + }, + Diagnostic(DiagnosticTarget), +} + +#[derive(Debug, Clone)] +struct FrozenEntry { + command: Command, + action: Option, +} +#[derive(Debug, Clone)] +enum Action { + Dispatch(Box), + Drawer(DrawerTab), +} +#[derive(Debug, Clone)] +pub struct Menu { + token: Id, + popup_token: u64, + anchor: Point, + context: CommandContext, + entries: Vec, +} +impl Menu { + pub fn token(&self) -> Id { + self.token + } +} + +#[derive(Debug)] +pub struct ReferencePresentation { + stamp: Option, + origin: String, + files: BTreeSet, + installed: bool, + collapsed_files: BTreeSet, + scroll_y: f32, +} +impl Default for ReferencePresentation { + fn default() -> Self { + Self { + stamp: None, + origin: String::new(), + files: BTreeSet::new(), + installed: false, + collapsed_files: BTreeSet::new(), + scroll_y: 0., + } + } +} +impl ReferencePresentation { + pub fn stamp(&self) -> Option { + self.stamp + } + pub fn scroll_y(&self) -> f32 { + self.scroll_y + } + pub fn collapsed(&self, path: &str) -> bool { + self.collapsed_files.contains(path) + } +} + +#[derive(Debug)] +pub struct State { + drawer: Option, + drawer_visit: Id, + drawer_height: f32, + references: ReferencePresentation, + menu: Option, + diagnostic_publication: Option, +} +impl Default for State { + fn default() -> Self { + Self { + drawer: None, + drawer_visit: Id::new_v4(), + drawer_height: 180., + references: ReferencePresentation::default(), + menu: None, + diagnostic_publication: None, + } + } +} +impl State { + pub fn drawer_visit(&self) -> Id { + self.drawer_visit + } + pub fn reference_scroll_id(&self) -> widget::Id { + widget::Id::from(format!("text-references-{}", self.drawer_visit)) + } + pub fn drawer(&self) -> Option { + self.drawer + } + pub fn drawer_height(&self) -> f32 { + self.drawer_height + } + pub fn references(&self) -> &ReferencePresentation { + &self.references + } + pub fn menu(&self) -> Option<&Menu> { + self.menu.as_ref() + } + pub fn diagnostic_publication(&self) -> Option { + self.diagnostic_publication + } + /// Call for EVERY diagnostics replacement or clear, including equal bytes. + pub fn publish_diagnostics(&mut self, source: SnapshotKey) -> DiagnosticPublication { + let publication = DiagnosticPublication { + generation: Id::new_v4(), + source, + }; + self.diagnostic_publication = Some(publication); + publication + } + pub fn reset(&mut self) { + *self = Self::default(); + } + pub fn dismiss_menu(&mut self) { + self.menu = None; + } + pub fn begin_references(&mut self, stamp: QueryStamp, origin: String) { + self.references = ReferencePresentation { + stamp: Some(stamp), + origin, + ..ReferencePresentation::default() + }; + self.show_drawer(Some(DrawerTab::References)); + } + /// Install presentation groups only for the current completed query. Repeated + /// delivery of the same completion cannot undo the user's group choices. + pub fn install_reference_files( + &mut self, + stamp: QueryStamp, + paths: impl IntoIterator, + ) { + let refs = &mut self.references; + if refs.stamp != Some(stamp) || refs.installed { + return; + } + refs.files = paths.into_iter().collect(); + refs.collapsed_files = refs.files.clone(); + let expanded = if refs.files.contains(&refs.origin) { + Some(refs.origin.clone()) + } else { + refs.files.first().cloned() + }; + if let Some(path) = expanded { + refs.collapsed_files.remove(&path); + } + refs.installed = true; + } + pub(super) fn show_drawer(&mut self, drawer: Option) { + self.drawer = drawer; + self.drawer_visit = Id::new_v4(); + } + fn toggle_file(&mut self, stamp: QueryStamp, visit: Id, path: String) -> bool { + let refs = &mut self.references; + if self.drawer != Some(DrawerTab::References) + || self.drawer_visit != visit + || refs.stamp != Some(stamp) + || !refs.files.contains(&path) + { + return false; + } + if !refs.collapsed_files.remove(&path) { + refs.collapsed_files.insert(path); + } + // Collapsing a group can replace a pressed row at the same position. + self.drawer_visit = Id::new_v4(); + true + } + fn reference_scrolled(&mut self, stamp: QueryStamp, visit: Id, y: f32) { + let refs = &mut self.references; + if self.drawer == Some(DrawerTab::References) + && refs.stamp == Some(stamp) + && self.drawer_visit == visit + && y.is_finite() + && y >= 0. + { + refs.scroll_y = y; + } + } +} + +impl App { + pub fn text_drawer_context(&self) -> DrawerContext { + DrawerContext { + document: self.canvas_document, + session: self.source_workspace.session(), + visit: self.text_workspace.drawer_visit(), + } + } + fn text_command_context(&self) -> CommandContext { + CommandContext { + document: self.canvas_document, + document_revision: self.document.revision(), + source: self.source_key(), + path: self.source_workspace.active.clone(), + cursor: self.source_editor.cursor(), + interaction: self.source_symbols.interaction, + } + } + pub fn text_command_message(&self, command: Command) -> Message { + Message::TextWorkspace(Event::Command(CommandRequest { + context: self.text_command_context(), + command, + })) + } + pub fn text_menu_message(&self, anchor: Point) -> Message { + Message::TextWorkspace(Event::OpenMenu(MenuRequest { + context: self.text_command_context(), + anchor, + })) + } + pub fn text_workspace_menu(&self) -> Option> { + let menu = self.text_workspace.menu.as_ref()?; + Some(context_menu::Menu { + token: menu.popup_token, + anchor: menu.anchor, + entries: menu + .entries + .iter() + .flat_map(|entry| { + let mut rows = Vec::with_capacity(2); + if matches!(entry.command, Command::UndoText | Command::ShowProblems) { + rows.push(context_menu::Entry::separator()); + } + rows.push(context_menu::Entry::Action { + label: entry.command.label().into(), + shortcut: entry.command.shortcut().map(str::to_owned), + message: entry.action.as_ref().map(|_| { + Message::TextWorkspace(Event::Invoke { + token: menu.token(), + command: entry.command, + }) + }), + }); + rows + }) + .collect(), + on_close: Message::TextWorkspace(Event::DismissMenu(menu.token())), + }) + } + pub fn text_diagnostic_message(&self, location: SourceLocation) -> Option { + let publication = self.text_workspace.diagnostic_publication?; + (publication.source == self.source_key() + && self.text_workspace.drawer() == Some(DrawerTab::Problems)) + .then(|| { + Message::TextWorkspace(Event::Diagnostic( + publication.target(self.text_workspace.drawer_visit(), location), + )) + }) + } + fn text_command_action(&self, command: Command) -> Option { + // Menu has been taken before dispatch-time admission. This intentionally + // uses navigation admission, not rename admission, for Find/text history. + if !self.source_navigation_available() { + return None; + } + let message = + match command { + Command::Find => Message::SourceNavigation(nav::Event::Open(false)), + Command::GoToLine => Message::SourceNavigation(nav::Event::Open(true)), + Command::Definition => self + .source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::Definition)), + Command::References => self + .source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::References)), + Command::RenameAlias => { + self.rename_admission().ok()?; + self.source_rename_message() + } + Command::Back => { + if !self + .source_symbols + .back + .iter() + .any(|entry| entry.session == self.source_workspace.session()) + { + return None; + } + self.source_symbol_message(nav::SymbolEvent::Back) + } + Command::UndoText => Message::SourceUndo, + Command::RedoText => Message::SourceRedo, + Command::UndoModel | Command::RedoModel => { + if self.source_dirty() || self.draft_dirty() { + return None; + } + if command == Command::UndoModel { + if !self.document.can_undo() { + return None; + } + Message::Undo + } else { + if !self.document.can_redo() { + return None; + } + Message::Redo + } + } + Command::UndoRename => self.source_rename_undo_message()?, + Command::RevertFile => Message::RevertSource, + Command::ShowProblems => return Some(Action::Drawer(DrawerTab::Problems)), + Command::ShowStatus => return Some(Action::Drawer(DrawerTab::Status)), + Command::ShowDiagramUsages => self.text_diagram_usages_message()?, + }; + Some(Action::Dispatch(Box::new(message))) + } + pub(super) fn text_workspace_event(&mut self, event: Event) -> Task { + match event { + Event::OpenMenu(request) => { + if request.context != self.text_command_context() + || !self.source_navigation_available() + || self.text_workspace.menu.is_some() + || !request.anchor.x.is_finite() + || !request.anchor.y.is_finite() + { + return Task::none(); + } + let Some(popup_token) = self.menu_sequence.checked_add(1) else { + return Task::none(); + }; + self.source_interaction(); + let commands = [ + Command::Find, + Command::GoToLine, + Command::Definition, + Command::References, + Command::RenameAlias, + Command::Back, + Command::UndoText, + Command::RedoText, + Command::UndoRename, + Command::UndoModel, + Command::RedoModel, + Command::RevertFile, + Command::ShowProblems, + Command::ShowStatus, + Command::ShowDiagramUsages, + ]; + let entries = commands + .into_iter() + .filter_map(|command| { + let action = self.text_command_action(command); + if command == Command::ShowDiagramUsages && action.is_none() { + None + } else { + Some(FrozenEntry { command, action }) + } + }) + .collect(); + self.menu_sequence = popup_token; + self.text_workspace.menu = Some(Menu { + token: Id::new_v4(), + popup_token, + anchor: request.anchor, + context: self.text_command_context(), + entries, + }); + } + Event::DismissMenu(token) => { + if self + .text_workspace + .menu + .as_ref() + .is_some_and(|menu| menu.token == token) + { + self.text_workspace.menu = None; + self.source_interaction(); + } + } + Event::Invoke { token, command } => { + if !self.text_workspace.menu.as_ref().is_some_and(|menu| { + menu.token == token && menu.context == self.text_command_context() + }) { + return Task::none(); + } + // Validate before closing; consume this invocation only once. + // Never resolve a frozen row against a newly generated menu. + let menu = self.text_workspace.menu.take().unwrap(); + let Some(action) = menu + .entries + .into_iter() + .find(|entry| entry.command == command) + .and_then(|entry| entry.action) + else { + return Task::none(); + }; + if self.text_command_action(command).is_none() { + return Task::none(); + } + return self.dispatch_text_action(action); + } + Event::Command(request) => { + if request.context != self.text_command_context() { + return Task::none(); + } + if let Some(action) = self.text_command_action(request.command) { + return self.dispatch_text_action(action); + } + } + Event::ShowDrawer(context, tab) => { + if context != self.text_drawer_context() || !self.source_navigation_available() { + return Task::none(); + } + return self.show_text_drawer(Some(tab)); + } + Event::HideDrawer(context) => { + if context != self.text_drawer_context() || !self.source_navigation_available() { + return Task::none(); + } + return self.show_text_drawer(None); + } + Event::ResizeDrawer(context, height) => { + if context == self.text_drawer_context() + && self.source_navigation_available() + && height.is_finite() + { + // The view applies the measured editor-allocation bound. + let height = height.clamp(180., 1200.); + if height != self.text_workspace.drawer_height { + self.source_interaction(); + self.text_workspace.drawer_height = height; + } + } + } + Event::ToggleFile { stamp, visit, path } => { + if self.source_navigation_available() + && self.text_workspace.toggle_file(stamp, visit, path) + { + self.source_interaction(); + } + } + Event::ReferenceScrolled { stamp, visit, y } => { + // Passive geometry must neither invalidate pending navigation + // nor reset the editor identity, even while commands are blocked. + self.text_workspace.reference_scrolled(stamp, visit, y); + } + Event::Diagnostic(target) => { + if !self.source_navigation_available() + || self.text_workspace.drawer() != Some(DrawerTab::Problems) + || self.text_workspace.drawer_visit() != target.visit + || self.text_workspace.diagnostic_publication != Some(target.publication) + || target.publication.source != self.source_key() + || !self + .source_workspace + .files + .get(&target.location.path) + .is_some_and(|file| valid_span(&file.text, &target.location)) + { + return Task::none(); + } + self.source_interaction(); + self.jump_to_source(target.location); + } + } + Task::none() + } + fn dispatch_text_action(&mut self, action: Action) -> Task { + match action { + Action::Dispatch(message) => self.update(*message), + Action::Drawer(tab) => self.show_text_drawer(Some(tab)), + } + } + pub(super) fn show_text_drawer(&mut self, drawer: Option) -> Task { + self.source_interaction(); + self.text_workspace.show_drawer(drawer); + if drawer == Some(DrawerTab::References) { + let refs = self.text_workspace.references(); + widget::operation::scroll_to( + self.text_workspace.reference_scroll_id(), + widget::scrollable::AbsoluteOffset { + x: 0., + y: refs.scroll_y(), + }, + ) + } else { + Task::none() + } + } +} + +fn valid_span(text: &str, location: &SourceLocation) -> bool { + let span = location.span; + span.start <= span.end + && span.end <= text.len() + && text.is_char_boundary(span.start) + && text.is_char_boundary(span.end) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod lifecycle_tests; diff --git a/crates/oa-desktop/src/app/text_workspace/lifecycle_tests.rs b/crates/oa-desktop/src/app/text_workspace/lifecycle_tests.rs new file mode 100644 index 00000000..4fc104a3 --- /dev/null +++ b/crates/oa-desktop/src/app/text_workspace/lifecycle_tests.rs @@ -0,0 +1,456 @@ +//! Real lifecycle receipts, with all disk writes confined to unique temp roots. +//! Returned App worker tasks are not polled; completions use the normal captured +//! query input and are delivered deterministically through App::update. +use super::*; +use crate::app::{Page, read_snapshot, write_snapshot}; +use crate::document::Document; +use crate::source_workspace::{LIMITS, Workspace}; +use oa_core::{ + Project, + language::{Diagnostic, Span}, + source_project::{SourceBundle, SourceDiagnostic}, + validation::Severity, +}; +use std::{collections::BTreeMap, path::Path, sync::Arc}; + +const VALID: &str = concat!( + "arcadia 7;\r\n", + "project model \"Lifecycle 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n", + " oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n", + "}\r\n", +); + +fn fixture() -> App { + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([("project.arcadia".into(), VALID.into())]), + }; + let mut app = App::with_project(Project::empty()); + // with_project uses in-memory Home metadata and no recovery directory. + assert!(app.recovery.directory.is_none()); + 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.publish_source_diagnostics(app.document.compiled().diagnostics.clone()); + app +} +fn send(app: &mut App, message: Message) { + let _ = app.update(message); +} +fn save(app: &mut App, path: &Path) { + let receipt = write_snapshot( + app.canvas_document, + app.document.revision(), + app.document.snapshot(), + path.to_owned(), + app.disk_project.clone(), + ) + .unwrap(); + send(app, Message::Saved(Ok(Some(receipt)))); +} +fn reveal(app: &mut App) { + if let Some(ticket) = app.source_reveal_ticket() { + send(app, Message::SourceViewport(Some(ticket))); + } +} +fn query(app: &mut App, action: nav::query::Action) -> nav::Completion { + let message = app.source_symbol_message(nav::SymbolEvent::Query(action)); + send(app, message); + let request = app.source_symbols.request.clone().expect("admitted query"); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + let snapshot = Arc::new(app.with_source_input(|input| { + nav::query::admit_owned(input, nav::query::Limits::default()) + .unwrap() + .capture() + .unwrap() + })); + let results = Arc::new( + snapshot + .query(nav::query::Query { + stamp: request.stamp, + path: &request.path, + byte_offset: request.offset, + action, + }) + .unwrap(), + ); + nav::Completion { + request, + result: Ok((snapshot, results)), + } +} +fn seed_navigation(app: &mut App, pending: bool) -> Option { + let text = app.source_workspace.text(); + let start = text.find("oa_activity assess").unwrap() + "oa_activity ".len(); + let prefix = &text[..start]; + let position = widget::text_editor::Position { + line: prefix.bytes().filter(|byte| *byte == b'\n').count(), + column: prefix.rsplit_once('\n').unwrap().1.len(), + }; + app.source_editor.move_to(Cursor { + position, + selection: None, + }); + let ready = query(app, nav::query::Action::Definition); + send(app, Message::SourceSymbolsReady(ready)); + reveal(app); + assert_eq!(app.source_symbols.back.len(), 1); + let ready = query(app, nav::query::Action::References); + send(app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.results.is_some() && app.source_symbols.cache.is_some()); + assert!(app.source_symbols.display_request.is_some()); + assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References)); + if pending { + Some(query(app, nav::query::Action::Definition)) + } else { + None + } +} +fn warning(app: &App) -> SourceDiagnostic { + let source = app.source_workspace.text(); + let primary = source.find("model").unwrap(); + let related = source.find("assess").unwrap(); + SourceDiagnostic { + path: app.source_workspace.active.clone(), + diagnostic: Diagnostic { + severity: Severity::Warning, + code: "lifecycle_warning".into(), + object_id: None, + field: None, + line: 2, + column: 9, + message: "Retained warning with a related source location".into(), + span: Span { + start: primary, + end: primary + 5, + }, + }, + related: vec![SourceLocation { + path: app.source_workspace.active.clone(), + span: Span { + start: related, + end: related + 6, + }, + }], + } +} +fn warning_messages(app: &App) -> [Message; 2] { + let warning = &app.source_diagnostics[0]; + [ + app.text_diagnostic_message(SourceLocation { + path: warning.path.clone(), + span: warning.diagnostic.span, + }) + .unwrap(), + app.text_diagnostic_message(warning.related[0].clone()) + .unwrap(), + ] +} +fn assert_cleared(app: &App, job: Option) { + assert_eq!(app.source_symbols.in_flight, job); + assert!(app.source_symbols.request.is_none() && app.source_symbols.display_request.is_none()); + assert!(app.source_symbols.results.is_none() && app.source_symbols.cache.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.text_workspace.drawer().is_none() && app.text_workspace.menu().is_none()); + assert!(app.text_workspace.references().stamp().is_none()); +} + +#[test] +fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and_related() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let first_path = first.path().canonicalize().unwrap().join("project.arcadia"); + let second_path = second + .path() + .canonicalize() + .unwrap() + .join("project.arcadia"); + let mut app = fixture(); + save(&mut app, &first_path); + let ready = seed_navigation(&mut app, true).unwrap(); + let job = ready.request.job; + app.publish_source_diagnostics(vec![warning(&app)]); + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + let old = warning_messages(&app); + let warnings = app.source_diagnostics.clone(); + let publication = app.text_workspace.diagnostic_publication().unwrap(); + let source = app.source_key(); + let accepted = app.document.snapshot(); + let revision = app.document.revision(); + let raw = app.source_workspace.bundle(); + save(&mut app, &second_path); + assert_eq!(app.path.as_deref(), Some(second_path.as_path())); + assert_ne!(app.source_key().workspace, source.workspace); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert_eq!(app.document.revision(), revision); + assert_eq!(std::fs::read_to_string(&first_path).unwrap(), VALID); + assert_eq!(std::fs::read_to_string(&second_path).unwrap(), VALID); + assert_eq!(app.source_diagnostics, warnings); + let current = app + .text_workspace + .diagnostic_publication() + .expect("identity-only save retained current warnings"); + assert_eq!(current.source(), app.source_key()); + assert_ne!(current.generation(), publication.generation()); + assert_cleared(&app, Some(job)); + for message in old { + let interaction = app.source_symbols.interaction; + send(&mut app, message); + assert_eq!(app.source_symbols.interaction, interaction); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + } + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + for message in warning_messages(&app) { + send(&mut app, message); + assert!( + app.source_symbols.reveal.is_some(), + "fresh primary and related warnings remain actionable" + ); + reveal(&mut app); + } + let cursor = app.source_editor.cursor(); + send(&mut app, Message::SourceSymbolsReady(ready)); + assert!(app.source_symbols.in_flight.is_none()); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_preserving_worker() +{ + for pending in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let path = directory + .path() + .canonicalize() + .unwrap() + .join("broken.arcadia"); + let invalid = format!( + "{} include \"missing.arcadia\";\r\n}}\r\n", + VALID.strip_suffix("}\r\n").unwrap() + ); + std::fs::write(&path, &invalid).unwrap(); + let mut app = fixture(); + let ready = seed_navigation(&mut app, pending); + let job = ready.as_ref().map(|ready| ready.request.job); + app.publish_source_diagnostics(vec![warning(&app)]); + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + let old = warning_messages(&app); + let old_publication = app.text_workspace.diagnostic_publication().unwrap(); + let old_source = app.source_key(); + let accepted = app.document.snapshot(); + let open_menu = app.text_menu_message(Point::new(20., 20.)); + send(&mut app, open_menu); + assert!(app.text_workspace.menu().is_some()); + let loaded = read_snapshot(path.clone()).unwrap(); + send(&mut app, Message::Loaded(Ok(Some(loaded)))); + assert!(!app.source_workspace.applied); + assert_eq!(app.path.as_deref(), Some(path.as_path())); + assert_ne!(app.source_key().workspace, old_source.workspace); + assert_eq!(app.source_workspace.text(), invalid); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert_cleared(&app, job); + assert!( + app.source_diagnostics + .iter() + .any(|issue| issue.diagnostic.severity == Severity::Error) + ); + let publication = app.text_workspace.diagnostic_publication().unwrap(); + assert_eq!(publication.source(), app.source_key()); + assert_ne!(publication.generation(), old_publication.generation()); + for message in old { + send(&mut app, message); + } + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + let issue = app + .source_diagnostics + .iter() + .find(|issue| { + issue.path == app.source_workspace.active + && issue.diagnostic.span.end <= invalid.len() + }) + .unwrap(); + let current = app + .text_diagnostic_message(SourceLocation { + path: issue.path.clone(), + span: issue.diagnostic.span, + }) + .unwrap(); + send(&mut app, current); + assert!(app.source_symbols.reveal.is_some()); + reveal(&mut app); + if let Some(ready) = ready { + send(&mut app, Message::SourceSymbolsReady(ready)); + } + assert!(app.source_symbols.in_flight.is_none()); + assert!(app.source_symbols.results.is_none()); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert_eq!(app.source_workspace.text(), invalid); + assert_eq!(std::fs::read_to_string(&path).unwrap(), invalid); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[test] +fn canceled_or_failed_open_that_installs_nothing_retains_current_presentation_and_diagnostics() { + for failed in [false, true] { + let mut app = fixture(); + seed_navigation(&mut app, false); + app.publish_source_diagnostics(vec![warning(&app)]); + let results = app.source_symbols.results.clone().unwrap(); + let cache = app.source_symbols.cache.clone().unwrap(); + let display = app.source_symbols.display_request.clone(); + let back = app + .source_symbols + .back + .iter() + .map(|entry| { + ( + entry.session, + entry.path.clone(), + entry.generation, + entry.cursor, + ) + }) + .collect::>(); + let key = app.source_key(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let publication = app.text_workspace.diagnostic_publication(); + let diagnostics = app.source_diagnostics.clone(); + let visit = app.text_workspace.drawer_visit(); + let result = if failed { + Err("Fixture read failed before installing a workspace".into()) + } else { + Ok(None) + }; + send(&mut app, Message::Loaded(result)); + assert_eq!(app.source_key(), key); + assert_eq!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!(app.source_diagnostics, diagnostics); + assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References)); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert!(Arc::ptr_eq( + app.source_symbols.results.as_ref().unwrap(), + &results + )); + assert!(Arc::ptr_eq( + app.source_symbols.cache.as_ref().unwrap(), + &cache + )); + assert_eq!(app.source_symbols.display_request, display); + assert_eq!( + app.source_symbols + .back + .iter() + .map(|entry| ( + entry.session, + entry.path.clone(), + entry.generation, + entry.cursor + )) + .collect::>(), + back + ); + assert_eq!(app.source_workspace.bundle(), raw); + assert_eq!(app.source_editor.cursor(), cursor); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + } +} + +#[test] +fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let first_path = first.path().canonicalize().unwrap().join("project.arcadia"); + let second_path = second + .path() + .canonicalize() + .unwrap() + .join("project.arcadia"); + let mut app = fixture(); + save(&mut app, &first_path); + app.publish_source_diagnostics(vec![warning(&app)]); + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + let old = warning_messages(&app); + let publication = app.text_workspace.diagnostic_publication().unwrap(); + let draft = format!("{VALID}// unsaved current draft\r\n"); + app.source_workspace + .edit(draft.clone(), app.source_editor.cursor()); + app.show_source_file(false); + assert_ne!(publication.source(), app.source_key()); + let accepted = app.document.snapshot(); + save(&mut app, &second_path); + assert!( + app.text_workspace.diagnostic_publication().is_none(), + "identity-only reset cannot turn an old source span into a current warning" + ); + { + let captured = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, captured); + }; + assert!( + app.text_diagnostic_message(SourceLocation { + path: app.source_diagnostics[0].path.clone(), + span: app.source_diagnostics[0].diagnostic.span, + }) + .is_none() + ); + for message in old { + send(&mut app, message); + } + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert_eq!(app.source_workspace.text(), draft); + assert!(app.source_dirty()); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert_eq!( + std::fs::read_to_string(&second_path).unwrap(), + VALID, + "receipt records accepted bytes while the newer draft remains unsaved" + ); +} diff --git a/crates/oa-desktop/src/app/text_workspace/tests.rs b/crates/oa-desktop/src/app/text_workspace/tests.rs new file mode 100644 index 00000000..3c24cb42 --- /dev/null +++ b/crates/oa-desktop/src/app/text_workspace/tests.rs @@ -0,0 +1,672 @@ +use super::*; +use crate::app::Page; +use oa_core::{Project, language::Span}; +use std::sync::Arc; + +fn fixture() -> App { + let mut app = App::with_project(Project::empty()); + app.page = Page::Text; + let diagnostics = app.source_diagnostics.clone(); + app.publish_source_diagnostics(diagnostics); + assert!(app.source_navigation_available()); + app +} +fn send(app: &mut App, event: Event) { + // Exercise the real envelope path; worker/lint futures are deliberately not + // polled here. The parent owns serialized compile/runtime verification. + let _ = app.update(Message::TextWorkspace(event)); +} +fn toggle(app: &App, stamp: QueryStamp, path: String) -> Event { + Event::ToggleFile { + stamp, + visit: app.text_workspace.drawer_visit(), + path, + } +} +fn open(app: &mut App) -> Id { + let message = app.text_menu_message(Point::new(10., 20.)); + let _ = app.update(message); + app.text_workspace.menu().unwrap().token() +} +fn invoke(app: &mut App, token: Id, command: Command) { + send(app, Event::Invoke { token, command }); +} +fn enabled(app: &App, command: Command) -> bool { + app.text_workspace + .menu + .as_ref() + .unwrap() + .entries + .iter() + .find(|entry| entry.command == command) + .is_some_and(|entry| entry.action.is_some()) +} +fn back_entry(app: &App) -> nav::HistoryEntry { + nav::HistoryEntry { + session: app.source_workspace.session(), + path: app.source_workspace.active.clone(), + generation: app.source_workspace.files[&app.source_workspace.active].generation(), + cursor: app.source_editor.cursor(), + } +} +fn stamp(app: &App, serial: u64) -> QueryStamp { + QueryStamp { + snapshot: app.source_key(), + serial, + } +} +fn location(app: &App, start: usize, end: usize) -> SourceLocation { + SourceLocation { + path: app.source_workspace.active.clone(), + span: Span { start, end }, + } +} + +#[test] +fn menu_own_open_invalidation_does_not_stale_find_and_invoke_is_once() { + let mut app = fixture(); + let old_interaction = app.source_symbols.interaction; + let token = open(&mut app); + assert_ne!(old_interaction, app.source_symbols.interaction); + assert_eq!( + app.text_workspace.menu.as_ref().unwrap().context, + app.text_command_context() + ); + assert!(enabled(&app, Command::Find)); + invoke(&mut app, token, Command::Find); + assert!(app.source_navigation.open); + assert!(app.text_workspace.menu().is_none()); + let instance = app.source_find_instance; + invoke(&mut app, token, Command::Find); + assert_eq!( + app.source_find_instance, instance, + "replay must not reopen Find" + ); +} + +#[test] +fn disabled_entry_cannot_become_a_new_action_under_the_same_menu_token() { + let mut app = fixture(); + let token = open(&mut app); + assert!(!enabled(&app, Command::Back)); + app.source_symbols.back.push(back_entry(&app)); + assert!( + !enabled(&app, Command::Back), + "frozen presentation must remain disabled" + ); + invoke(&mut app, token, Command::Back); + assert_eq!( + app.source_symbols.back.len(), + 1, + "a newly enabled action cannot be substituted" + ); + assert!(app.source_symbols.reveal.is_none()); +} + +#[test] +fn enabled_entry_is_readmitted_at_dispatch_and_blocked_by_busy() { + let mut app = fixture(); + let token = open(&mut app); + assert!(enabled(&app, Command::Find)); + app.busy = true; + assert!(enabled(&app, Command::Find)); + invoke(&mut app, token, Command::Find); + assert!(!app.source_navigation.open); + assert!(app.text_workspace.menu().is_none()); +} + +#[test] +fn old_dismiss_and_invoke_do_not_consume_a_newer_menu() { + let mut app = fixture(); + let old = open(&mut app); + send(&mut app, Event::DismissMenu(old)); + let new = open(&mut app); + send(&mut app, Event::DismissMenu(old)); + invoke(&mut app, old, Command::Find); + assert_eq!(app.text_workspace.menu().unwrap().token(), new); + assert!(!app.source_navigation.open); +} + +#[test] +fn menu_source_caret_document_path_and_interaction_changes_refuse_stale_find() { + for change in 0..6 { + let mut app = fixture(); + let token = open(&mut app); + match change { + 0 => { + let value = format!("{}\n// changed", app.source_workspace.text()); + app.source_workspace.edit(value, app.source_editor.cursor()); + } + 1 => { + let mut cursor = app.source_editor.cursor(); + cursor.position.column += 1; + app.source_editor.move_to(cursor); + } + 2 => app.canvas_document = Id::new_v4(), + 3 => app.source_workspace.active = "other.arcadia".into(), + 4 => app.source_symbols.interaction = Id::new_v4(), + _ => { + let mut bundle = app.document.bundle().clone(); + bundle + .files + .get_mut(&bundle.entry) + .unwrap() + .push_str("\n// accepted revision"); + assert!(app.document.apply_bundle(bundle).unwrap()); + } + } + invoke(&mut app, token, Command::Find); + assert!(!app.source_navigation.open, "stale context case {change}"); + } +} + +#[test] +fn source_and_property_drafts_keep_find_and_text_undo_without_model_application() { + let mut app = fixture(); + let accepted = app.document.bundle().clone(); + let revision = app.document.revision(); + let pending_name = "Pending property draft"; + // Use the actual property input route before source edits coexist. Changing + // Draft::value alone is overwritten by its authoritative raw input buffer. + let _ = app.update(Message::Field("/name".into(), pending_name.into())); + let assert_pending_property = |app: &App| { + let draft = app.draft.as_ref().unwrap(); + assert!(draft.dirty()); + assert_eq!(draft.inputs["/name"].raw, pending_name); + assert_eq!(draft.inputs["/name"].editor.text(), pending_name); + assert_eq!( + draft.materialize().unwrap()["name"].as_str(), + Some(pending_name) + ); + }; + assert_pending_property(&app); + let initial = app.source_workspace.text().to_owned(); + app.source_workspace + .edit(format!("{initial}\n// draft"), app.source_editor.cursor()); + app.show_source_file(false); + assert!(app.source_dirty() && app.draft_dirty()); + let token = open(&mut app); + assert!(enabled(&app, Command::Find) && enabled(&app, Command::UndoText)); + assert!(!enabled(&app, Command::RenameAlias) && !enabled(&app, Command::UndoModel)); + invoke(&mut app, token, Command::Find); + assert!(app.source_navigation.open); + assert_pending_property(&app); + let token = open(&mut app); + invoke(&mut app, token, Command::UndoText); + assert_eq!(app.source_workspace.text(), initial); + assert_pending_property(&app); + assert_eq!(app.document.revision(), revision); + assert_eq!(app.document.bundle(), &accepted); +} + +#[test] +fn queued_toolbar_request_cannot_retarget_after_source_change() { + let mut app = fixture(); + let request = app.text_command_message(Command::Find); + app.source_workspace + .edit("// changed".into(), app.source_editor.cursor()); + let _ = app.update(request); + assert!(!app.source_navigation.open); +} + +#[test] +fn equal_diagnostic_republication_invalidates_primary_and_related_targets() { + let mut app = fixture(); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + let primary = app.text_diagnostic_message(location(&app, 0, 1)).unwrap(); + let related = app.text_diagnostic_message(location(&app, 1, 2)).unwrap(); + let previous = app.text_workspace.diagnostic_publication().unwrap(); + app.publish_source_diagnostics(app.source_diagnostics.clone()); + let current = app.text_workspace.diagnostic_publication().unwrap(); + assert_eq!(previous.source(), current.source()); + assert_ne!(previous.generation(), current.generation()); + for message in [primary, related] { + let interaction = app.source_symbols.interaction; + let _ = app.update(message); + assert_eq!(app.source_symbols.interaction, interaction); + assert!(app.source_symbols.reveal.is_none()); + } +} + +#[test] +fn current_diagnostic_target_navigates_but_changed_source_or_clear_refuses_it() { + let mut app = fixture(); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap(); + let _ = app.update(message); + assert!(app.source_symbols.reveal.is_some()); + app.source_interaction(); + let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap(); + app.source_workspace + .edit("// newer".into(), app.source_editor.cursor()); + let _ = app.update(message); + assert!(app.source_symbols.reveal.is_none()); + assert!(app.text_diagnostic_message(location(&app, 0, 1)).is_none()); + app.publish_source_diagnostics(Vec::new()); + let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap(); + app.publish_source_diagnostics(Vec::new()); + let _ = app.update(message); + assert!(app.source_symbols.reveal.is_none()); +} + +#[test] +fn diagnostic_span_must_be_loaded_ordered_and_utf8_exact() { + let mut app = fixture(); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + app.source_workspace + .edit("a雨b".into(), app.source_editor.cursor()); + app.show_source_file(false); + app.publish_source_diagnostics(Vec::new()); + let mut missing = location(&app, 0, 1); + missing.path = "missing.arcadia".into(); + for target in [ + location(&app, 3, 2), + location(&app, 0, 6), + location(&app, 2, 4), + location(&app, 1, 3), + missing, + ] { + let message = app.text_diagnostic_message(target).unwrap(); + let _ = app.update(message); + assert!(app.source_symbols.reveal.is_none()); + } + let message = app.text_diagnostic_message(location(&app, 1, 4)).unwrap(); + let _ = app.update(message); + assert!(app.source_symbols.reveal.is_some()); +} + +#[test] +fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries() { + let mut app = fixture(); + let first = stamp(&app, 1); + app.text_workspace + .begin_references(first, "b/use.arcadia".into()); + app.text_workspace + .install_reference_files(first, ["a/use.arcadia".into(), "b/use.arcadia".into()]); + assert!(app.text_workspace.references().collapsed("a/use.arcadia")); + assert!(!app.text_workspace.references().collapsed("b/use.arcadia")); + let event = toggle(&app, first, "a/use.arcadia".into()); + send(&mut app, event); + let old_visit = app.text_workspace.drawer_visit(); + let old_scroll_id = app.text_workspace.reference_scroll_id(); + let interaction = app.source_symbols.interaction; + send( + &mut app, + Event::ReferenceScrolled { + stamp: first, + visit: old_visit, + y: 123., + }, + ); + assert_eq!( + app.source_symbols.interaction, interaction, + "scroll is passive" + ); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Status); + send(&mut app, captured); + }; + send( + &mut app, + Event::ReferenceScrolled { + stamp: first, + visit: old_visit, + y: 0., + }, + ); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References); + send(&mut app, captured); + }; + assert_eq!(app.text_workspace.references().scroll_y(), 123.); + assert!(!app.text_workspace.references().collapsed("a/use.arcadia")); + send( + &mut app, + Event::ReferenceScrolled { + stamp: first, + visit: old_visit, + y: 0., + }, + ); + assert_eq!(app.text_workspace.references().scroll_y(), 123.); + assert_ne!(app.text_workspace.reference_scroll_id(), old_scroll_id); + let second = stamp(&app, 2); + app.text_workspace + .begin_references(second, "absent.arcadia".into()); + app.text_workspace + .install_reference_files(first, ["old.arcadia".into()]); + app.text_workspace + .install_reference_files(second, ["b/use.arcadia".into(), "a/use.arcadia".into()]); + assert_eq!(app.text_workspace.references().scroll_y(), 0.); + assert!(!app.text_workspace.references().collapsed("a/use.arcadia")); + assert!(app.text_workspace.references().collapsed("b/use.arcadia")); + let event = toggle(&app, first, "b/use.arcadia".into()); + send(&mut app, event); + assert!(app.text_workspace.references().collapsed("b/use.arcadia")); +} + +#[test] +fn reset_removes_ui_and_publication_authority_and_resize_rejects_nan() { + let mut app = fixture(); + open(&mut app); + app.text_workspace.reset(); + assert!(app.text_workspace.menu().is_none()); + assert!(app.text_workspace.diagnostic_publication().is_none()); + assert!(app.text_workspace.drawer().is_none()); + { + let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN); + send(&mut app, captured); + }; + assert_eq!(app.text_workspace.drawer_height(), 180.); + { + let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::MAX); + send(&mut app, captured); + }; + assert_eq!(app.text_workspace.drawer_height(), 1200.); +} + +#[test] +fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() { + let mut app = fixture(); + let current = stamp(&app, 1); + app.text_workspace + .begin_references(current, "uses.arcadia".into()); + app.text_workspace + .install_reference_files(current, ["uses.arcadia".into()]); + let request = nav::Request { + job: Id::new_v4(), + interaction: app.source_symbols.interaction, + raw_interaction: 0, + stamp: current, + path: app.source_workspace.active.clone(), + offset: 0, + cursor: app.source_editor.cursor(), + action: nav::query::Action::References, + }; + app.source_symbols.request = Some(request.clone()); + app.source_symbols.in_flight = Some(request.job); + let interaction = app.source_symbols.interaction; + let event = toggle(&app, current, "not-a-group.arcadia".into()); + send(&mut app, event); + let old = QueryStamp { + serial: 0, + ..current + }; + let event = toggle(&app, old, "uses.arcadia".into()); + send(&mut app, event); + { + let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN); + send(&mut app, captured); + }; + { + let captured = Event::ResizeDrawer(app.text_drawer_context(), 180.); + send(&mut app, captured); + }; + assert_eq!(app.source_symbols.interaction, interaction); + assert!(app.source_symbols.request.is_some()); + let event = toggle(&app, current, "uses.arcadia".into()); + send(&mut app, event); + assert!(app.source_symbols.request.is_none()); + assert_ne!(app.source_symbols.interaction, interaction); + assert_eq!( + app.source_symbols.in_flight, + Some(request.job), + "intent does not release an occupied worker" + ); + app.source_symbols.request = Some(request.clone()); + { + let captured = Event::ResizeDrawer(app.text_drawer_context(), 240.); + send(&mut app, captured); + }; + assert!(app.source_symbols.request.is_none()); + app.source_symbols.request = Some(request.clone()); + { + let captured = Event::HideDrawer(app.text_drawer_context()); + send(&mut app, captured); + }; + assert!(app.source_symbols.request.is_none()); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert_eq!(app.text_workspace.references().stamp(), Some(current)); +} + +#[test] +fn stale_retained_group_can_expand_without_navigating_or_applying_drafts() { + let mut app = fixture(); + let current = stamp(&app, 1); + let path = app.source_workspace.active.clone(); + app.text_workspace.begin_references(current, path.clone()); + app.text_workspace + .install_reference_files(current, [path.clone()]); + let event = toggle(&app, current, path.clone()); + send(&mut app, event); + assert!(app.text_workspace.references().collapsed(&path)); + let accepted = app.document.snapshot(); + let _ = app.update(Message::SourceEdit(widget::text_editor::Action::Edit( + widget::text_editor::Edit::Insert(' '), + ))); + assert_ne!(current.snapshot, app.source_key()); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let event = toggle(&app, current, path.clone()); + send(&mut app, event); + assert!(!app.text_workspace.references().collapsed(&path)); + assert_eq!(app.source_workspace.active, path); + assert_eq!(app.source_workspace.bundle(), raw); + assert_eq!(app.source_editor.cursor(), cursor); + assert!(app.source_symbols.reveal.is_none() && app.source_symbols.back.is_empty()); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); +} + +#[test] +fn document_reset_preserves_popup_token_sequence_and_drops_old_menu_authority() { + let mut app = fixture(); + let old = open(&mut app); + let sequence = app.text_workspace_menu().unwrap().token; + app.reset(Project::empty(), None); + assert!(app.text_workspace.menu().is_none()); + app.page = Page::Text; + let fresh = open(&mut app); + assert_ne!(old, fresh); + assert!(app.text_workspace_menu().unwrap().token > sequence); + invoke(&mut app, old, Command::Find); + assert!(!app.source_navigation.open); + assert_eq!(app.text_workspace.menu().unwrap().token(), fresh); +} + +#[test] +fn retained_primary_and_related_targets_cannot_replay_after_hide_or_problems_references_problems() { + for hide in [false, true] { + let mut app = fixture(); + { + let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + let old_visit = app.text_workspace.drawer_visit(); + let publication = app.text_workspace.diagnostic_publication(); + let targets = [location(&app, 0, 1), location(&app, 1, 2)]; + let old = targets + .iter() + .cloned() + .map(|target| app.text_diagnostic_message(target).unwrap()) + .collect::>(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let history = ( + app.document.revision(), + app.document.can_undo(), + app.document.can_redo(), + app.document.dirty(), + ); + let event = if hide { + Event::HideDrawer(app.text_drawer_context()) + } else { + Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References) + }; + send(&mut app, event); + for reopened in [false, true] { + if reopened { + { + let captured = + Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + } else { + assert!(app.text_diagnostic_message(targets[0].clone()).is_none()); + } + assert_ne!(app.text_workspace.drawer_visit(), old_visit); + assert_eq!( + app.text_workspace.diagnostic_publication(), + publication, + "same list and source isolate the visit guard from publication freshness" + ); + let context = app.text_command_context(); + let drawer = app.text_workspace.drawer(); + let visit = app.text_workspace.drawer_visit(); + for message in old.iter().cloned() { + let _ = app.update(message); + assert_eq!( + app.text_command_context(), + context, + "stale diagnostic must fail before interaction invalidation" + ); + assert_eq!(app.text_workspace.drawer(), drawer); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!( + ( + app.document.revision(), + app.document.can_undo(), + app.document.can_redo(), + app.document.dirty() + ), + history + ); + } + } + for target in targets { + let fresh = app.text_diagnostic_message(target).unwrap(); + let _ = app.update(fresh); + assert!( + app.source_symbols.reveal.is_some(), + "fresh primary and related targets remain usable" + ); + app.source_interaction(); + } + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + } +} + +#[test] +fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() { + for replacement in 0..3 { + let mut app = fixture(); + let current = stamp(&app, 1); + app.text_workspace + .begin_references(current, "a.arcadia".into()); + app.text_workspace + .install_reference_files(current, ["a.arcadia".into(), "b.arcadia".into()]); + let old = toggle(&app, current, "a.arcadia".into()); + let old_visit = app.text_workspace.drawer_visit(); + match replacement { + 0 => { + { + let captured = Event::HideDrawer(app.text_drawer_context()); + send(&mut app, captured); + }; + { + let captured = + Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References); + send(&mut app, captured); + }; + } + 1 => { + { + let captured = + Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, captured); + }; + { + let captured = + Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References); + send(&mut app, captured); + }; + } + _ => { + let event = toggle(&app, current, "b.arcadia".into()); + send(&mut app, event); + } + } + let visit = app.text_workspace.drawer_visit(); + assert_ne!(visit, old_visit); + send( + &mut app, + Event::ReferenceScrolled { + stamp: current, + visit, + y: 84., + }, + ); + let scroll_id = app.text_workspace.reference_scroll_id(); + let request = nav::Request { + job: Id::new_v4(), + interaction: app.source_symbols.interaction, + raw_interaction: 0, + stamp: current, + path: app.source_workspace.active.clone(), + offset: 0, + cursor: app.source_editor.cursor(), + action: nav::query::Action::References, + }; + app.source_symbols.request = Some(request.clone()); + app.source_symbols.in_flight = Some(request.job); + let groups = + ["a.arcadia", "b.arcadia"].map(|path| app.text_workspace.references().collapsed(path)); + let context = app.text_command_context(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + for _ in 0..2 { + send(&mut app, old.clone()); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert_eq!(app.text_workspace.references().scroll_y(), 84.); + assert_eq!(app.text_workspace.reference_scroll_id(), scroll_id); + assert_eq!(app.text_workspace.references().stamp(), Some(current)); + assert_eq!( + ["a.arcadia", "b.arcadia"] + .map(|path| app.text_workspace.references().collapsed(path)), + groups, + ); + assert_eq!(app.text_command_context(), context); + assert_eq!(app.source_symbols.request.as_ref(), Some(&request)); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + } + let fresh = toggle(&app, current, "a.arcadia".into()); + send(&mut app, fresh); + assert_eq!( + app.text_workspace.references().collapsed("a.arcadia"), + !groups[0] + ); + assert_ne!(app.text_workspace.drawer_visit(), visit); + assert_ne!(app.source_symbols.interaction, context.interaction); + assert!(app.source_symbols.request.is_none()); + assert_eq!(app.source_symbols.in_flight, Some(request.job)); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + assert!(app.source_symbols.back.is_empty()); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} diff --git a/crates/oa-desktop/src/diagnostic_input.rs b/crates/oa-desktop/src/diagnostic_input.rs new file mode 100644 index 00000000..d26b36ec --- /dev/null +++ b/crates/oa-desktop/src/diagnostic_input.rs @@ -0,0 +1,301 @@ +//! Publication-and-visit-bound input for a mounted diagnostic list. +//! +//! Keep this wrapper at the same widget-tree position, inside the list's outer +//! scroller. Pass `None` with an empty child while hidden/removed; unmounting the +//! wrapper loses its ability to consume an outstanding release. Every change to +//! row identity/order/action must have a fresh publication Id. Pass the pair +//! (publication generation, drawer visit): a new visit resets armed controls even +//! if Hide and Show coalesce without an intermediate hidden Tree. The child tree +//! resets when either component changes, clearing button presses and row focus; +//! the outer scroller and editor trees are not reset. +//! +//! Capturing stops Iced event bubbling and later consumers that respect capture. +//! It cannot undo an earlier sibling's event handling. This is a row-list guard, +//! not a root event filter or a guard for actions in child overlays. Diagnostic +//! messages still need publication/snapshot and drawer-visit validation at dispatch. +use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget}; +use iced::{Element, Event, Length, Rectangle, Size, Vector, mouse, touch}; +use oa_core::Id; + +pub struct Guard<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> { + identity: Option<(Id, Id)>, + content: Element<'a, Message, Theme, Renderer>, +} + +impl<'a, Message, Theme, Renderer> Guard<'a, Message, Theme, Renderer> { + pub fn new( + identity: Option<(Id, Id)>, + content: impl Into>, + ) -> Self { + Self { + identity, + content: content.into(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Pointer { + Mouse, + Touch(touch::Finger), +} +impl Pointer { + fn press(event: &Event) -> Option { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => Some(Self::Mouse), + Event::Touch(touch::Event::FingerPressed { id, .. }) => Some(Self::Touch(*id)), + _ => None, + } + } + fn release(event: &Event) -> Option { + match event { + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => Some(Self::Mouse), + Event::Touch( + touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. }, + ) => Some(Self::Touch(*id)), + _ => None, + } + } +} + +struct State { + identity: Option<(Id, Id)>, + pressed: Option, + swallowed: Vec, +} +impl State { + fn quarantine(&mut self, pointer: Pointer) { + if !self.swallowed.contains(&pointer) { + self.swallowed.push(pointer); + } + } + fn forget(&mut self, pointer: Pointer) -> bool { + let found = self.swallowed.contains(&pointer); + self.swallowed.retain(|item| *item != pointer); + found + } +} + +impl Widget + for Guard<'_, Message, Theme, Renderer> +{ + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State { + identity: self.identity, + pressed: None, + swallowed: Vec::new(), + }) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.content)] + } + fn diff(&self, tree: &mut widget::Tree) { + let state = tree.state.downcast_mut::(); + if state.identity != self.identity { + state.identity = self.identity; + if let Some(pointer) = state.pressed.take() { + state.quarantine(pointer); + } + tree.children = self.children(); + } else { + tree.diff_children(std::slice::from_ref(&self.content)); + } + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_mut::(); + let release = Pointer::release(event); + if let Some(pointer) = release { + // Bounds may have become empty or moved. Consume the physical + // gesture's terminal event regardless of its current position. + if state.forget(pointer) { + shell.capture_event(); + return; + } + // Iced Button has a single is_pressed bit, not a pointer identity. + // A different finger/mouse release must not activate that button. + if state.pressed.is_some_and(|pressed| pressed != pointer) { + shell.capture_event(); + return; + } + } + let press = Pointer::press(event); + if let Some(pointer) = state.pressed.filter(|pointer| { + press == Some(*pointer) || (release == Some(*pointer) && shell.is_event_captured()) + }) { + // Recover an omitted terminal event, or cancel one intercepted by + // an earlier consumer. An off-target mouse release / lost touch + // clears Iced Button's pressed state without rebuilding this + // publication's tree (which would also discard row focus). + state.pressed = None; + let cancel = match pointer { + Pointer::Mouse => Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + Pointer::Touch(id) => Event::Touch(touch::Event::FingerLost { + id, + position: iced::Point::ORIGIN, + }), + }; + let mut discarded = Vec::new(); + let mut cancellation = Shell::new(&mut discarded); + self.content.as_widget_mut().update( + &mut tree.children[0], + &cancel, + layout, + mouse::Cursor::Unavailable, + renderer, + clipboard, + &mut cancellation, + viewport, + ); + // Cancellation must not publish an action or capture the fresh + // down. Preserve rendering requests from the state change only. + shell.request_redraw(); + if cancellation.is_layout_invalid() { + shell.invalidate_layout(); + } + if cancellation.are_widgets_invalid() { + shell.invalidate_widgets(); + } + } + if shell.is_event_captured() || self.identity.is_none() { + return; + } + if let Some(pointer) = press { + // A fresh down starts a new gesture even if the platform omitted a + // previous release (for example while the window lacked focus). + state.forget(pointer); + if let Some(pressed) = state.pressed { + if pressed != pointer { + state.quarantine(pointer); + } + shell.capture_event(); + return; + } + } + let was_pressed = release.is_some() && release == state.pressed; + if was_pressed { + state.pressed = None; + } + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + if let Some(pointer) = press + && shell.is_event_captured() + { + state.pressed = Some(pointer); + } + if was_pressed { + // FingerLost cancels Iced Button state but does not itself capture. + shell.capture_event(); + } + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a> + From> for Element<'a, Message, Theme, Renderer> +{ + fn from(guard: Guard<'a, Message, Theme, Renderer>) -> Self { + Element::new(guard) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/diagnostic_input/tests.rs b/crates/oa-desktop/src/diagnostic_input/tests.rs new file mode 100644 index 00000000..6f390581 --- /dev/null +++ b/crates/oa-desktop/src/diagnostic_input/tests.rs @@ -0,0 +1,626 @@ +use super::*; +use iced::advanced::widget::operation::{self, Focusable, Scrollable}; +use iced::widget::{Space, button, column, container, mouse_area, scrollable, text_input}; +use iced::{Point, Theme, event, keyboard}; + +type View = Element<'static, Message, Theme, ()>; +const BOUNDS: Rectangle = Rectangle { + x: 0., + y: 0., + width: 400., + height: 300., +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Message { + Primary(u8), + Related(u8), + BackgroundButton, + BackgroundRelease, + Submit(u8), + Edit, +} + +fn rows(version: u8) -> View { + column![ + button(Space::new()) + .width(200.) + .height(40.) + .padding(0) + .on_press(Message::Primary(version)), + button(Space::new()) + .width(200.) + .height(40.) + .padding(0) + .on_press(Message::Related(version)), + ] + .into() +} +fn guarded(generation: Option, version: u8) -> View { + let child: View = if generation.is_some() { + rows(version) + } else { + Space::new().into() + }; + Guard::new( + generation.map(|publication| (publication, Id::nil())), + child, + ) + .into() +} +fn press() -> Event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) +} +fn release() -> Event { + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) +} +fn enter() -> Event { + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Enter), + modified_key: keyboard::Key::Named(keyboard::key::Named::Enter), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Enter), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }) +} +fn touch_down(id: u64, position: Point) -> Event { + Event::Touch(touch::Event::FingerPressed { + id: touch::Finger(id), + position, + }) +} +fn touch_up(id: u64, position: Point) -> Event { + Event::Touch(touch::Event::FingerLifted { + id: touch::Finger(id), + position, + }) +} +fn touch_lost(id: u64, position: Point) -> Event { + Event::Touch(touch::Event::FingerLost { + id: touch::Finger(id), + position, + }) +} + +struct Mounted { + view: View, + tree: widget::Tree, + node: layout::Node, +} +impl Mounted { + fn new(mut view: View) -> Self { + let mut tree = widget::Tree::new(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut tree, + &(), + &layout::Limits::new(Size::ZERO, BOUNDS.size()), + ); + Self { view, tree, node } + } + fn replace(&mut self, mut view: View) { + self.tree.diff(view.as_widget()); + self.node = view.as_widget_mut().layout( + &mut self.tree, + &(), + &layout::Limits::new(Size::ZERO, BOUNDS.size()), + ); + self.view = view; + } + fn event(&mut self, event: Event, point: Point) -> (Vec, event::Status) { + self.event_with_capture(event, point, false) + } + fn event_with_capture( + &mut self, + event: Event, + point: Point, + captured: bool, + ) -> (Vec, event::Status) { + let mut messages = Vec::new(); + let mut shell = Shell::new(&mut messages); + if captured { + shell.capture_event(); + } + self.view.as_widget_mut().update( + &mut self.tree, + &event, + Layout::new(&self.node), + mouse::Cursor::Available(point), + &(), + &mut iced::advanced::clipboard::Null, + &mut shell, + &BOUNDS, + ); + let status = shell.event_status(); + (messages, status) + } + fn operate(&mut self, operation: &mut dyn widget::Operation) { + self.view + .as_widget_mut() + .operate(&mut self.tree, Layout::new(&self.node), &(), operation); + } + fn focus(&mut self) { + self.operate(&mut operation::focusable::focus::<()>("row".into())); + } + fn focused(&mut self) -> bool { + #[derive(Default)] + struct Focus(bool); + impl widget::Operation for Focus { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + _: Option<&widget::Id>, + _: Rectangle, + state: &mut dyn Focusable, + ) { + self.0 |= state.is_focused(); + } + } + let mut focus = Focus::default(); + self.operate(&mut focus); + focus.0 + } + fn scroll(&mut self) -> Vector { + #[derive(Default)] + struct Offset(Option); + impl widget::Operation for Offset { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn scrollable( + &mut self, + _: Option<&widget::Id>, + _: Rectangle, + _: Rectangle, + translation: Vector, + _: &mut dyn Scrollable, + ) { + self.0 = Some(translation); + } + } + let mut offset = Offset::default(); + self.operate(&mut offset); + offset.0.unwrap() + } +} + +#[test] +fn current_primary_and_related_mouse_clicks_survive_same_publication_diff() { + for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] { + let generation = Id::new_v4(); + let point = Point::new(20., y); + let mut ui = Mounted::new(guarded(Some(generation), 1)); + assert_eq!(ui.event(press(), point), (vec![], event::Status::Captured)); + ui.replace(guarded(Some(generation), 1)); + assert_eq!( + ui.event(release(), point), + (vec![expected], event::Status::Captured) + ); + } +} + +#[test] +fn same_publication_new_visit_cancels_armed_rows_without_a_hidden_tree_frame() { + for touch in [false, true] { + for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] { + let publication = Id::new_v4(); + let old_visit = Id::new_v4(); + let new_visit = Id::new_v4(); + let point = Point::new(20., y); + let mut ui = Mounted::new(Guard::new(Some((publication, old_visit)), rows(1)).into()); + let down = || if touch { touch_down(7, point) } else { press() }; + let up = || if touch { touch_up(7, point) } else { release() }; + assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured)); + // Hide→Show has already happened in application state. No None + // publication or empty child is ever diffed into this retained Tree. + ui.replace(Guard::new(Some((publication, new_visit)), rows(2)).into()); + assert_eq!(ui.event(up(), point), (vec![], event::Status::Captured)); + assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured)); + assert_eq!( + ui.event(up(), point), + (vec![expected], event::Status::Captured) + ); + } + } +} + +#[test] +fn negative_control_iced_button_retargets_a_press_after_plain_tree_diff() { + for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] { + let point = Point::new(20., y); + let mut ui = Mounted::new(rows(1)); + assert!(ui.event(press(), point).0.is_empty()); + ui.replace(rows(2)); + assert_eq!(ui.event(release(), point).0, vec![expected]); + } +} + +#[test] +fn replaced_primary_and_related_rows_consume_stale_release_then_accept_new_click() { + for y in [20., 60.] { + let point = Point::new(20., y); + let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1)); + ui.event(press(), point); + ui.replace(guarded(Some(Id::new_v4()), 2)); + ui.replace(guarded(Some(Id::new_v4()), 3)); + assert_eq!( + ui.event(release(), point), + (vec![], event::Status::Captured) + ); + ui.event(press(), point); + let expected = if y < 40. { + Message::Primary(3) + } else { + Message::Related(3) + }; + assert_eq!( + ui.event(release(), point), + (vec![expected], event::Status::Captured) + ); + } +} + +#[test] +fn hidden_zero_size_list_consumes_release_outside_old_bounds_once() { + let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1)); + ui.event(press(), Point::new(20., 20.)); + ui.replace(guarded(None, 0)); + ui.replace(guarded(None, 0)); + assert_eq!(ui.node.size(), Size::ZERO); + let outside = Point::new(350., 250.); + assert_eq!( + ui.event(release(), outside), + (vec![], event::Status::Captured) + ); + assert_eq!( + ui.event(release(), outside), + (vec![], event::Status::Ignored) + ); + ui.replace(guarded(Some(Id::new_v4()), 2)); + assert_eq!(ui.event(release(), Point::new(20., 20.)).0, vec![]); +} + +fn with_background(content: View) -> View { + mouse_area( + button(container(content).width(250.).height(100.)) + .padding(0) + .on_press(Message::BackgroundButton), + ) + .on_release(Message::BackgroundRelease) + .into() +} + +#[test] +fn stale_release_does_not_bubble_to_real_background_controls() { + let point = Point::new(20., 20.); + let mut ui = Mounted::new(with_background(guarded(Some(Id::new_v4()), 1))); + ui.event(press(), point); + ui.replace(with_background(guarded(None, 0))); + assert_eq!( + ui.event(release(), point), + (vec![], event::Status::Captured) + ); + // The enclosing real button still supports a fresh ordinary click. + ui.event(press(), point); + assert_eq!( + ui.event(release(), point).0, + vec![Message::BackgroundButton] + ); +} + +#[test] +fn negative_control_removing_unguarded_rows_exposes_background_release() { + let point = Point::new(20., 20.); + let mut ui = Mounted::new(with_background(rows(1))); + ui.event(press(), point); + ui.replace(with_background(Space::new().into())); + assert_eq!( + ui.event(release(), point).0, + vec![Message::BackgroundRelease] + ); +} + +#[test] +fn current_touch_activates_primary_and_related_and_cancel_does_not_activate() { + for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] { + let point = Point::new(20., y); + let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1)); + assert_eq!( + ui.event(touch_down(1, point), point), + (vec![], event::Status::Captured) + ); + assert_eq!( + ui.event(touch_up(1, point), point), + (vec![expected], event::Status::Captured) + ); + ui.event(touch_down(2, point), point); + assert_eq!( + ui.event(touch_lost(2, point), point), + (vec![], event::Status::Captured) + ); + assert!(ui.event(touch_up(2, point), point).0.is_empty()); + } +} + +#[test] +fn replaced_touch_consumes_lift_or_cancel_even_when_hidden() { + for hide in [false, true] { + for lost in [false, true] { + for y in [20., 60.] { + let point = Point::new(20., y); + let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1)); + ui.event(touch_down(1, point), point); + ui.replace(guarded(if hide { None } else { Some(Id::new_v4()) }, 2)); + let end = if lost { + touch_lost(1, point) + } else { + touch_up(1, point) + }; + assert_eq!(ui.event(end, point), (vec![], event::Status::Captured)); + } + } + } +} + +#[test] +fn unrelated_finger_or_mouse_release_cannot_complete_another_fingers_press() { + let point = Point::new(20., 20.); + let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1)); + ui.event(touch_down(1, point), point); + ui.event(touch_down(2, point), point); + assert_eq!( + ui.event(touch_up(2, point), point), + (vec![], event::Status::Captured) + ); + assert_eq!( + ui.event(release(), point), + (vec![], event::Status::Captured) + ); + assert_eq!( + ui.event(touch_up(1, point), point).0, + vec![Message::Primary(1)] + ); +} + +fn keyboard_row(version: u8) -> View { + // Iced 0.14.2 Button has no focus/keyboard activation. A real focusable + // submit control tests the wrapper's focus lifecycle without inventing it. + text_input("Diagnostic action", "row") + .id("row") + .on_input(|_| Message::Edit) + .on_submit(Message::Submit(version)) + .into() +} + +#[test] +fn same_publication_preserves_focus_but_replacement_never_inherits_activation() { + let generation = Id::new_v4(); + let mut ui = Mounted::new(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into()); + ui.focus(); + assert!(ui.focused()); + ui.replace(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into()); + assert!(ui.focused()); + assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]); + ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(2)).into()); + assert!(!ui.focused()); + assert!(ui.event(enter(), Point::ORIGIN).0.is_empty()); + ui.focus(); + assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]); + ui.replace(Guard::new(None, Space::new()).into()); + ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(3)).into()); + assert!(!ui.focused()); + assert!(ui.event(enter(), Point::ORIGIN).0.is_empty()); +} + +#[test] +fn negative_control_plain_focusable_row_migrates_activation_to_replacement() { + let mut ui = Mounted::new(keyboard_row(1)); + ui.focus(); + ui.replace(keyboard_row(2)); + assert!(ui.focused()); + assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]); +} + +#[test] +fn outer_scroll_offset_survives_same_and_changed_publications() { + fn pane(generation: Id) -> View { + scrollable(Guard::new( + Some((generation, Id::nil())), + column![rows(1), Space::new().height(600.)], + )) + .width(240.) + .height(100.) + .into() + } + let generation = Id::new_v4(); + let mut ui = Mounted::new(pane(generation)); + ui.event( + Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: -120. }, + }), + Point::new(20., 20.), + ); + let offset = ui.scroll(); + assert_eq!(offset, Vector::new(0., 120.)); + ui.replace(pane(generation)); + assert_eq!(ui.scroll(), offset); + ui.replace(pane(Id::new_v4())); + assert_eq!(ui.scroll(), offset); +} + +#[test] +fn stale_touch_release_does_not_disarm_a_new_current_gesture() { + let point = Point::new(20., 20.); + let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1)); + ui.event(touch_down(1, point), point); + ui.replace(guarded(Some(Id::new_v4()), 2)); + ui.event(touch_down(2, point), point); + assert_eq!( + ui.event(touch_up(1, point), point), + (vec![], event::Status::Captured) + ); + assert_eq!( + ui.event(touch_up(2, point), point).0, + vec![Message::Primary(2)] + ); +} + +#[test] +fn fresh_mouse_press_after_missing_stale_release_can_activate_current_row() { + let point = Point::new(20., 20.); + let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1)); + ui.event(press(), point); + ui.replace(guarded(Some(Id::new_v4()), 2)); + // The platform delivers another down without the previous up. + ui.event(press(), point); + assert_eq!(ui.event(release(), point).0, vec![Message::Primary(2)]); +} + +#[test] +fn missed_press_is_ignored_and_dragging_release_outside_does_not_activate() { + let inside = Point::new(20., 20.); + let outside = Point::new(350., 250.); + let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1)); + assert_eq!(ui.event(press(), outside), (vec![], event::Status::Ignored)); + assert_eq!( + ui.event(release(), inside), + (vec![], event::Status::Ignored) + ); + ui.event(press(), inside); + assert_eq!( + ui.event(release(), outside), + (vec![], event::Status::Captured) + ); + assert_eq!( + ui.event(release(), inside), + (vec![], event::Status::Ignored) + ); +} + +#[test] +fn same_publication_missing_terminal_recovers_on_fresh_mouse_or_touch_down() { + let primary = Point::new(20., 20.); + let related = Point::new(20., 60.); + for is_touch in [false, true] { + let generation = Id::new_v4(); + let mut ui = Mounted::new(guarded(Some(generation), 1)); + let down = |point| { + if is_touch { + touch_down(1, point) + } else { + press() + } + }; + let up = |point| { + if is_touch { + touch_up(1, point) + } else { + release() + } + }; + ui.event(down(primary), primary); + ui.replace(guarded(Some(generation), 1)); + // The first gesture never receives its terminal event; same Id means + // no publication reset can accidentally make this regression pass. + assert_eq!( + ui.event(down(related), related), + (vec![], event::Status::Captured) + ); + assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]); + assert!(ui.event(up(primary), primary).0.is_empty()); + } +} + +#[test] +fn already_captured_terminal_cancels_child_without_activation_or_lingering_press() { + let primary = Point::new(20., 20.); + let related = Point::new(20., 60.); + for is_touch in [false, true] { + let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1)); + let down = |point| { + if is_touch { + touch_down(1, point) + } else { + press() + } + }; + let up = |point| { + if is_touch { + touch_up(1, point) + } else { + release() + } + }; + ui.event(down(primary), primary); + assert_eq!( + ui.event_with_capture(up(primary), primary, true), + (vec![], event::Status::Captured) + ); + // An unpaired second release must not expose a lingering button press. + assert_eq!( + ui.event(up(primary), primary), + (vec![], event::Status::Ignored) + ); + ui.event(down(related), related); + assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]); + } +} + +#[test] +fn captured_terminal_cancellation_preserves_same_publication_focusable_child_state() { + let generation = Id::new_v4(); + let mut ui = Mounted::new( + Guard::new( + Some((generation, Id::nil())), + column![rows(1), keyboard_row(1)], + ) + .into(), + ); + ui.event(press(), Point::new(20., 20.)); + // Focus through an operation avoids making the old pointer gesture whole. + ui.focus(); + assert!(ui.focused()); + assert!( + ui.event_with_capture(release(), Point::new(20., 20.), true) + .0 + .is_empty() + ); + assert!( + ui.focused(), + "gesture cancellation must not remount the row tree" + ); + assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]); +} + +#[test] +fn hidden_zero_height_scroller_delivers_cancelled_mouse_and_touch_terminals_to_guard() { + fn pane(generation: Option) -> View { + scrollable(guarded(generation, 1)) + .width(240.) + .height(if generation.is_some() { 100. } else { 0. }) + .into() + } + let point = Point::new(20., 20.); + let outside = Point::new(350., 250.); + for is_touch in [false, true] { + let mut ui = Mounted::new(pane(Some(Id::new_v4()))); + let down = if is_touch { + touch_down(1, point) + } else { + press() + }; + assert_eq!(ui.event(down, point), (vec![], event::Status::Captured)); + ui.replace(pane(None)); + ui.replace(pane(None)); + assert_eq!(ui.node.size().height, 0.); + let up = if is_touch { + touch_up(1, outside) + } else { + release() + }; + assert_eq!(ui.event(up, outside), (vec![], event::Status::Captured)); + } +} diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 5d802195..51bb8be4 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -8,6 +8,7 @@ mod connection_tool; mod context_menu; mod data_values; mod data_view; +mod diagnostic_input; mod diagram; mod diagram_usage; mod document; diff --git a/crates/oa-desktop/src/source_navigation/controller.rs b/crates/oa-desktop/src/source_navigation/controller.rs index 620a6988..929b7284 100644 --- a/crates/oa-desktop/src/source_navigation/controller.rs +++ b/crates/oa-desktop/src/source_navigation/controller.rs @@ -13,6 +13,7 @@ pub enum SymbolEvent { Close, Choose { stamp: query::QueryStamp, + visit: Id, reference: bool, index: usize, }, @@ -34,7 +35,7 @@ pub struct HistoryEntry { pub cursor: Cursor, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Request { pub job: Id, pub interaction: Id, @@ -61,12 +62,15 @@ pub struct Reveal { } pub struct SymbolState { - pub open: bool, pub interaction: Id, pub raw_interaction: Arc, pub serial: u64, pub in_flight: Option, pub request: Option, + /// Latest admitted query owns displayed data independently of whether its + /// original navigation intent is still current. Hiding a drawer cancels + /// navigation, but a matching completion can still populate retained data. + pub display_request: Option, pub cache: Option>, pub results: Option>, pub notice: Option, @@ -77,12 +81,12 @@ pub struct SymbolState { impl Default for SymbolState { fn default() -> Self { Self { - open: false, interaction: Id::new_v4(), raw_interaction: Arc::new(AtomicU64::new(0)), serial: 0, in_flight: None, request: None, + display_request: None, cache: None, results: None, notice: None, diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 9195b8cc..bde08594 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -1,4 +1,5 @@ mod source_symbols; +mod text_workspace; pub(crate) mod traceability; use crate::{ app::{App, Choice, FileOperation, Message, Page, Pending}, @@ -256,7 +257,7 @@ pub fn view(app: &App) -> View<'_, Message> { p.relations.len() )), action( - &format!("{} checks", app.issue_count()), + &format!("{} model checks", app.issue_count()), Message::Page(Page::Validation) ) ] @@ -1096,239 +1097,70 @@ fn source_files(app: &App) -> View<'_, Message> { } fn source(app: &App) -> View<'_, Message> { - use crate::source_navigation::Event as SourceEvent; - let (undo_label, undo_message) = match app.source_rename_undo_message() { - Some(message) => ("Undo rename", Some(message)), - None => ( - "Undo model", - (app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo), - ), - }; - let navigation: View<'_, Message> = if app.source_navigation.open { - column![ - row![ - text_input("Find literal text…", &app.source_navigation.query) - .id("source-find") - .on_input(|query| Message::SourceNavigation(SourceEvent::Query(query))) - .on_submit(Message::SourceNavigation(SourceEvent::Next(false))) - .padding(8) - .size(12) - .width(Fill), - action( - "Previous", - Message::SourceNavigation(SourceEvent::Next(true)) - ), - action("Next", Message::SourceNavigation(SourceEvent::Next(false))), - label(app.source_navigation.label()), - action("Close", app.source_find_close_message()), - ] - .spacing(6) - .align_y(iced::Center), - row![ - text_input("Line", &app.source_navigation.line) - .id("source-line") - .on_input(|line| Message::SourceNavigation(SourceEvent::Line(line))) - .on_submit(Message::SourceNavigation(SourceEvent::Go)) - .padding(8) - .size(12) - .width(80), - action("Go to line", Message::SourceNavigation(SourceEvent::Go)), - label("Enter or F3: next match · Shift F3: previous · Esc: close"), - ] - .spacing(6) - .align_y(iced::Center), - ] - .spacing(6) - .into() - } else { - column![].into() - }; - let recovery: View<'_, Message> = if let Some(path) = &app.pending_recovery { - let edits_block_recovery = - !app.source_workspace.applied && app.source_workspace.has_edits(); - let filename = path.file_name().unwrap_or_default().to_string_lossy(); - container( - column![ - tooltip( - text(format!("Interrupted save · {filename}")).size(15), - text(path.display().to_string()).size(12), - tooltip::Position::Bottom, - ), - text(if edits_block_recovery { - "Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them." - } else if app.source_workspace.applied { - "Restore files to their state before the interrupted save. Your current model and editor drafts are kept." - } else { - "Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save." - }) - .size(13), - button(text("Recover interrupted save").size(12)) - .padding(10) - .on_press_maybe( - (!edits_block_recovery && !app.busy).then_some(Message::SourceRecover), - ), - ] - .spacing(8), - ) - .padding(12) - .width(Fill) - .style(style::card) - .into() - } else { - column![].into() - }; - let mut diagnostics = column![].spacing(5); - let mut visible_diagnostics = 0; - for (index, issue) in app.source_diagnostics.iter().enumerate() { - // The persistent recovery banner supplies the action and explanation; - // repeating the internal journal path obscures ordinary diagnostics. - if app.pending_recovery.is_some() && issue.diagnostic.code == "pending_save" { - continue; + use crate::app::text_workspace::DrawerTab; + use oa_core::source_project::SourceLocation; + let visible = app.text_workspace.drawer() == Some(DrawerTab::Problems); + let publication = app + .text_workspace + .diagnostic_publication() + .filter(|publication| visible && publication.source() == app.source_key()); + let mut diagnostics = column![].spacing(3).width(Fill); + if visible { + if publication.is_none() && !app.source_diagnostics.is_empty() { + diagnostics = diagnostics + .push(text("Source changed. Waiting for current source checks…").size(12)); } - visible_diagnostics += 1; - diagnostics = diagnostics.push( - button( - text(format!( - "{}:{}:{} {}", - issue.path, - issue.diagnostic.line, - issue.diagnostic.column, - issue.diagnostic.message - )) - .size(12) - .color(match issue.diagnostic.severity { - validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64), - validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23), - }), - ) - .width(Fill) - .padding([4, 6]) - .style(style::nav) - .on_press(Message::SourceDiagnostic(index)), - ); - for related in &issue.related { - diagnostics = diagnostics.push(action( - &format!("Related declaration · {}", related.path), - Message::SourceRelated(related.clone()), - )); + if app.source_diagnostics.is_empty() { + diagnostics = diagnostics.push(text("No source problems reported.").size(12)); + } + for issue in &app.source_diagnostics { + let location = SourceLocation { + path: issue.path.clone(), + span: issue.diagnostic.span, + }; + let severity = match issue.diagnostic.severity { + validation::Severity::Error => "Error", + validation::Severity::Warning => "Warning", + }; + diagnostics = diagnostics.push( + button( + text(format!( + "{severity} · {}:{}:{} · {}", + issue.path, + issue.diagnostic.line, + issue.diagnostic.column, + issue.diagnostic.message, + )) + .size(12) + .wrapping(widget::text::Wrapping::WordOrGlyph), + ) + .width(Fill) + .padding([4, 6]) + .style(style::nav) + .on_press_maybe(app.text_diagnostic_message(location)), + ); + for related in &issue.related { + diagnostics = diagnostics.push( + button( + text(format!("Related declaration · {}", related.path)) + .size(12) + .wrapping(widget::text::Wrapping::WordOrGlyph), + ) + .width(Fill) + .padding([4, 12]) + .style(style::nav) + .on_press_maybe(app.text_diagnostic_message(related.clone())), + ); + } } } - let editor = widget::text_editor(&app.source_editor) - .id(app.source_editor_id()) - .style(style::source_editor) - .on_action(Message::SourceEdit) - .key_binding({ - let context = app.source_symbol_context(); - let rename = app.source_rename_begin(); - move |press| source_binding(press, &context, Some(&rename)) - }) - .font(Font::MONOSPACE) - .size(14) - .padding(16) - .height(Fill) - .highlight_with::( - app.source_snapshot.clone(), - crate::highlight::format, - ); - let header = container( - column![ - row![ - container(heading( - "Textual model", - "One model across files · comments and identities preserved" - )) - .width(Fill), - button(text("Apply all").size(12)) - .padding(10) - .on_press(Message::ApplySource), - action("Revert file", Message::RevertSource) - ] - .spacing(8) - .align_y(iced::Center), - row![ - action("Undo text", Message::SourceUndo), - action("Redo text", Message::SourceRedo), - button(text(undo_label).size(12)).padding([8,10]).style(style::nav) - .on_press_maybe(undo_message), - button(text("Redo model").size(12)).padding([8,10]).style(style::nav) - .on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)), - action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))), - label(if app.source_dirty() { - "Drafts pending" - } else { - "Model in sync" - }) - ] - .spacing(7) - .align_y(iced::Center), - recovery, - text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK), - label(if app.pending_recovery.is_some() && !app.source_workspace.applied { - "The previous graphical model remains available while these files are recovered." - } else if !app.source_workspace.applied { - "This project needs repair. Apply all adopts it; the previous graphical model remains available." - } else if app.source_workspace.detached_dirty() { - "A detached file has an unsaved draft. Reattach it or revert that file before saving." - } else { - "Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)." - }), - navigation, - row![ - action("Definition · F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::Definition))), - action("References · Shift F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::References))), - action("Rename alias · F2", app.source_rename_message()), - action("Show in diagrams…", app.source_symbol_message(crate::source_navigation::SymbolEvent::DiagramUsages)), - button(text("Back · Alt ←").size(12)).padding([8, 10]).style(style::nav) - .on_press_maybe((app.source_symbols.back.iter().any(|entry| entry.session == app.source_workspace.session())).then(|| app.source_symbol_message(crate::source_navigation::SymbolEvent::Back))), - ].spacing(7), - scrollable(diagnostics) - .height((visible_diagnostics as f32 * 32.).min(132.)) - ] - .spacing(12) - ) - .padding(18); - let editor = widget::sensor(editor) - .key(( - app.source_workspace.active.clone(), - app.source_reveal_ticket(), - )) - .on_show({ - let ticket = app.source_reveal_ticket(); - move |_| Message::SourceViewport(ticket) - }) - .on_resize({ - let ticket = app.source_reveal_ticket(); - move |_| Message::SourceViewport(ticket) - }); - let workspace: View<'_, Message> = if app.source_symbols.open { - row![ - container(editor).width(Fill).height(Fill), - container(scrollable(source_symbols::panel(app))) - .width(300) - .height(Fill), - ] - .height(Fill) - .into() - } else { - container(editor).width(Fill).height(Fill).into() - }; - source_layout(header.into(), workspace, app.window_size.height) + let guard = crate::diagnostic_input::Guard::new( + publication.map(|p| (p.generation(), app.text_workspace.drawer_visit())), + diagnostics, + ); + text_workspace::view(app, source_symbols::panel(app), guard.into()) } -fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>( - header: View<'a, Message, iced::Theme, Renderer>, - workspace: View<'a, Message, iced::Theme, Renderer>, - window_height: f32, -) -> View<'a, Message, iced::Theme, Renderer> { - // Lint clears and repopulates diagnostics asynchronously. Reserve the same - // outer slot so a pointer aimed at the editor cannot land in the header. - column![ - container(scrollable(header).id("source-header")).height((window_height * 0.42).max(180.)), - workspace, - ] - .height(Fill) - .into() -} pub(crate) fn source_binding( press: widget::text_editor::KeyPress, context: &crate::source_navigation::SymbolContext, @@ -1392,8 +1224,7 @@ pub(crate) mod source_tests { use iced::advanced::{ Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget, }; - use iced::widget::column; - use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector}; + use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation}; // Measure real Iced paragraphs/editor text without constructing a window or GPU. pub(crate) struct FontLayout; @@ -1438,207 +1269,6 @@ pub(crate) mod source_tests { fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {} } - #[derive(Default)] - struct Geometry { - header: Option<(Rectangle, Rectangle, Vector)>, - editor: Option, - } - impl core_widget::Operation for Geometry { - fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) { - visit(self); - } - fn container(&mut self, id: Option<&core_widget::Id>, bounds: Rectangle) { - if id == Some(&core_widget::Id::new("test-source-editor")) { - self.editor = Some(bounds); - } - } - fn scrollable( - &mut self, - id: Option<&core_widget::Id>, - bounds: Rectangle, - content: Rectangle, - offset: Vector, - _: &mut dyn core_widget::operation::Scrollable, - ) { - if id == Some(&core_widget::Id::new("source-header")) { - self.header = Some((bounds, content, offset)); - } - } - } - - fn mounted_source<'a>( - content: &'a widget::text_editor::Content, - diagnostics: usize, - find: bool, - window_height: f32, - ) -> View<'a, Message, iced::Theme, FontLayout> { - let mut notices = column![].spacing(5); - for index in 0..diagnostics { - notices = notices.push(button(text(format!( - "救援/assess.arcadia:{}:5 · Warning: verify this participant's engineering allocation", index + 1, - )).size(12)).padding([4, 6]).width(Fill)); - } - let find: View<'_, Message, iced::Theme, FontLayout> = if find { - column![ - text_input("Find literal text", "assess").on_input(|s| Message::SourceNavigation( - crate::source_navigation::Event::Query(s) - )), - text_input("Line", "1").on_input(|s| Message::SourceNavigation( - crate::source_navigation::Event::Line(s) - )), - ] - .spacing(8) - .into() - } else { - Space::new().into() - }; - let header = container( - column![ - text("Textual model").size(24), - text("Edit the current file. Apply text validates the complete included model.") - .size(11), - row![ - button(text("Apply text")), - button(text("Undo text")), - button(text("Model Undo")) - ] - .spacing(8), - text("救援/assess.arcadia · All files applied to the accepted model").size(11), - text("Undo text affects this draft. Model Undo restores accepted files together.") - .size(11), - find, - row![ - button(text("Definition · F12")), - button(text("References · Shift F12")), - button(text("Back")) - ] - .spacing(7), - scrollable(notices).height((diagnostics as f32 * 32.).min(132.)), - ] - .spacing(12), - ) - .padding(18); - let editor = container( - widget::text_editor(content) - .on_action(Message::SourceEdit) - .font(Font::MONOSPACE) - .size(14) - .padding(16) - .height(Fill), - ) - .id("test-source-editor") - .height(Fill) - .width(Fill); - source_layout(header.into(), editor.into(), window_height) - } - - #[test] - fn source_header_keeps_editor_bounds_stable_through_lint_and_find_rebuilds() { - for (host, window_height) in [ - (Size::new(720., 540.), 700.), - (Size::new(1180., 890.), 1080.), - ] { - let content = widget::text_editor::Content::::with_text( - "// is excluded\r\n// 雨\r\n", - ); - let mut tree = core_widget::Tree::empty(); - let mut baseline = None; - for (diagnostics, find) in [ - (0, false), - (6, false), - (0, false), - (6, true), - (1, true), - (0, false), - ] { - let mut ui = mounted_source(&content, diagnostics, find, window_height); - tree.diff(ui.as_widget()); - let node = ui.as_widget_mut().layout( - &mut tree, - &FontLayout, - &layout::Limits::new(Size::ZERO, host), - ); - let mut geometry = Geometry::default(); - ui.as_widget_mut().operate( - &mut tree, - Layout::new(&node), - &FontLayout, - &mut geometry, - ); - let editor = geometry.editor.unwrap(); - let (header, _, _) = geometry.header.unwrap(); - let slot = node.children()[0].bounds(); - assert_eq!(slot.height, (window_height * 0.42).max(180.)); - assert!( - header.height <= slot.height, - "short content may shrink inside the fixed slot" - ); - assert!( - editor.height >= 240., - "remaining editor must stay usable: {editor:?}" - ); - assert_eq!(editor.y, slot.y + slot.height); - if let Some(expected) = baseline { - assert_eq!(editor, expected, "diagnostics={diagnostics}, find={find}"); - } else { - baseline = Some(editor); - } - } - } - } - - #[test] - fn source_header_remains_scrollable_and_preserves_offset_across_diagnostic_refresh() { - let content = widget::text_editor::Content::::with_text("// 雨\r\n"); - let host = Size::new(720., 540.); - let mut tree = core_widget::Tree::empty(); - let mut saved = None; - for diagnostics in [6, 7] { - let mut ui = mounted_source(&content, diagnostics, true, 700.); - tree.diff(ui.as_widget()); - let node = ui.as_widget_mut().layout( - &mut tree, - &FontLayout, - &layout::Limits::new(Size::ZERO, host), - ); - let mut geometry = Geometry::default(); - ui.as_widget_mut() - .operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry); - let (bounds, contents, _) = geometry.header.unwrap(); - assert!( - contents.height > bounds.height, - "fixture must actually overflow" - ); - if saved.is_none() { - let mut messages = Vec::new(); - let mut shell = Shell::new(&mut messages); - ui.as_widget_mut().update( - &mut tree, - &iced::Event::Mouse(iced::mouse::Event::WheelScrolled { - delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -45. }, - }), - Layout::new(&node), - iced::mouse::Cursor::Available(Point::new(bounds.x + 8., bounds.y + 8.)), - &FontLayout, - &mut iced::advanced::clipboard::Null, - &mut shell, - &Rectangle::with_size(host), - ); - assert!(shell.is_event_captured()); - } - let mut geometry = Geometry::default(); - ui.as_widget_mut() - .operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry); - let offset = geometry.header.unwrap().2; - assert!(offset.y > 0., "the actual mounted Scrollable must move"); - if let Some(before) = saved { - assert_eq!(offset, before); - } else { - saved = Some(offset); - } - } - } - #[cfg(target_os = "windows")] #[test] fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() { @@ -1945,3 +1575,8 @@ mod tests { assert_eq!(short("e\u{0301}B", 1), "…"); } } + +#[cfg(all(test, debug_assertions))] +pub(crate) fn captured_text_drawer_resize(app: &App) -> Message { + text_workspace::capture_drawer_resize(app) +} diff --git a/crates/oa-desktop/src/views/source_symbols.rs b/crates/oa-desktop/src/views/source_symbols.rs index bdcbe734..b145405b 100644 --- a/crates/oa-desktop/src/views/source_symbols.rs +++ b/crates/oa-desktop/src/views/source_symbols.rs @@ -2,80 +2,151 @@ use crate::{ app::{App, Message}, source_navigation::{ SymbolEvent, - query::{self, Declaration, Disposition, Evidence, Identity, Membership, Position}, + query::{Declaration, Disposition, Evidence, Identity, Membership, Position}, }, style, }; use iced::{ Element, Fill, - widget::{Space, button, column, container, row, scrollable, text, tooltip}, + widget::{button, column, container, scrollable, text, tooltip}, }; pub(super) fn panel(app: &App) -> Element<'_, Message> { + use crate::app::text_workspace::{DrawerTab, Event}; + use std::collections::BTreeSet; let state = &app.source_symbols; - if !state.open - || (state.results.is_none() && state.in_flight.is_none() && state.notice.is_none()) - { - return column![].into(); + let visible = app.text_workspace.drawer() == Some(DrawerTab::References); + let presentation = app.text_workspace.references(); + let mut content = column![].spacing(0).width(Fill); + if visible && let Some(notice) = &state.notice { + content = content.push(text(notice.clone()).size(12)); } - let title = state.results.as_ref().map_or_else( - || "Source symbols".to_owned(), - |results| { - let action = match results.action { - query::Action::Definition => "Definitions", - query::Action::References => "References", - }; - results.alias.as_ref().map_or_else( - || action.to_owned(), - |alias| format!("{action} · @{}", preview(alias, 40)), - ) - }, - ); - let mut content = column![ - row![ - text(title).size(13), - Space::new().width(Fill), - button(text("Close").size(11)) - .padding([3, 8]) - .style(style::nav) - .on_press(app.source_symbol_message(SymbolEvent::Close)), - ] - .align_y(iced::Center) - ] - .spacing(5); - - if let Some(notice) = &state.notice { - content = content.push(super::label(preview(notice, 240))); - } - if state.in_flight.is_some() { - content = content.push(super::label("Finding source symbols…")); - } else if let Some(results) = &state.results { + if visible && let Some(results) = &state.results { let stale = results.stamp.snapshot != app.source_key(); - if stale { - content = content.push(super::label( - "Stale results — source changed. Run Definition or References again.", - )); + let enabled = !stale && app.source_navigation_available(); + let files: BTreeSet<_> = results + .definitions + .iter() + .map(|row| row.path.as_str()) + .chain(results.references.iter().map(|row| row.path.as_str())) + .collect(); + let mut ordered: Vec<_> = files.iter().copied().collect(); + // Keep the query's captured origin first even after navigating elsewhere. + if let Some(origin) = state + .display_request + .as_ref() + .map(|request| request.path.as_str()) + { + ordered.sort_by_key(|path| (*path != origin, *path)); + } + for path in ordered { + let collapsed = presentation.collapsed(path); + let count = results + .definitions + .iter() + .filter(|row| row.path == path) + .count() + + results + .references + .iter() + .filter(|row| row.path == path) + .count(); + let suffix = distinguishing_path(path, &files); + content = content.push( + button( + text(format!( + "{} {suffix} · {count}", + if collapsed { "▸" } else { "▾" } + )) + .size(12) + .wrapping(iced::widget::text::Wrapping::None), + ) + .height(24) + .width(Fill) + .padding([2, 6]) + .style(style::nav) + .on_press_maybe(app.source_navigation_available().then(|| { + Message::TextWorkspace(Event::ToggleFile { + stamp: results.stamp, + visit: app.text_workspace.drawer_visit(), + path: path.to_owned(), + }) + })), + ); + if collapsed { + continue; + } + for (index, definition) in results + .definitions + .iter() + .enumerate() + .filter(|(_, row)| row.path == path) + { + content = content.push(location_row( + declaration_title(&definition.declaration), + path, + definition.position, + provenance( + definition.membership, + definition.evidence, + definition.declaration.identity, + false, + ), + enabled.then(|| { + app.source_symbol_message(SymbolEvent::Choose { + stamp: results.stamp, + visit: app.text_workspace.drawer_visit(), + reference: false, + index, + }) + }), + )); + } + for (index, reference) in results + .references + .iter() + .enumerate() + .filter(|(_, row)| row.path == path) + { + let title = reference.context.as_ref().map_or_else( + || format!("@{} · lexical use", preview(&reference.alias, 48)), + |context| format!("use · {}", declaration_title(context)), + ); + content = content.push(location_row( + title, + path, + reference.position, + provenance( + reference.membership, + reference.evidence, + reference + .context + .as_ref() + .map_or(Identity::Missing, |context| context.identity), + true, + ), + enabled.then(|| { + app.source_symbol_message(SymbolEvent::Choose { + stamp: results.stamp, + visit: app.text_workspace.drawer_visit(), + reference: true, + index, + }) + }), + )); + } + // Full path is readable by ordinary scrolling, independently of hover. + content = content.push( + container( + text(path.to_owned()) + .font(iced::Font::MONOSPACE) + .size(12) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ) + .padding([3, 8]), + ); } let scope = &results.scope; - content = content.push(super::label(format!( - "{} · {} · {} included / {} detached / {} unverified files · {} scope issues", - if scope.partial() { - "Partial scope" - } else { - "Complete include scope" - }, - if !scope.applied { - "Unapplied drafts" - } else if scope.validated { - "Validated sources" - } else { - "Draft sources" - }, - scope.included_files, - scope.detached_files, - scope.unverified_files, - scope.issues.len(), - ))); let explanation = match results.disposition { Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.", Disposition::NoDefinition => "No matching declaration in the loaded sources.", @@ -87,89 +158,113 @@ pub(super) fn panel(app: &App) -> Element<'_, Message> { "Lexical uses of this alias; these do not establish a semantic target." } }; - content = content.push(super::label(explanation)); - if results.total_definitions > 1 { - content = content.push(super::label(format!( - "Alias collision: {} declarations. Uses are not attributed to a single declaration.", - results.total_definitions, - ))); - } - let enabled = !stale && !app.busy; - let mut rows = column![super::label(counts( - "Declarations", - results.definitions.len(), - results.total_definitions, - results.omitted_definitions, - ))] - .spacing(4); - for (index, definition) in results.definitions.iter().enumerate() { - rows = rows.push(location_row( - declaration_title(&definition.declaration), - &definition.path, - definition.position, - provenance( - definition.membership, - definition.evidence, - definition.declaration.identity, - false, - ), - enabled.then(|| { - app.source_symbol_message(SymbolEvent::Choose { - stamp: results.stamp, - reference: false, - index, - }) - }), - )); - } - rows = rows.push(super::label(counts( - "Lexical uses", - results.references.len(), - results.total_references, - results.omitted_references, - ))); - for (index, reference) in results.references.iter().enumerate() { - let title = reference.context.as_ref().map_or_else( - || format!("@{} · lexical use", preview(&reference.alias, 48)), - |context| { - format!( - "@{} · inside {}", - preview(&reference.alias, 48), - declaration_title(context) - ) - }, + content = content.push(text(explanation).size(12)); + if stale { + content = content.push( + text("Source changed. Run Definition or References again for current locations.") + .size(12), ); - rows = rows.push(location_row( - title, - &reference.path, - reference.position, - provenance( - reference.membership, - reference.evidence, - reference - .context - .as_ref() - .map_or(Identity::Missing, |context| context.identity), - true, - ), - enabled.then(|| { - app.source_symbol_message(SymbolEvent::Choose { - stamp: results.stamp, - reference: true, - index, - }) - }), - )); } - let row_count = results.definitions.len() + results.references.len(); - content = - content.push(scrollable(rows).height((44.0 + row_count as f32 * 48.0).min(180.0))); + if results.total_definitions > 1 { + content = content.push( + text("Alias collision: uses are not attributed to a single declaration.").size(12), + ); + } + content = content + .push( + text(format!( + "{} · {} included / {} detached / {} unverified files · {} scope issues", + if scope.partial() { + "Partial include scope" + } else { + "Complete include scope" + }, + scope.included_files, + scope.detached_files, + scope.unverified_files, + scope.issues.len() + )) + .size(12), + ) + .push( + text(if !scope.applied { + "Unapplied drafts" + } else if scope.validated { + "Validated sources" + } else { + "Draft sources" + }) + .size(12), + ) + .push( + text(counts( + "Declarations", + results.definitions.len(), + results.total_definitions, + results.omitted_definitions, + )) + .size(12), + ) + .push( + text(counts( + "Lexical uses", + results.references.len(), + results.total_references, + results.omitted_references, + )) + .size(12), + ); } - container(content) - .padding(8) - .width(Fill) - .style(style::subtle) - .into() + let identity = if visible { + state + .display_request + .as_ref() + .map(|request| (request.job, app.text_workspace.drawer_visit())) + } else { + None + }; + let guarded = crate::diagnostic_input::Guard::new(identity, content); + let mut body = scrollable( + container(guarded) + .width(Fill) + .padding(iced::Padding::ZERO.right(14)), + ) + .id(app.text_workspace.reference_scroll_id()) + .height(Fill) + .width(Fill); + if let Some(stamp) = presentation.stamp() { + let visit = app.text_workspace.drawer_visit(); + body = body.on_scroll(move |viewport| { + Message::TextWorkspace(Event::ReferenceScrolled { + stamp, + visit, + y: viewport.absolute_offset().y, + }) + }); + } + body.into() +} + +/// Shortest suffix that still distinguishes duplicate basenames and suffix paths. +fn distinguishing_path<'a>(path: &'a str, files: &std::collections::BTreeSet<&str>) -> &'a str { + let starts = std::iter::once(0).chain(path.match_indices('/').map(|(index, _)| index + 1)); + for start in starts.collect::>().into_iter().rev() { + let candidate = &path[start..]; + if files + .iter() + .filter(|other| { + **other == candidate + || other + .strip_suffix(candidate) + .is_some_and(|prefix| prefix.ends_with('/')) + }) + .count() + == 1 + { + return candidate; + } + } + path } fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String { @@ -223,23 +318,24 @@ fn location_row( ) -> Element<'static, Message> { let line = position.line.saturating_add(1); let column = position.byte_column.saturating_add(1); - let location = format!("{}:{line}:{column}", preview(path, 72)); + let full = + format!("{path}:{line}:{column} · {title} · {status} (1-based line and UTF-8 byte column)"); tooltip( button( - column![ - text(title).size(12), - super::label(format!("{location} · {status}")) - ] - .spacing(2), + container( + text(format!("{line}:{column} {title} · {status}")) + .size(12) + .wrapping(iced::widget::text::Wrapping::None), + ) + .clip(true) + .width(Fill), ) - .padding([4, 6]) + .padding([3, 12]) + .height(28) .width(Fill) .style(style::nav) .on_press_maybe(event), - text(format!( - "{path}:{line}:{column} (1-based line and UTF-8 byte column)" - )) - .size(11), + text(full).size(12), tooltip::Position::Top, ) .into() @@ -258,3 +354,32 @@ fn preview(value: &str, limit: usize) -> String { } result } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn suffixes_distinguish_nested_duplicate_basenames_without_changing_unicode() { + let files = [ + "a/core.arcadia", + "b/core.arcadia", + "core.arcadia", + "救援/actors.arcadia", + ] + .into_iter() + .collect(); + assert_eq!( + distinguishing_path("a/core.arcadia", &files), + "a/core.arcadia" + ); + assert_eq!( + distinguishing_path("b/core.arcadia", &files), + "b/core.arcadia" + ); + assert_eq!(distinguishing_path("core.arcadia", &files), "core.arcadia"); + assert_eq!( + distinguishing_path("救援/actors.arcadia", &files), + "actors.arcadia" + ); + } +} diff --git a/crates/oa-desktop/src/views/text_workspace.rs b/crates/oa-desktop/src/views/text_workspace.rs new file mode 100644 index 00000000..84cb8b12 --- /dev/null +++ b/crates/oa-desktop/src/views/text_workspace.rs @@ -0,0 +1,1165 @@ +//! Compact Text workspace presentation. Visibility is explicit controller state; +//! rebuilding diagnostics or query results must never change editor geometry. +use crate::app::text_workspace::{Command, DrawerContext, DrawerTab, Event}; +use crate::{ + app::{App, Message}, + style, +}; +use iced::{ + Element, Fill, Font, Length, + widget::{self, button, column, container, row, scrollable, text, text_input}, +}; +use std::{cell::Cell, rc::Rc}; + +pub(crate) const STRIP_HEIGHT: f32 = 42.; +pub(crate) const FIND_HEIGHT: f32 = 36.; +pub(crate) const DRAWER_HEIGHT: f32 = 180.; +pub(crate) const STATUS_HEIGHT: f32 = 22.; + +/// The caller supplies one persistent diagnostic Guard, including when hidden +/// (None publication and empty child). References is empty while hidden; its +/// grouping and scroll restoration are controller-owned. +pub(super) fn view<'a>( + app: &'a App, + references: Element<'a, Message>, + problems: Element<'a, Message>, +) -> Element<'a, Message> { + let tab = app.text_workspace.drawer(); + let measured_drawer = Rc::new(Cell::new(DRAWER_HEIGHT)); + let strip = row![ + container( + text(app.source_workspace.active.clone()) + .font(Font::MONOSPACE) + .size(12) + .wrapping(widget::text::Wrapping::None) + ) + .width(Fill) + .clip(true), + compact_action("Find", app.text_command_message(Command::Find)), + crate::context_menu::Area::left( + button(text("Actions ▾").size(12)) + .padding([4, 8]) + .style(style::nav) + .on_press(app.text_menu_message(iced::Point::ORIGIN)), + |anchor| app.text_menu_message(anchor), + ), + compact_action("Apply all", Message::ApplySource), + ] + .spacing(6) + .align_y(iced::Center); + let find: Element<'a, Message> = if app.source_navigation.open { + find_bar(app) + } else { + widget::Space::new().into() + }; + let editor = widget::text_editor(&app.source_editor) + .id(app.source_editor_id()) + .style(style::source_editor) + .on_action(Message::SourceEdit) + .key_binding({ + let context = app.source_symbol_context(); + let rename = app.source_rename_begin(); + move |press| super::source_binding(press, &context, Some(&rename)) + }) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .height(Fill) + .highlight_with::( + app.source_snapshot.clone(), + crate::highlight::format, + ); + let editor = widget::sensor(editor) + .key(( + app.source_workspace.active.clone(), + app.source_reveal_ticket(), + )) + .on_show({ + let ticket = app.source_reveal_ticket(); + move |_| Message::SourceViewport(ticket) + }) + .on_resize({ + let ticket = app.source_reveal_ticket(); + move |_| Message::SourceViewport(ticket) + }); + let (errors, warnings) = diagnostic_counts(app); + let summary: Element<'a, Message> = match tab { + Some(DrawerTab::References) => reference_summary(app), + Some(DrawerTab::Problems) => text(format!( + "{errors} source errors · {warnings} warnings · choose a location for details" + )) + .size(12) + .wrapping(widget::text::Wrapping::None) + .into(), + Some(DrawerTab::Status) => text("Source adoption, recovery and text/model undo") + .size(12) + .wrapping(widget::text::Wrapping::None) + .into(), + None => widget::Space::new().into(), + }; + let chrome: Element<'a, Message> = if tab.is_some() { + let mut tabs = row![].spacing(4).align_y(iced::Center); + for (destination, title) in [ + (DrawerTab::References, "References"), + (DrawerTab::Problems, "Problems"), + (DrawerTab::Status, "Status"), + ] { + tabs = tabs.push( + button(text(title).size(12)) + .padding([4, 8]) + .style(if tab == Some(destination) { + style::selected + } else { + style::nav + }) + .on_press(Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + destination, + ))), + ); + } + tabs.push(widget::Space::new().width(Fill)) + .push(resize_button( + "Smaller", + measured_drawer.clone(), + -60., + app.text_drawer_context(), + )) + .push(resize_button( + "Larger", + measured_drawer.clone(), + 60., + app.text_drawer_context(), + )) + .push(compact_action( + "Hide", + Message::TextWorkspace(Event::HideDrawer(app.text_drawer_context())), + )) + .into() + } else { + widget::Space::new().into() + }; + let status: Element<'a, Message> = if tab == Some(DrawerTab::Status) { + status_body(app) + } else { + widget::Space::new().into() + }; + let body = widget::Column::from_vec(vec![ + container(references) + .width(Fill) + .height(if tab == Some(DrawerTab::References) { + Length::Fill + } else { + Length::Fixed(0.) + }) + .clip(true) + .into(), + tab_slot( + problems, + tab == Some(DrawerTab::Problems), + "text-problems-body", + ), + tab_slot(status, tab == Some(DrawerTab::Status), "text-status-body"), + ]) + .width(Fill) + .height(Fill); + let drawer = widget::Column::from_vec(vec![ + container(chrome).width(Fill).height(32).clip(true).into(), + container(summary).width(Fill).height(24).clip(true).into(), + body.into(), + ]) + .width(Fill) + .height(Fill); + let mut states = Vec::new(); + if app.pending_recovery.is_some() { + states.push("Recovery"); + } + if !app.source_workspace.applied { + states.push("Repair needed"); + } + if app.source_workspace.detached_dirty() { + states.push("Detached draft"); + } + if app.source_dirty() { + states.push("Drafts pending"); + } + let state = match states.as_slice() { + [] => "Model in sync".to_owned(), + [only] => (*only).to_owned(), + [first, rest @ ..] => format!("{first} +{} conditions", rest.len()), + }; + let source_status = button( + text(format!( + "{errors} errors · {warnings} warnings · {state} · Status…" + )) + .size(12), + ) + .padding([2, 8]) + .width(Fill) + .height(STATUS_HEIGHT) + .style(style::nav) + .on_press(Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Status, + ))); + layout( + container(strip) + .width(Fill) + .height(Fill) + .padding([4, 8]) + .style(style::panel) + .into(), + find, + editor.into(), + container(drawer).style(style::panel).into(), + source_status.into(), + LayoutSettings { + drawer_context: app.text_drawer_context(), + find_open: app.source_navigation.open, + drawer_height: tab.map(|_| app.text_workspace.drawer_height()), + measured_drawer, + }, + ) +} + +fn resize_button( + label: &'static str, + height: Rc>, + delta: f32, + context: DrawerContext, +) -> Element<'static, Message> { + widget::tooltip( + button(text(label).size(12)) + .padding([4, 8]) + .style(style::nav) + .on_press_with(move || { + Message::TextWorkspace(Event::ResizeDrawer(context, height.get() + delta)) + }), + text(if delta < 0. { + "Smaller drawer · Alt Page Up" + } else { + "Larger drawer · Alt Page Down" + }) + .size(12), + widget::tooltip::Position::Top, + ) + .into() +} + +fn tab_slot<'a>( + body: Element<'a, Message>, + active: bool, + id: &'static str, +) -> Element<'a, Message> { + container( + scrollable( + container(body) + .width(Fill) + .padding(iced::Padding::ZERO.right(14)), + ) + .id(id) + .height(Fill) + .width(Fill), + ) + .width(Fill) + .height(if active { + Length::Fill + } else { + Length::Fixed(0.) + }) + .clip(true) + .into() +} + +fn find_bar(app: &App) -> Element<'_, Message> { + use crate::source_navigation::Event as Find; + container( + row![ + text_input("Find literal text…", &app.source_navigation.query) + .id("source-find") + .on_input(|value| Message::SourceNavigation(Find::Query(value))) + .on_submit(Message::SourceNavigation(Find::Next(false))) + .size(12) + .padding(5) + .width(Fill), + compact_action("Previous", Message::SourceNavigation(Find::Next(true))), + compact_action("Next", Message::SourceNavigation(Find::Next(false))), + text(app.source_navigation.label()).size(12), + text_input("Line", &app.source_navigation.line) + .id("source-line") + .on_input(|value| Message::SourceNavigation(Find::Line(value))) + .on_submit(Message::SourceNavigation(Find::Go)) + .size(12) + .padding(5) + .width(58), + compact_action("Go", Message::SourceNavigation(Find::Go)), + compact_action("Close", app.source_find_close_message()), + ] + .spacing(4) + .align_y(iced::Center), + ) + .width(Fill) + .height(Fill) + .padding([2, 8]) + .into() +} + +fn reference_summary<'a, Renderer: iced::advanced::text::Renderer + 'a>( + app: &'a App, +) -> Element<'a, Message, iced::Theme, Renderer> { + let Some(results) = &app.source_symbols.results else { + return text(if app.source_symbols.in_flight.is_some() { + "Finding source symbols…" + } else { + "Run References or Definition from Actions or the editor shortcuts." + }) + .size(12) + .wrapping(widget::text::Wrapping::None) + .into(); + }; + let subject = results.alias.as_deref().map_or_else( + || "Source symbols".to_owned(), + |alias| format!("@{}", super::short(alias, 32)), + ); + let value = format!( + "{subject} · {} declarations · {} uses", + results.total_definitions, results.total_references + ); + let mut flags = Vec::new(); + if results.stamp.snapshot != app.source_key() { + flags.push("Stale"); + } + if results.scope.partial() { + flags.push("Partial"); + } + if results.total_definitions > 1 { + flags.push("Collision"); + } + if results.scope.detached_files > 0 { + flags.push("Detached"); + } + if results.scope.unverified_files > 0 { + flags.push("Unverified"); + } + let detail = text(value).size(12).wrapping(widget::text::Wrapping::None); + if flags.is_empty() { + return detail.into(); + } + // Exception status owns its width; a long alias can only clip the detail. + row![ + text(flags.join(" · ")) + .size(12) + .wrapping(widget::text::Wrapping::None), + container(detail).width(Fill).clip(true), + ] + .spacing(8) + .align_y(iced::Center) + .width(Fill) + .into() +} + +fn compact_action(label: &str, message: Message) -> widget::Button<'_, Message> { + button(text(label.to_owned()).size(12)) + .padding([4, 8]) + .style(style::nav) + .on_press(message) +} + +/// Conditions accumulate: recovery must not conceal detached drafts or repair. +fn status_body(app: &App) -> Element<'_, Message> { + let workspace = &app.source_workspace; + let mut body = column![ + text(workspace.active.clone()).font(Font::MONOSPACE).size(12).wrapping(widget::text::Wrapping::WordOrGlyph), + text("Resize this drawer with Smaller / Larger or Alt Page Up / Alt Page Down.").size(13), + text("Undo text affects only this file's draft. Undo model restores accepted source files together (100 changes / about 32 MiB). Undo rename restores the accepted rename transaction when available.").size(13), + row![compact_action("Undo text", app.text_command_message(Command::UndoText)), compact_action("Redo text", app.text_command_message(Command::RedoText))].spacing(6), + ].spacing(8).width(Fill); + if let Some(path) = &app.pending_recovery { + let blocked = !workspace.applied && workspace.has_edits(); + body = body.push(text(format!("Interrupted save · {}", path.display())).size(13).wrapping(widget::text::Wrapping::WordOrGlyph)) + .push(text(if blocked { + "Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them." + } else if workspace.applied { + "Restore files to their state before the interrupted save. Your current model and editor drafts are kept." + } else { + "Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save." + }).size(13)) + .push(button(text("Recover interrupted save").size(12)).padding([4, 8]).on_press_maybe((!blocked && !app.busy).then_some(Message::SourceRecover))); + } + if !workspace.applied { + body = body.push(text("Project needs repair. The previous graphical model remains available. Apply all validates and adopts the loaded reachable files as one model change.").size(13)); + if workspace.has_edits() { + body = body.push(text("Edited files are retained as drafts. Revert each edited file before recovering an interrupted save; reverting discards that file's edits.").size(13)); + } + } + if workspace.detached_dirty() { + body = body.push(text("A detached file has an unsaved draft. Reattach it or revert that file before saving. Closing a detached buffer never deletes its file.").size(13)); + } + if app.source_dirty() { + body = body.push(text("Drafts pending. The graphical views continue to show the accepted model until Apply all succeeds.").size(13)); + } + body = body.push( + row![ + compact_action("Apply all", Message::ApplySource), + compact_action("Revert file", app.text_command_message(Command::RevertFile)), + compact_action("Save all", Message::Save(false)), + ] + .spacing(6), + ); + body.into() +} + +fn diagnostic_counts(app: &App) -> (usize, usize) { + app.source_diagnostics + .iter() + .fold((0, 0), |(errors, warnings), issue| { + match issue.diagnostic.severity { + oa_core::validation::Severity::Error => (errors + 1, warnings), + oa_core::validation::Severity::Warning => (errors, warnings + 1), + } + }) +} + +/// All five positions exist on every rebuild. In particular, the editor never +/// changes siblings or wrapper types when Find, diagnostics or results change. +pub(crate) fn layout<'a, Renderer: iced::advanced::text::Renderer + 'a>( + strip: Element<'a, Message, iced::Theme, Renderer>, + find: Element<'a, Message, iced::Theme, Renderer>, + editor: Element<'a, Message, iced::Theme, Renderer>, + drawer: Element<'a, Message, iced::Theme, Renderer>, + status: Element<'a, Message, iced::Theme, Renderer>, + settings: LayoutSettings, +) -> Element<'a, Message, iced::Theme, Renderer> { + Element::new(WorkspaceLayout { + children: vec![ + container(strip) + .id("text-workspace-strip") + .width(Fill) + .height(Fill) + .clip(true) + .into(), + container(find) + .id("text-workspace-find") + .width(Fill) + .height(Fill) + .clip(true) + .into(), + container(editor) + .id("text-workspace-editor") + .width(Fill) + .height(Fill) + .clip(true) + .into(), + container(drawer) + .id("text-workspace-drawer") + .width(Fill) + .height(Fill) + .clip(true) + .into(), + container(status) + .id("text-workspace-status") + .width(Fill) + .height(Fill) + .clip(true) + .into(), + ], + settings, + }) +} + +pub(crate) struct LayoutSettings { + pub drawer_context: DrawerContext, + pub find_open: bool, + pub drawer_height: Option, + /// Layout publishes the effective height so Smaller immediately shrinks a + /// previously oversized request after a window resize. + pub measured_drawer: Rc>, +} + +fn slot_heights(height: f32, find: bool, requested: Option) -> [f32; 5] { + let height = height.max(0.); + let strip = STRIP_HEIGHT.min(height); + let status = STATUS_HEIGHT.min(height - strip); + let find = if find { + FIND_HEIGHT.min(height - strip - status) + } else { + 0. + }; + let available = (height - strip - status - find).max(0.); + let drawer = requested.map_or(0., |requested| { + let maximum = (available - 240.).max(DRAWER_HEIGHT).min(available); + let requested = if requested.is_finite() { + requested.max(DRAWER_HEIGHT) + } else { + DRAWER_HEIGHT + }; + requested.min(maximum) + }); + [strip, find, available - drawer, drawer, status] +} + +struct WorkspaceLayout<'a, Renderer> { + children: Vec>, + settings: LayoutSettings, +} + +impl + iced::advanced::Widget for WorkspaceLayout<'_, Renderer> +{ + fn children(&self) -> Vec { + self.children + .iter() + .map(iced::advanced::widget::Tree::new) + .collect() + } + fn diff(&self, tree: &mut iced::advanced::widget::Tree) { + tree.diff_children(&self.children); + } + fn size(&self) -> iced::Size { + iced::Size::new(Fill, Fill) + } + fn layout( + &mut self, + tree: &mut iced::advanced::widget::Tree, + renderer: &Renderer, + limits: &iced::advanced::layout::Limits, + ) -> iced::advanced::layout::Node { + let size = limits.resolve(Fill, Fill, iced::Size::ZERO); + let heights = slot_heights( + size.height, + self.settings.find_open, + self.settings.drawer_height, + ); + self.settings.measured_drawer.set(heights[3]); + let mut y = 0.; + let nodes = self + .children + .iter_mut() + .zip(&mut tree.children) + .zip(heights) + .map(|((child, state), height)| { + let slot = iced::Size::new(size.width, height); + let node = child + .as_widget_mut() + .layout( + state, + renderer, + &iced::advanced::layout::Limits::new(slot, slot), + ) + .move_to(iced::Point::new(0., y)); + y += height; + node + }) + .collect(); + iced::advanced::layout::Node::with_children(size, nodes) + } + fn operate( + &mut self, + tree: &mut iced::advanced::widget::Tree, + layout: iced::advanced::Layout<'_>, + renderer: &Renderer, + operation: &mut dyn iced::advanced::widget::Operation, + ) { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { + for ((child, state), layout) in self + .children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + { + child + .as_widget_mut() + .operate(state, layout, renderer, operation); + } + }); + } + fn update( + &mut self, + tree: &mut iced::advanced::widget::Tree, + event: &iced::Event, + layout: iced::advanced::Layout<'_>, + cursor: iced::mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn iced::advanced::Clipboard, + shell: &mut iced::advanced::Shell<'_, Message>, + viewport: &iced::Rectangle, + ) { + // Local drawer commands. Popup is the persistent outer host and owns + // keyboard input while a menu is open; closed drawers claim no keys. + if self.settings.drawer_height.is_some() + && let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key, modifiers, .. + }) = event + && *modifiers == iced::keyboard::Modifiers::ALT + { + let delta = match key { + iced::keyboard::Key::Named(iced::keyboard::key::Named::PageUp) => Some(-60.), + iced::keyboard::Key::Named(iced::keyboard::key::Named::PageDown) => Some(60.), + _ => None, + }; + if let Some(delta) = delta { + shell.publish(Message::TextWorkspace(Event::ResizeDrawer( + self.settings.drawer_context, + self.settings.measured_drawer.get() + delta, + ))); + shell.capture_event(); + return; + } + } + // Preserve delivery to the hidden permanent Guard: it may need to + // swallow an armed release. Hidden content is emptied by the caller. + for ((child, state), layout) in self + .children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + { + child.as_widget_mut().update( + state, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + } + } + fn mouse_interaction( + &self, + tree: &iced::advanced::widget::Tree, + layout: iced::advanced::Layout<'_>, + cursor: iced::mouse::Cursor, + viewport: &iced::Rectangle, + renderer: &Renderer, + ) -> iced::mouse::Interaction { + self.children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .map(|((child, state), layout)| { + child + .as_widget() + .mouse_interaction(state, layout, cursor, viewport, renderer) + }) + .max() + .unwrap_or_default() + } + fn draw( + &self, + tree: &iced::advanced::widget::Tree, + renderer: &mut Renderer, + theme: &iced::Theme, + style: &iced::advanced::renderer::Style, + layout: iced::advanced::Layout<'_>, + cursor: iced::mouse::Cursor, + viewport: &iced::Rectangle, + ) { + if let Some(viewport) = layout.bounds().intersection(viewport) { + for ((child, state), layout) in self + .children + .iter() + .zip(&tree.children) + .zip(layout.children()) + { + child + .as_widget() + .draw(state, renderer, theme, style, layout, cursor, &viewport); + } + } + } + fn overlay<'b>( + &'b mut self, + tree: &'b mut iced::advanced::widget::Tree, + layout: iced::advanced::Layout<'b>, + renderer: &Renderer, + viewport: &iced::Rectangle, + translation: iced::Vector, + ) -> Option> { + iced::advanced::overlay::from_children( + &mut self.children, + tree, + layout, + renderer, + viewport, + translation, + ) + } +} + +#[cfg(all(test, debug_assertions))] +mod tests { + use super::*; + use crate::views::source_tests::FontLayout; + use iced::widget::column; + use iced::{ + Size, + advanced::{ + layout, + widget::{Tree, operation::Focusable}, + }, + }; + + #[test] + fn five_slots_keep_editor_state_and_selection_through_explicit_and_passive_changes() { + let mut content = + widget::text_editor::Content::::with_text("// 雨\r\n@assess\r\n"); + content.move_to(widget::text_editor::Cursor { + position: widget::text_editor::Position { line: 1, column: 7 }, + selection: Some(widget::text_editor::Position { line: 1, column: 1 }), + }); + let expected_cursor = content.cursor(); + let mut tree = Tree::empty(); + let mut state_address = None; + for (find, drawer, issues) in [ + (false, false, 0), + (false, false, 100), + (true, false, 1), + (true, true, 100), + (true, true, 0), + (false, true, 1), + (false, false, 0), + ] { + let mut notices = column![]; + for index in 0..issues { + notices = notices.push(text(format!("Diagnostic {index}"))); + } + let editor = widget::text_editor(&content) + .on_action(Message::SourceEdit) + .height(Fill) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .highlight_with::( + content.text().into(), + crate::highlight::format, + ); + let editor = widget::sensor(editor) + .key(("救援/assess.arcadia".to_owned(), None::)) + .on_show(|_| Message::SourceViewport(None)) + .on_resize(|_| Message::SourceViewport(None)); + let mut view = layout( + text("救援/assess.arcadia").into(), + text("Find").into(), + editor.into(), + scrollable(notices).height(Fill).into(), + text("Status").into(), + LayoutSettings { + drawer_context: DrawerContext { + document: oa_core::Id::nil(), + session: oa_core::Id::nil(), + visit: oa_core::Id::nil(), + }, + find_open: find, + drawer_height: drawer.then_some(DRAWER_HEIGHT), + measured_drawer: Rc::new(Cell::new(0.)), + }, + ); + tree.diff(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, Size::new(720., 540.)), + ); + assert_eq!(node.children().len(), 5); + let editor_bounds = node.children()[2].bounds(); + assert_eq!( + editor_bounds.y, + STRIP_HEIGHT + if find { FIND_HEIGHT } else { 0. } + ); + assert_eq!( + editor_bounds.height, + 540. - STRIP_HEIGHT + - STATUS_HEIGHT + - if find { FIND_HEIGHT } else { 0. } + - if drawer { DRAWER_HEIGHT } else { 0. } + ); + assert!(editor_bounds.height >= 240.); + // Container delegates to the permanent sensor, whose child is the + // actual highlighted editor, matching the production wrapper path. + let state = tree.children[2].children[0] + .state + .downcast_mut::>(); + let address = state as *mut _ as usize; + if let Some(expected) = state_address { + assert_eq!( + address, expected, + "the actual editor state must be retained" + ); + assert!(state.is_focused()); + } else { + state.focus(); + state_address = Some(address); + } + assert_eq!(content.cursor(), expected_cursor); + assert_eq!(content.selection().as_deref(), Some("assess")); + } + } + + #[test] + fn drawer_clamps_to_actual_center_allocation_including_oversize_requests() { + for (host, find, request, expected_drawer, expected_editor) in [ + (Size::new(720., 480.), true, 1200., 180., 200.), + (Size::new(720., 540.), true, 1200., 200., 240.), + (Size::new(1180., 890.), false, 1200., 586., 240.), + (Size::new(1180., 890.), true, 280., 280., 510.), + (Size::new(720., 540.), false, 180., 180., 296.), + ] { + let measured = Rc::new(Cell::new(0.)); + let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout( + text("Strip").into(), + text("Find").into(), + text("Editor").into(), + text("Drawer").into(), + text("Status").into(), + LayoutSettings { + drawer_context: DrawerContext { + document: oa_core::Id::nil(), + session: oa_core::Id::nil(), + visit: oa_core::Id::nil(), + }, + find_open: find, + drawer_height: Some(request), + measured_drawer: measured.clone(), + }, + ); + let mut tree = Tree::new(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, host), + ); + assert_eq!(node.size(), host); + assert_eq!(node.children().len(), 5); + assert_eq!(node.children()[3].bounds().height, expected_drawer); + assert_eq!(node.children()[2].bounds().height, expected_editor); + assert_eq!(measured.get(), expected_drawer); + assert_eq!(node.children()[4].bounds().y + STATUS_HEIGHT, host.height); + } + } + + #[test] + fn keyboard_resize_uses_effective_height_and_only_exact_open_drawer_shortcut() { + use iced::keyboard::{ + self, Key, Modifiers, + key::{Code, Named, Physical}, + }; + for (open, modifiers, named, expected) in [ + (true, Modifiers::ALT, Named::PageUp, Some(140.)), + (true, Modifiers::ALT, Named::PageDown, Some(260.)), + (false, Modifiers::ALT, Named::PageDown, None), + ( + true, + Modifiers::CTRL | Modifiers::ALT, + Named::PageDown, + None, + ), + (true, Modifiers::ALT | Modifiers::SHIFT, Named::PageUp, None), + ] { + let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout( + text("Strip").into(), + text("Find").into(), + text("Editor").into(), + text("Drawer").into(), + text("Status").into(), + LayoutSettings { + drawer_context: DrawerContext { + document: oa_core::Id::nil(), + session: oa_core::Id::nil(), + visit: oa_core::Id::nil(), + }, + find_open: true, + drawer_height: open.then_some(1200.), + measured_drawer: Rc::new(Cell::new(0.)), + }, + ); + let mut tree = Tree::new(view.as_widget()); + let host = Size::new(720., 540.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, host), + ); + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(named), + modified_key: Key::Named(named), + physical_key: Physical::Code(Code::PageDown), + location: keyboard::Location::Standard, + modifiers, + text: None, + repeat: false, + }); + let mut messages = Vec::new(); + let mut shell = iced::advanced::Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &event, + iced::advanced::Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::with_size(host), + ); + assert_eq!(shell.is_event_captured(), expected.is_some()); + assert_eq!(messages.len(), usize::from(expected.is_some())); + if let Some(expected) = expected { + assert!( + matches!(messages[0], Message::TextWorkspace(Event::ResizeDrawer(context, actual)) if actual == expected && context.document == oa_core::Id::nil() && context.session == oa_core::Id::nil() && context.visit == oa_core::Id::nil()) + ); + } + } + } + + #[derive(Default)] + struct ScrollProbe { + scroll: Option<(usize, f32)>, + } + impl iced::advanced::renderer::Renderer for ScrollProbe { + fn start_layer(&mut self, _: iced::Rectangle) {} + fn end_layer(&mut self) {} + fn start_transformation(&mut self, _: iced::Transformation) {} + fn end_transformation(&mut self) {} + fn reset(&mut self, _: iced::Rectangle) {} + fn fill_quad(&mut self, _: iced::advanced::renderer::Quad, _: impl Into) { + } + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce( + Result, + ) + Send + + 'static, + ) { + iced::advanced::renderer::Renderer::allocate_image(&mut (), handle, callback); + } + } + impl iced::advanced::text::Renderer for ScrollProbe { + type Font = Font; + type Paragraph = iced::advanced::graphics::text::Paragraph; + type Editor = iced::advanced::graphics::text::Editor; + const ICON_FONT: Font = Font::DEFAULT; + const CHECKMARK_ICON: char = '0'; + const ARROW_DOWN_ICON: char = '0'; + const SCROLL_UP_ICON: char = '0'; + const SCROLL_DOWN_ICON: char = '0'; + const SCROLL_LEFT_ICON: char = '0'; + const SCROLL_RIGHT_ICON: char = '0'; + const ICED_LOGO: char = '0'; + fn default_font(&self) -> Font { + Font::DEFAULT + } + fn default_size(&self) -> iced::Pixels { + iced::Pixels(16.) + } + fn fill_paragraph( + &mut self, + _: &Self::Paragraph, + _: iced::Point, + _: iced::Color, + _: iced::Rectangle, + ) { + } + fn fill_editor( + &mut self, + editor: &Self::Editor, + _: iced::Point, + _: iced::Color, + _: iced::Rectangle, + ) { + let scroll = editor.buffer().scroll(); + self.scroll = Some((scroll.line, scroll.vertical)); + } + fn fill_text( + &mut self, + _: iced::advanced::text::Text, + _: iced::Point, + _: iced::Color, + _: iced::Rectangle, + ) { + } + } + + #[test] + fn highlighted_sensor_editor_retains_real_scrolled_buffer_across_panel_rebuilds() { + let source = (0..100) + .map(|index| format!("// 雨 {index} @assess\r\n")) + .collect::(); + let mut content = widget::text_editor::Content::::with_text(&source); + content.move_to(widget::text_editor::Cursor { + position: widget::text_editor::Position { line: 2, column: 6 }, + selection: Some(widget::text_editor::Position { line: 2, column: 3 }), + }); + let cursor = content.cursor(); + let mut tree = Tree::empty(); + let mut probe = ScrollProbe::default(); + let mut baseline_scroll = None; + let host = Size::new(720., 540.); + // First iteration sends a real wheel event and applies its editor action. + for (iteration, (find, drawer)) in [ + (false, false), + (false, false), + (true, false), + (true, true), + (true, true), + (false, true), + (false, false), + ] + .into_iter() + .enumerate() + { + let mut messages = Vec::new(); + { + let editor = widget::text_editor(&content) + .on_action(Message::SourceEdit) + .height(Fill) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .highlight_with::( + source.clone().into(), + crate::highlight::format, + ); + let editor = widget::sensor(editor) + .key(("救援/assess.arcadia".to_owned(), None::)) + .on_show(|_| Message::SourceViewport(None)) + .on_resize(|_| Message::SourceViewport(None)); + let mut view = layout( + text("Strip").into(), + text("Find").into(), + editor.into(), + text("Details").into(), + text("Status").into(), + LayoutSettings { + drawer_context: DrawerContext { + document: oa_core::Id::nil(), + session: oa_core::Id::nil(), + visit: oa_core::Id::nil(), + }, + find_open: find, + drawer_height: drawer.then_some(180.), + measured_drawer: Rc::new(Cell::new(0.)), + }, + ); + tree.diff(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut tree, + &probe, + &layout::Limits::new(Size::ZERO, host), + ); + let state = tree.children[2].children[0].state.downcast_mut::>(); + if iteration == 0 { + state.focus(); + } else { + assert!(state.is_focused()); + } + if iteration == 0 { + let mut shell = iced::advanced::Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &iced::Event::Mouse(iced::mouse::Event::WheelScrolled { + delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -120. }, + }), + iced::advanced::Layout::new(&node), + iced::mouse::Cursor::Available(iced::Point::new(100., 120.)), + &probe, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::with_size(host), + ); + assert!(shell.is_event_captured()); + } else { + view.as_widget().draw( + &tree, + &mut probe, + &style::theme(), + &iced::advanced::renderer::Style { + text_color: style::INK, + }, + iced::advanced::Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &iced::Rectangle::with_size(host), + ); + let scroll = probe.scroll.expect("actual highlighted editor was drawn"); + assert!( + scroll.0 > 0 || scroll.1 > 0., + "the fixture must actually scroll" + ); + if let Some(expected) = baseline_scroll { + assert_eq!(scroll, expected); + } else { + baseline_scroll = Some(scroll); + } + } + } + if iteration == 0 { + assert!(messages.iter().any(|message| matches!( + message, + Message::SourceEdit(widget::text_editor::Action::Scroll { .. }) + ))); + } + for message in messages { + if let Message::SourceEdit(action) = message { + content.perform(action); + } + } + assert_eq!(content.cursor(), cursor); + } + } +} + +#[cfg(all(test, debug_assertions))] +mod summary_visibility_tests; + +/// The same retained layout and Alt+PageDown publisher as the production view; +/// only its five content slots are inert for this CPU integration witness. +#[cfg(all(test, debug_assertions))] +pub(super) fn capture_drawer_resize(app: &App) -> Message { + use crate::views::source_tests::FontLayout; + use iced::advanced::{Layout, Shell, layout as core_layout, widget::Tree}; + use iced::keyboard::{self, Key, Modifiers}; + let mut view: Element<'_, Message, iced::Theme, FontLayout> = layout( + widget::Space::new().into(), + widget::Space::new().into(), + widget::Space::new().into(), + widget::Space::new().into(), + widget::Space::new().into(), + LayoutSettings { + drawer_context: app.text_drawer_context(), + find_open: false, + drawer_height: Some(app.text_workspace.drawer_height()), + measured_drawer: Rc::new(Cell::new(0.)), + }, + ); + let mut tree = Tree::new(view.as_widget()); + let host = iced::Size::new(720., 700.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &core_layout::Limits::new(iced::Size::ZERO, host), + ); + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(keyboard::key::Named::PageDown), + modified_key: Key::Named(keyboard::key::Named::PageDown), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::PageDown), + location: keyboard::Location::Standard, + modifiers: Modifiers::ALT, + text: None, + repeat: false, + }); + let mut messages = Vec::new(); + let mut shell = Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &iced::Rectangle::with_size(host), + ); + assert!(shell.is_event_captured()); + assert_eq!(messages.len(), 1); + let message = messages.remove(0); + assert!( + matches!(&message, Message::TextWorkspace(Event::ResizeDrawer(context, height)) + if *context == app.text_drawer_context() && *height > app.text_workspace.drawer_height()) + ); + message +} diff --git a/crates/oa-desktop/src/views/text_workspace/summary_visibility_tests.rs b/crates/oa-desktop/src/views/text_workspace/summary_visibility_tests.rs new file mode 100644 index 00000000..81d39838 --- /dev/null +++ b/crates/oa-desktop/src/views/text_workspace/summary_visibility_tests.rs @@ -0,0 +1,248 @@ +//! Shapes and draws the production summary, checking actual paragraph advances. +//! Native validation remains necessary for platform raster ink/fallback fonts. +use super::*; +use crate::source_navigation::query; +use crate::views::source_tests::FontLayout; +use iced::advanced::{Layout, layout, renderer, text as shaped, widget::Tree}; +use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation}; +use std::sync::Arc; + +struct Cluster { + paragraph: String, + bytes: std::ops::Range, + bounds: Rectangle, + clip: Rectangle, +} +struct Recorder { + clips: Vec, + transforms: Vec, + clusters: Vec, +} +impl Recorder { + fn transform(&self) -> Transformation { + *self.transforms.last().unwrap() + } + fn clip(&self, bounds: Rectangle) -> Rectangle { + self.clips + .last() + .unwrap() + .intersection(&(bounds * self.transform())) + .unwrap_or(Rectangle::with_size(Size::ZERO)) + } +} +impl renderer::Renderer for Recorder { + fn start_layer(&mut self, bounds: Rectangle) { + self.clips.push(self.clip(bounds)); + } + fn end_layer(&mut self) { + self.clips.pop().unwrap(); + } + fn start_transformation(&mut self, transform: Transformation) { + self.transforms.push(self.transform() * transform); + } + fn end_transformation(&mut self) { + self.transforms.pop().unwrap(); + } + fn reset(&mut self, bounds: Rectangle) { + self.clips = vec![bounds]; + self.transforms = vec![Transformation::IDENTITY]; + self.clusters.clear(); + } + fn fill_quad(&mut self, _: renderer::Quad, _: impl Into) {} + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce(Result) + + Send + + 'static, + ) { + renderer::Renderer::allocate_image(&mut FontLayout, handle, callback); + } +} +impl shaped::Renderer for Recorder { + type Font = Font; + type Paragraph = ::Paragraph; + type Editor = ::Editor; + const ICON_FONT: Font = ::ICON_FONT; + const CHECKMARK_ICON: char = ::CHECKMARK_ICON; + const ARROW_DOWN_ICON: char = ::ARROW_DOWN_ICON; + const SCROLL_UP_ICON: char = ::SCROLL_UP_ICON; + const SCROLL_DOWN_ICON: char = ::SCROLL_DOWN_ICON; + const SCROLL_LEFT_ICON: char = ::SCROLL_LEFT_ICON; + const SCROLL_RIGHT_ICON: char = ::SCROLL_RIGHT_ICON; + const ICED_LOGO: char = ::ICED_LOGO; + fn default_font(&self) -> Font { + shaped::Renderer::default_font(&FontLayout) + } + fn default_size(&self) -> Pixels { + shaped::Renderer::default_size(&FontLayout) + } + fn fill_paragraph( + &mut self, + paragraph: &Self::Paragraph, + position: Point, + _: Color, + clip: Rectangle, + ) { + let buffer = paragraph.buffer(); + let content = buffer + .lines + .iter() + .map(|line| line.text()) + .collect::>() + .join("\n"); + let clip = self.clip(clip); + for run in buffer.layout_runs() { + for glyph in run.glyphs { + self.clusters.push(Cluster { + paragraph: content.clone(), + bytes: glyph.start..glyph.end, + bounds: Rectangle { + x: position.x + glyph.x + glyph.x_offset * glyph.font_size, + y: position.y + run.line_top, + width: glyph.w, + height: run.line_height, + } * self.transform(), + clip, + }); + } + } + } + fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {} + fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {} +} + +const FLAGS: [&str; 5] = ["Stale", "Partial", "Collision", "Detached", "Unverified"]; + +fn draw_and_check_flags( + content: Element<'_, Message, iced::Theme, Recorder>, + width: f32, +) -> Vec { + let viewport = Rectangle::with_size(Size::new(width, 24.)); + let mut renderer = Recorder { + clips: vec![viewport], + transforms: vec![Transformation::IDENTITY], + clusters: Vec::new(), + }; + let mut view: Element<'_, Message, iced::Theme, Recorder> = + container(content).width(Fill).height(24).clip(true).into(); + let mut tree = Tree::new(view.as_widget()); + let node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, viewport.size()), + ); + assert_eq!(node.size(), viewport.size()); + view.as_widget().draw( + &tree, + &mut renderer, + &iced::Theme::Light, + &renderer::Style::default(), + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &viewport, + ); + let mut failures = Vec::new(); + for flag in FLAGS { + for byte in 0..flag.len() { + let matching: Vec<_> = renderer + .clusters + .iter() + .filter(|cluster| { + cluster + .paragraph + .find(flag) + .is_some_and(|start| cluster.bytes.contains(&(start + byte))) + }) + .collect(); + if matching.is_empty() { + failures.push(format!("missing shaped flag byte: {flag}[{byte}]")); + } + for cluster in matching { + let b = cluster.bounds; + let c = cluster.clip; + if !(b.x >= c.x - 0.5 + && b.y >= c.y - 0.5 + && b.x + b.width <= c.x + c.width + 0.5 + && b.y + b.height <= c.y + c.height + 0.5) + { + failures.push(format!("flag {flag} is clipped: glyph {b:?}, clip {c:?}")); + } + } + } + } + failures +} + +fn old_summary(value: String) -> Element<'static, Message, iced::Theme, Recorder> { + text(value) + .size(12) + .wrapping(widget::text::Wrapping::None) + .into() +} + +#[test] +fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts() { + let mut app = App::with_project(oa_core::Project::empty()); + let old_key = App::with_project(oa_core::Project::empty()).source_key(); + assert_ne!(old_key, app.source_key()); + for alias in ["雨".repeat(32), "W".repeat(32)] { + // Unicode aliases are presentation stress: the current lexer admits + // ASCII identifiers only. Six-digit totals are within the query's + // 250,000-record cap; this does not build that many source records. + let old_value = format!( + "@{alias} · 100000 declarations · 100000 uses · Stale · Partial · Collision · Detached · Unverified" + ); + app.source_symbols.results = Some(Arc::new(query::Results { + stamp: query::QueryStamp { + snapshot: old_key, + serial: 1, + }, + action: query::Action::References, + origin_path: app.source_workspace.active.clone(), + origin_offset: 0, + origin: None, + alias: Some(alias.clone()), + scope: query::Scope { + closure_complete: false, + applied: false, + validated: false, + included_files: 28, + detached_files: 1, + unverified_files: 1, + issues: Vec::new(), + }, + disposition: query::Disposition::LexicalReferences, + definitions: Vec::new(), + references: Vec::new(), + total_definitions: 100_000, + total_references: 100_000, + omitted_definitions: 100_000, + omitted_references: 100_000, + })); + for width in [840., 720.] { + let positive = draw_and_check_flags(reference_summary(&app), width); + assert!( + positive.is_empty(), + "reserved flags, width {width}, alias {alias:?}: {positive:?}" + ); + // Observe the exact old presentation without predicting font widths. + // This is emitted under --nocapture, whether it clips or fits. + let old = draw_and_check_flags(old_summary(old_value.clone()), width); + println!( + "old alias-first summary width={width} alias={alias:?}: {} missing/clipped flag-byte checks; first={:?}", + old.len(), + old.first() + ); + } + } + // Deliberate evaluator stress, beyond the production 32-character subject + // bound: require the same evaluator to reject a clipped old-style paragraph. + // This is not evidence of a production/native overflow at 840 pixels. + let stress = format!("@{} · {}", "W".repeat(256), FLAGS.join(" · ")); + let rejected = draw_and_check_flags(old_summary(stress), 720.); + assert!( + !rejected.is_empty(), + "negative control must detect missing/clipped warning text" + ); +}