//! 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, } #[derive(Debug, Clone)] pub enum Event { ShowDrawer(DrawerTab), HideDrawer, ResizeDrawer(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 { 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), )) }) } pub(crate) fn source_annotation_snapshot( &self, ) -> Option> { let publication = self.text_workspace.diagnostic_publication?; let buffer = self .source_workspace .files .get(&self.source_workspace.active)?; if publication.source != self.source_key() || buffer.text != self.source_snapshot.as_ref() { return None; } Some(crate::source_annotations::Snapshot { publication: publication.generation, source: self.source_snapshot.clone(), path: &self.source_workspace.active, diagnostics: &self.source_diagnostics, }) } 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(tab) => { if !self.source_navigation_available() { return Task::none(); } return self.show_text_drawer(Some(tab)); } Event::HideDrawer => { if !self.source_navigation_available() { return Task::none(); } return self.show_text_drawer(None); } Event::ResizeDrawer(height) => { if 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; #[cfg(all(test, debug_assertions))] mod focus_selection_tests;