pub(crate) mod allocation_drag; pub(crate) mod architecture_authoring; #[cfg(test)] mod architecture_authoring_tests; #[cfg(test)] mod behavior_removal_tests; mod checkpoints; #[cfg(test)] mod diagram_focus_tests; pub(crate) mod diagram_removal; #[cfg(test)] mod diagram_removal_tests; mod diagram_reveal; #[cfg(test)] mod diagram_selection_tests; mod diagram_usages; mod hosted; mod reference_authoring; #[cfg(test)] mod review_navigation_tests; pub(crate) mod source_rename; mod source_symbols; #[cfg(test)] mod transition_context_tests; #[cfg(test)] mod view_copy_tests; use crate::forms::Draft; use crate::{ behavior, document::Document, source_workspace::{self, LIMITS, Workspace}, }; use iced::{Element, Subscription, Task, keyboard, widget::text_editor, window}; use oa_core::{projection::project_view, validation::validate, *}; use oa_core::{ source_files::{self, DiskProject}, source_project::{CompiledSourceProject, SourceDiagnostic, SourceLocation}, }; use serde_json::{Value, json}; use std::{collections::BTreeSet, path::PathBuf, sync::Arc}; #[cfg(test)] mod direct_scenario_tests { use super::*; use crate::behavior::{ self, scenario::{self, CanvasEvent, Event, MessageStyle, Slot}, }; fn setup() -> (App, scenario::direct_tests::Fixture) { let f = scenario::direct_tests::fixture(); let mut app = App::with_project(f.project.clone()); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(Page::Behavior)); assert_eq!(app.scenario_context().unwrap().scenario, f.owner); (app, f) } fn form(app: &mut App, event: Event) { let _ = app.update(Message::Behavior(behavior::Event::Scenario(event))); } fn begin(app: &App, f: &scenario::direct_tests::Fixture) -> CanvasEvent { CanvasEvent::Begin { context: app.scenario_context().unwrap(), slot: Slot { parent: f.owner, before: None, }, sender: f.sender, receiver: f.receiver, style: MessageStyle::Asynchronous, } } #[test] fn direct_scenario_camera_details_and_real_resize_invalidate_only_pointer_context() { let (mut app, f) = setup(); let original = app.document.bundle().clone(); let event = begin(&app, &f); let context = app.scenario_context().unwrap(); let size = app.window_size; let _ = app.update(Message::WindowResized(size)); assert_eq!(app.scenario_context(), Some(context)); let _ = app.update(Message::WindowResized(iced::Size::new( size.width + 20., size.height, ))); assert_ne!(app.scenario_context(), Some(context)); let _ = app.update(Message::ScenarioCanvas(event)); assert!(!app.behavior.has_pending()); let context = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera( context, app.camera_epoch, behavior::canvas_workspace::Event::Pan(iced::Vector::new(30., 150.)), ))); assert_ne!(app.scenario_context(), Some(context)); let context = app.scenario_context().unwrap(); for offset in [iced::Vector::new(0., 90.), iced::Vector::new(0., 150.)] { let before = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( context, scenario::ScrollArea::Page, offset, ))); assert_ne!(app.scenario_context(), Some(before)); let current = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( context, scenario::ScrollArea::Page, offset, ))); assert_eq!( app.scenario_context(), Some(current), "unchanged redraw notification retains context" ); } let event = begin(&app, &f); let _ = app.update(Message::ScenarioCanvas(event)); form(&mut app, Event::Name("Keep my draft".into())); let context = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( context, scenario::ScrollArea::Page, iced::Vector::new(0., 200.), ))); assert!(app.behavior.has_pending()); assert_eq!(app.document.bundle(), &original); let current = app.scenario_context().unwrap(); for dimension in 0..3 { let mut stale = current; match dimension { 0 => stale.document = Id::new_v4(), 1 => stale.scenario = Id::new_v4(), _ => stale.revision += 1, } let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( stale, scenario::ScrollArea::Page, iced::Vector::new(0., 900.), ))); assert_eq!(app.scenario_context(), Some(current)); } assert!(!app.document.can_undo()); } #[test] fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() { let (mut app, f) = setup(); let before = app.document.snapshot(); for dimension in 0..4 { let mut event = begin(&app, &f); let CanvasEvent::Begin { context, .. } = &mut event else { unreachable!() }; match dimension { 0 => context.document = Id::new_v4(), 1 => context.scenario = Id::new_v4(), 2 => context.revision += 1, _ => context.epoch += 1, } let _ = app.update(Message::ScenarioCanvas(event)); assert!(!app.behavior.has_pending()); assert!(Arc::ptr_eq(&before, &app.document.snapshot())); } let _ = app.update(Message::Field( "/name".into(), "Pending scenario name".into(), )); assert!(app.draft_dirty()); let event = begin(&app, &f); let _ = app.update(Message::ScenarioCanvas(event)); assert!(!app.behavior.has_pending()); assert!(app.draft_dirty()); assert!(Arc::ptr_eq(&before, &app.document.snapshot())); } #[test] fn direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once() { let (mut app, f) = setup(); let original = app.document.bundle().clone(); let event = begin(&app, &f); let _ = app.update(Message::ScenarioCanvas(event)); form( &mut app, Event::Exchange(Choice { id: f.exchange, label: "Contract".into(), }), ); form(&mut app, Event::Submit); assert!(!app.behavior.has_pending()); let created = app.selected; assert_ne!(created, f.owner); assert_eq!( app.project().elements[&f.owner].steps().unwrap(), &[f.call, f.reply, created] ); let before_reorder = app.document.bundle().clone(); let context = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select( context, f.sender, ))); assert_eq!( app.scenario_context(), Some(context), "same-owner selection retains gesture context" ); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder { context, occurrence: created, slot: Slot { parent: f.owner, before: Some(f.call), }, })); assert_eq!(app.selected, created); assert_eq!( app.project().elements[&f.owner].steps().unwrap(), &[created, f.call, f.reply] ); let _ = app.update(Message::Undo); assert_eq!(app.document.bundle(), &before_reorder); let _ = app.update(Message::Undo); assert_eq!(app.document.bundle(), &original); assert_eq!(app.project(), &f.project); assert!(!app.document.can_undo()); } #[test] fn direct_scenario_source_failure_and_navigation_keep_form_until_success() { let (mut app, f) = setup(); let bundle = app.document.bundle().clone(); let mut limits = oa_core::source_project::ProjectLimits::default(); limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::() + 4000; app.document = Document::from_bundle(bundle.clone(), limits).unwrap(); app.sync(); let event = begin(&app, &f); let _ = app.update(Message::ScenarioCanvas(event)); form( &mut app, Event::Exchange(Choice { id: f.exchange, label: "Contract".into(), }), ); form(&mut app, Event::Name("Long message".repeat(1024))); let _ = app.update(Message::Page(Page::Catalog)); assert_eq!(app.page, Page::Behavior); form(&mut app, Event::Submit); assert!(app.behavior.has_pending()); assert_eq!(app.document.bundle(), &bundle); assert!(!app.document.can_undo()); form(&mut app, Event::Name("Recovered message".into())); form(&mut app, Event::Submit); assert!(!app.behavior.has_pending()); assert_eq!(app.project().named(app.selected), "Recovered message"); let _ = app.update(Message::Undo); assert_eq!(app.document.bundle(), &bundle); assert!(!app.document.can_undo()); } } #[cfg(test)] mod behavior_lifecycle_tests; mod process_canvas; mod state_diagram; #[cfg(test)] mod state_diagram_tests; #[derive(Debug, Clone)] pub enum LoadedSnapshot { Source(DiskProject), Legacy(PathBuf, Box, Arc<[u8]>), } #[derive(Debug, Clone)] pub struct FileSnapshot { pub document: Id, pub revision: u64, pub snapshot: Arc, pub disk: Option, pub path: PathBuf, } #[derive(Debug, Clone)] pub struct SaveFailure { pub document: Id, pub message: String, pub recovery: Option, } #[derive(Debug, Clone)] pub struct RecoverySnapshot { pub target: PathBuf, /// Recovery already succeeded even when reloading its files fails. pub disk: Result, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { Home, Diagram, Catalog, Behavior, Data, Traces, Text, Validation, Guide, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Choice { pub id: Id, pub label: String, } impl std::fmt::Display for Choice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.label) } } #[derive(Debug, Clone, Copy)] pub enum Pending { Recent, Recover(Id), HostedOpen, New, Demo, Walkthrough, Open, Close(window::Id), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FileOperation { Open, Save, Export, } pub struct FilePrompt { pub operation: FileOperation, pub path: String, pub overwrite: bool, } #[derive(Debug, Clone)] pub enum Message { WorkDrag(allocation_drag::Event), BehaviorControl(behavior::canvas_workspace::Intent), Architecture(architecture_authoring::Event), Home(crate::home::Event), CheckpointTick, CheckpointWritten(Id, Result<(), String>), CheckpointLoaded(Result, String>), Hosted(crate::hosted::Event), PortAnchor(crate::port_edit::Event), Connection(crate::connection_edit::Event), Menu(crate::model_menu::Event), CreateContextElement(Kind, bool), SourceTree(crate::source_tree::Event), SourceTreeCollapse, SourceTreeLocate, ExactStrings(bool), Page(Page), Guide(crate::guide::Event), Traceability(crate::views::traceability::Event), Behavior(crate::behavior::Event), Layer(Layer), Select(Id), EditProperties(Id), SelectNode(Id), SelectProject, Search(String), StartTransition, StartSemanticReview(Id), Workflow(crate::workflow::Event), CollapseExplorer(Id), ExplorerGroup(crate::explorer::Group), ExplorerCollapseAll, ExplorerLocate, Explore(Option), BrowseGroup(crate::explorer::Group), ExplorerPage(usize), CreateKind(Kind), Data(crate::data_view::Event), Add(bool), AddRelation, PortAction(crate::port_workflow::Action), AddRelationKind(RelationKind, bool), InspectorTab(crate::navigation::InspectorTab), RevealInDiagram(Id, Id), AddDiagram, DuplicateDiagram(Id), AddArtifact, AddTrace, Field(String, String), FieldText(String, text_editor::Action), FieldShape(String, Value), FieldAppend(String, Value), FieldValuePath(String, crate::data_values::FieldPathKey), FieldValueBrowse(String, Option, usize), FieldReferenceBrowse(String, String, usize), FieldReferenceOpen(crate::forms::reference_chooser::adapter::Open), FieldReferenceEvent(crate::forms::reference_chooser::Event), FieldVariant(String, Value), FieldArray(String, Option, i32), Apply, RevertProperties, ElementKind(Kind), RelationKind(RelationKind), Command(Command), Delete, ConfirmDelete(u64), DiagramRemove(diagram_removal::Intent), DiagramTraverse(bool), CancelDelete, Undo, Redo, Diagram(Choice), Projection(ProjectionKind), BehaviorOwner(Choice), BehaviorDiagram(Choice), StateDiagram(crate::behavior::state_edit::Event), ScenarioCanvas(crate::behavior::scenario::CanvasEvent), TopologyCanvas(crate::behavior::topology::CanvasEvent), PlaceSelected, HideSelected, ToggleCollapse, MoveNode(Id, Position), Route(crate::route_edit::Event), Zoom(f32), Pan(iced::Vector), Fit, SourceEdit(text_editor::Action), ApplySource, RevertSource, SourceUndo, SourceRedo, SourceFile(String), SourceNewPath(String), SourceCreateFile, SourceCloseFile(String), SourceLoadIncludes, SourceRecover, SourceRecovered(Result), IncludesLoaded(u64, Result), Lint(u64), Linted(u64, Result, String>), SourceDiagnostic(usize), SourceRelated(SourceLocation), SourceViewport(Option), SourceSymbols( crate::source_navigation::SymbolContext, crate::source_navigation::SymbolEvent, ), SourceSymbolsReady(crate::source_navigation::Completion), SourceRename(source_rename::Event), SourceRenameReady(source_rename::Completion), DiagramUsage(crate::diagram_usage::Event), DiagramUsagesReady(crate::diagram_usage::Completion), DiagramRevealViewport(crate::diagram_usage::Context, u64, iced::Size), SourceForSelection, SourceNavigation(crate::source_navigation::Event), SourceFindClose(crate::source_navigation::SymbolContext, Id), Save(bool), SavePath(Option), Saved(Result, SaveFailure>), Loaded(Result, String>), Request(Pending), Discard, Cancel, SaveAndContinue, FilePath(String), SubmitFile, CancelFile, Export, Exported(Result), WindowResized(iced::Size), CanvasResized(iced::Size), ShortcutUndo(bool), Escape, } pub struct App { pub allocation_drag: allocation_drag::State, pub home: crate::home::State, pub recovery: crate::recovery::State, recent_target: Option, checkpoint_close: Option, pub hosted: crate::hosted::State, pub connection: Option, pub model_menu: Option, reference_authoring: reference_authoring::State, pub source_tree: crate::source_tree::State, menu_sequence: u64, pub canvas_document: Id, pub canvas_epoch: u64, /// Camera batches survive their own updates, never external presentation changes. pub camera_epoch: u64, camera_boundary_epoch: u64, pub selected_bend: Option<(Id, usize)>, pub exact_strings: bool, pub document: Document, pub page: Page, /// Home resumes this page; explicit Architecture navigation remains separate. last_workspace_page: Page, pub guide: crate::guide::State, pub traceability: crate::views::traceability::State, pub behavior: crate::behavior::State, pub data_state: crate::data_view::State, pub inspector_tab: crate::navigation::InspectorTab, pub layer: Layer, pub selected: Id, pub selected_node: Option, pub draft: Option, pub active_diagram: Option, pub search: String, pub create_kind: Kind, pub architecture: Option, pub architecture_palette_expanded: bool, pub status: String, pub busy: bool, pub pending: Option, pub delete_pending: bool, pub delete_target: Option, pub path: Option, /// A disk transaction requiring an explicit action, independent of lint. pub pending_recovery: Option, disk_project: Option, pub file_prompt: Option, continue_after_save: bool, pub zoom: f32, pub pan: iced::Vector, pub window_size: iced::Size, canvas_size: Option, fit_to_view: bool, pub source_editor: text_editor::Content, pub source_workspace: Workspace, pub source_snapshot: Arc, pub source_diagnostics: Vec, pub source_jump: Option, source_select_line: bool, pub source_navigation: crate::source_navigation::State, source_find_instance: Id, pub source_symbols: crate::source_navigation::SymbolState, pub source_rename: source_rename::State, pub(crate) diagram_usages: Option, pub(crate) diagram_navigation_focus: Option, source_diagram_job: Option, diagram_lookup_in_flight: Option, pending_diagram_reveal: Option<( crate::diagram_usage::Context, crate::diagram_usage::Usage, u64, )>, pub model_issues: Vec, pub migration: Option, source_version: u64, lint_running: Option, lint_requested: Option, pub explorer_collapsed: BTreeSet, pub explorer_groups_collapsed: BTreeSet, pub explorer_index: crate::explorer::Index, pub explorer_focus: Option, pub explorer_group: Option, pub explorer_page: usize, pub workflow: Option, } impl App { pub fn new() -> Self { let mut app = Self::with_project(Project::empty()); app.home = crate::home::State::load_default(); app.recovery = crate::recovery::State::load_default(); app.page = Page::Home; app.status = "Create a model, open a project or explore the example.".into(); app.fit(); app } pub fn with_project(project: Project) -> Self { let document = Document::from_project(project.clone(), LIMITS) .expect("Application models are valid source projects"); let source_workspace = Workspace::new(document.bundle(), true); let source = source_workspace.text().to_owned(); let model_issues = validate(&project); let selected = project.id; let populated = |layer| { project.elements.values().any(|e| e.layer() == layer) || project.diagrams.values().any(|d| d.layer == layer) }; let layer = if !populated(Layer::Oa) && populated(Layer::Sa) { Layer::Sa } else { Layer::Oa }; let active_diagram = project .diagrams .values() .filter(|d| d.layer == layer) .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) .map(|d| d.id); let draft = Draft::from_project(&project, selected); let explorer_index = crate::explorer::Index::new(&project, layer); let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect(); let mut app = Self { allocation_drag: Default::default(), home: Default::default(), recovery: Default::default(), recent_target: None, checkpoint_close: None, connection: None, model_menu: None, reference_authoring: Default::default(), source_tree: Default::default(), menu_sequence: 0, canvas_document: Id::new_v4(), canvas_epoch: 0, camera_epoch: 0, camera_boundary_epoch: 0, selected_bend: None, exact_strings: false, document, page: Page::Diagram, last_workspace_page: Page::Diagram, guide: crate::guide::State::default(), traceability: Default::default(), behavior: Default::default(), data_state: Default::default(), inspector_tab: Default::default(), layer, selected, selected_node: None, draft, active_diagram, search: String::new(), architecture: None, architecture_palette_expanded: false, create_kind: if layer == Layer::Sa { Kind::Function } else { Kind::Activity }, status: "Coastal rescue example · explore both engineering perspectives".into(), hosted: Default::default(), busy: false, pending: None, delete_pending: false, delete_target: None, path: None, pending_recovery: None, disk_project: None, file_prompt: None, continue_after_save: false, zoom: 0.65, pan: iced::Vector::new(24., 48.), window_size: iced::Size::new(1440., 900.), canvas_size: None, fit_to_view: true, source_editor: text_editor::Content::with_text(&source), source_workspace, source_snapshot: Arc::from(source), source_diagnostics: vec![], source_jump: None, source_select_line: false, source_navigation: crate::source_navigation::State::default(), source_find_instance: Id::new_v4(), source_symbols: crate::source_navigation::SymbolState::default(), source_rename: source_rename::State::default(), diagram_usages: None, diagram_navigation_focus: None, source_diagram_job: None, diagram_lookup_in_flight: None, pending_diagram_reveal: None, model_issues, migration: None, source_version: 0, lint_running: None, lint_requested: None, explorer_collapsed, explorer_groups_collapsed: BTreeSet::new(), explorer_index, explorer_focus: None, explorer_group: None, explorer_page: 0, workflow: None, }; app.route_state_diagram_workspace(); app.last_workspace_page = app.page; app } pub fn project(&self) -> &Project { self.document.project() } pub fn title(&self) -> String { format!( "{}{} — Arcadia Studio", self.project() .name .split_whitespace() .collect::>() .join(" "), if self.dirty() { " *" } else { "" } ) } pub fn source_dirty(&self) -> bool { self.source_workspace.dirty() } pub fn draft_dirty(&self) -> bool { self.draft.as_ref().is_some_and(Draft::dirty) } pub fn dirty(&self) -> bool { self.document.dirty() || self.architecture.is_some() || self.workflow.is_some() || self.draft_dirty() || self.source_dirty() || self.behavior.has_pending() } fn sync_source(&mut self) { self.sync_source_with_alias_editor(None); } 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.source_version += 1; self.lint_requested = None; } fn show_source_file(&mut self, restore_cursor: bool) { self.show_source_file_with_alias_editor(restore_cursor, None); } fn show_source_file_with_alias_editor( &mut self, restore_cursor: bool, hint: Option>, ) { self.source_symbols.reveal = None; self.source_tree.reveal(&self.source_workspace.active); self.source_select_line = false; let source = self.source_workspace.text().to_owned(); self.source_navigation.refresh(&source); if !hint.is_some_and(|hint| { hint.apply( &mut self.source_editor, &self.source_workspace.active, &source, ) }) { self.source_editor = text_editor::Content::with_text(&source); } self.source_snapshot = Arc::from(source); self.source_jump = restore_cursor .then(|| { self.source_workspace .files .get(&self.source_workspace.active) .map(|b| b.cursor) }) .flatten(); if let Some(cursor) = self.source_jump { self.queue_source_reveal(cursor, false); } } fn selected_architecture_placement(&self) -> Option { self.active_diagram .and_then(|id| self.project().diagrams.get(&id)) .and_then(|d| { d.nodes .values() .find(|n| n.id == self.selected || n.element == self.selected) }) .map(|n| n.id) } fn refresh_draft(&mut self) { self.selected_node = if self.page == Page::Behavior { None } else { self.selected_architecture_placement() }; if let Some(context) = self.state_context() { self.behavior.diagram.placement = self.project().diagrams[&context.diagram] .nodes .values() .find(|n| n.id == self.selected || n.element == self.selected) .map(|n| n.id); } self.draft = Draft::from_project(self.project(), self.selected); if self.exact_strings && let Some(d) = &mut self.draft { let _ = d.set_exact(true); } } fn sync(&mut self) { self.sync_with_alias_editor(None); } fn sync_with_alias_editor(&mut self, hint: Option>) { self.explorer_index = crate::explorer::Index::new(self.project(), self.layer); self.model_issues = validate(self.project()); self.normalize_creation(); if !self.project().contains(self.selected) { self.selected = self.project().id; self.selected_node = None; } if let Some(focus) = self.explorer_focus { self.explorer_group = self.explorer_index.group(focus); if self.explorer_group.is_none() { self.explorer_focus = None; } } self.reveal_explorer_selection(); if self.active_diagram.is_none_or(|id| { self.project() .diagrams .get(&id) .is_none_or(|d| d.layer != self.layer) }) { self.active_diagram = self .project() .diagrams .values() .filter(|d| d.layer == self.layer) .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) .map(|d| d.id); } self.refresh_draft(); self.sync_source_with_alias_editor(hint); } fn normalize_creation(&mut self) { if !self.create_kind.supports(self.layer) { self.create_kind = if self.layer == Layer::Oa { Kind::Activity } else { Kind::Function }; } } pub fn apply_source(&mut self) -> bool { if self.behavior.has_pending() { self.status = "Apply or cancel the behavior form before applying source changes.".into(); return false; } if !self.source_dirty() { return true; } if self.draft_dirty() { self.status = "Property and source drafts both exist. Revert one draft before applying the other; both are retained.".into(); return false; } match source_workspace::compile(&self.source_workspace.bundle()) { Ok(compiled) => { match self.document.apply_compiled(compiled) { Ok(_) => { self.sync(); self.status = if self.source_workspace.detached_dirty() { "Reachable files applied. Reattach or revert detached drafts before saving." } else { "All source files applied as one model change" }.into(); true } Err(e) => { self.status = e.to_string(); false } } } Err(issues) => { self.source_diagnostics = issues; self.status = "Source contains errors. Your draft is preserved.".into(); self.page = Page::Text; false } } } pub fn apply(&mut self) -> bool { if self.behavior.has_pending() { self.status = "Apply or cancel the behavior form before changing the model.".into(); return false; } if self.source_dirty() { self.status = "Apply all source drafts or revert them before changing the model.".into(); return false; } if !self.draft_dirty() { return true; } let original = self.selected; let result = self .draft .as_ref() .ok_or("No selection.".to_string()) .and_then(|d| Ok((d.command()?, d.resulting_id()?))); match result.and_then(|(command, id)| { self.document .execute(crate::port_edit::repair_owner_change( self.document.project(), command, )) .map(|_| id) .map_err(|e| e.to_string()) }) { Ok(id) => { if self.active_diagram == Some(original) { self.active_diagram = Some(id); } if self.behavior.active_owner == Some(original) { self.behavior.active_owner = Some(id); } if self.behavior.diagram.diagram == Some(original) { self.behavior.diagram.diagram = Some(id); } if self.selected_node == Some(original) { self.selected_node = Some(id); } if self.explorer_collapsed.remove(&original) { self.explorer_collapsed.insert(id); } self.selected = id; if let Some(layer) = self.project().layer_of(id) { self.layer = layer; } if let Some((diagram, layer, node)) = self .project() .diagrams .values() .find(|d| d.id == id || d.nodes.contains_key(&id)) .map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id))) { self.layer = layer; if self.page == Page::Behavior && let Some(machine) = self.project().diagrams[&diagram].projection.state_machine() { self.behavior.active_owner = Some(machine); self.behavior.diagram.diagram = Some(diagram); self.behavior.diagram.placement = node; } else { self.active_diagram = Some(diagram); self.selected_node = node; } } self.sync(); self.status = "Properties applied".into(); true } Err(e) => { self.status = e; false } } } fn execute(&mut self, command: Command) -> bool { let command = crate::port_edit::repair_owner_change(self.project(), command); let created = self.behavior.creation.created_by(self.project(), &command); let scenario_selection = self .behavior .scenario .created_selection(self.project(), &command) .or_else(|| { self.behavior .topology .created_selection(self.project(), &command) }); match self.document.execute(command) { Ok(selected) => { if let Some(created) = created { self.layer = created.layer; self.selected = created.behavior; self.selected_node = None; self.behavior.active_owner = Some(created.behavior); self.behavior.diagram = behavior::state_view::State::default(); self.behavior.diagram.diagram = created.diagram; self.page = Page::Behavior; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } else if let Some(id) = scenario_selection.or(selected) { self.selected = id; } self.sync(); self.status = "Model updated".into(); true } Err(e) => { self.status = e.to_string(); false } } } fn apply_behavior(&mut self) -> bool { if !self.behavior.has_pending() { return true; } if self.source_dirty() || self.draft_dirty() { self.status = "Property, source and behavior drafts are retained. Revert the other drafts before applying the behavior form.".into(); return false; } match self.behavior.prepare_pending(self.project()) { Ok(Some(command)) => { if self.execute(command) { self.behavior.acknowledge_commit(); true } else { false } } Ok(None) => true, Err(error) => { self.status = error; false } } } fn reset(&mut self, project: Project, path: Option) { self.retire_checkpoint(); let home = std::mem::take(&mut self.home); let recovery = std::mem::take(&mut self.recovery); let window_size = self.window_size; let canvas_size = self.canvas_size; let source_version = self.source_version + 1; let lint_running = self.lint_running; 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 guide = std::mem::take(&mut self.guide); let mut hosted = std::mem::take(&mut self.hosted); hosted.leave_document(); *self = Self::with_project(project); self.home = home; self.recovery = recovery; self.hosted = hosted; self.window_size = window_size; self.canvas_size = canvas_size; self.source_version = source_version; self.lint_running = lint_running; 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.guide = guide; self.path = path; self.status = "Project ready".into(); self.fit(); } fn diagram_for(&self, layer: Layer, element: Id) -> Option { preferred_diagram(self.project(), self.active_diagram, layer, element) } /// Navigation after an accepted review is not another model operation. /// Callers retain their workspace policy; keep an already visible target's /// camera and never leave an old-layer diagram behind after counterpart focus. fn focus_after_review(&mut self, target: Id, destination: Option) -> bool { if !self.project().contains(target) { return false; } let previous = (self.layer, self.active_diagram); self.selected = target; self.selected_node = None; if let Some(layer) = self.project().layer_of(target) { let retained = self.active_diagram.filter(|id| { self.project().diagrams.get(id).is_some_and(|d| { d.layer == layer && (d.nodes.values().any(|n| n.element == target) || self.project().elements.get(&target).is_some_and(|e| { e.kind() == Kind::Port && project_view(self.project(), *id) .ports .iter() .any(|port| port.element == target) })) }) }); self.layer = layer; self.active_diagram = destination .filter(|id| { self.project() .diagrams .get(id) .is_some_and(|d| d.layer == layer) }) .or(retained) .or_else(|| self.diagram_for(layer, target)); } self.refresh_draft(); previous != (self.layer, self.active_diagram) } fn select(&mut self, id: Id) { if self.selected == id { return; } if !self.apply() { return; } if !self.project().contains(id) { return; } let previous_state_view = self.state_view_identity(); self.selected = id; self.selected_node = None; if let Some(layer) = self.project().layer_of(id) && layer != self.layer { self.layer = layer; self.active_diagram = self.diagram_for(layer, id); self.fit(); } if self.page == Page::Behavior && let Some(owner) = crate::behavior::selected_behavior(self.project(), id) { self.behavior.active_owner = Some(owner); } if self.reset_state_view_after_navigation(previous_state_view) && self.page == Page::Behavior { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } self.refresh_draft(); } fn reveal_explorer_selection(&mut self) { if let Some(group) = self.explorer_index.group(self.selected) { self.explorer_groups_collapsed.remove(&group); for id in self.explorer_index.ancestry(self.selected) { if id != self.selected { self.explorer_collapsed.remove(&id); } } } if self .explorer_focus .is_some_and(|id| !self.project().elements.contains_key(&id)) { self.explorer_focus = None; } } pub fn view(&self) -> Element<'_, Message> { let base = crate::context_menu::Popup::new( crate::views::view(self), crate::model_menu::popup(self), ) .into(); crate::source_navigation::interaction::observe( crate::behavior::scenario::focus::root( behavior::canvas_workspace::focus_root( crate::hosted::view::overlay( self, self.reference_overlay(crate::diagram_usage::view::overlay(self, base)), ), behavior::canvas_workspace::Scope::input_owner(self), ), self.scenario_focus_owner(), ), self.source_symbols.raw_interaction.clone(), ) } pub fn subscription(&self) -> Subscription { Subscription::batch([ self.diagram_usages .as_ref() .map_or_else(Subscription::none, |panel| { iced::event::listen_with(|event, status, _| { let iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event else { return None; }; if !modifiers.is_empty() { return None; } match key { keyboard::Key::Named(keyboard::key::Named::ArrowUp) => Some(-1i8), keyboard::Key::Named(keyboard::key::Named::ArrowDown) => Some(1i8), keyboard::Key::Named(keyboard::key::Named::Enter) if status == iced::event::Status::Ignored => { Some(0i8) } _ => None, } }) .with(panel.token) .map(|(token, step)| { Message::DiagramUsage(if step == 0 { crate::diagram_usage::Event::Activate(token) } else { crate::diagram_usage::Event::Move(token, step) }) }) }), iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick), window::close_requests().map(|id| Message::Request(Pending::Close(id))), window::resize_events().map(|(_, size)| Message::WindowResized(size)), architecture_authoring::subscription(self), iced::event::listen_with(|event, _, _| { matches!( event, iced::Event::Keyboard(keyboard::Event::KeyPressed { .. }) ) .then_some(Message::WorkDrag(allocation_drag::Event::KeyboardBoundary)) }), // 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()), ) .map(|()| Message::Escape), iced::event::listen_with(|event, status, _| { if matches!(event, iced::Event::Window(window::Event::Unfocused)) { return Some(Message::WorkDrag(allocation_drag::Event::Unfocused)); } let iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers: mods, .. }) = event else { return None; }; if key == keyboard::Key::Named(keyboard::key::Named::Escape) { if status == iced::event::Status::Captured { return None; } return Some(Message::Escape); } if key == keyboard::Key::Named(keyboard::key::Named::F1) { return Some(Message::Page(Page::Guide)); } if key == keyboard::Key::Named(keyboard::key::Named::F3) { return Some(Message::SourceNavigation( crate::source_navigation::Event::Next(mods.shift()), )); } if !mods.command() { return None; } match key.as_ref() { keyboard::Key::Character("s") => Some(Message::Save(mods.shift())), keyboard::Key::Character("o") => Some(Message::Request(Pending::Open)), keyboard::Key::Character("n") => Some(Message::Request(Pending::New)), keyboard::Key::Character("z") => Some(Message::ShortcutUndo(mods.shift())), keyboard::Key::Character("y") => Some(Message::ShortcutUndo(true)), keyboard::Key::Character("f") => Some(Message::SourceNavigation( crate::source_navigation::Event::Open(false), )), keyboard::Key::Character("g") => Some(Message::SourceNavigation( crate::source_navigation::Event::Open(true), )), _ => None, } }), ]) } pub fn update(&mut self, message: Message) -> Task { // 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) || matches!(&message, Message::DiagramUsage(event) if !self.diagram_usage_envelope(event)) || matches!(&message, Message::WorkDrag(event) if !self.allocation_event_envelope(event)) { return Task::none(); } if let Message::WorkDrag(allocation_drag::Event::Press(press)) = &message && !self.allocation_press_owns_current_view(press) { // Keep the existing stale-press explanation, without treating the // rejected press as a new navigation or allocation boundary. return self.allocation_event(allocation_drag::Event::Press(press.clone())); } // A queued toolbar from a previous input owner cannot retire a newer // navigation destination or allocation capability before dispatch. if let Message::BehaviorControl(intent) = &message && behavior::canvas_workspace::Scope::input_owner(self) != Some(intent.scope) { return Task::none(); } // Reject stale rename intents before camera/reveal reconciliation or // source interaction can disturb a newer navigation destination. if let Message::SourceRename(event) = &message && !self.source_rename_envelope(event) { return Task::none(); } // A validated preview measurement only retains display state. Do not // turn each wheel report into source interaction or scan shared boundaries. if let Message::SourceRename(event @ source_rename::Event::PreviewScrolled(..)) = message { return self.source_rename_event(event); } let usage_viewport = self.diagram_reveal_viewport(&message); let window_resized = matches!(&message, Message::WindowResized(_)); // Reject stale measurements before legacy viewport handlers can change // dimensions or invalidate the exact pending destination epoch. if self.pending_diagram_reveal.is_some() && Self::is_diagram_viewport(&message) && usage_viewport.is_none() { return Task::none(); } self.reconcile_pending_diagram_reveal(&message); self.allocation_before(&message); if let Message::WorkDrag(event) = message { return self.allocation_event(event); } let allocation_before = self.allocation_boundary(); if let Message::BehaviorControl(intent) = message { return self.behavior_control(intent); } let camera = matches!( &message, Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(..)) | Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..)) ); let before = self.camera_boundary(); let task = self.update_inner(message); if self.allocation_boundary() != allocation_before { self.allocation_retire(); } if window_resized && let Some((_, _, epoch)) = &mut self.pending_diagram_reveal { *epoch = self.canvas_epoch; } if let Some(surface) = usage_viewport { self.finish_diagram_reveal(surface); } if self .diagram_navigation_focus .as_ref() .is_some_and(|focus| self.diagram_focus(focus.usage.surface).is_none()) { self.diagram_navigation_focus = None; } if !camera && self.camera_boundary() != before { self.camera_epoch = self.camera_epoch.wrapping_add(1); self.camera_boundary_epoch = self.canvas_epoch; } task } fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task { use behavior::canvas_workspace::{self, Event as Camera, Scope}; use behavior::scenario::CanvasEvent as Scenario; use behavior::topology::CanvasEvent as Process; if Scope::input_owner(self) != Some(intent.scope) { return Task::none(); } let mut message = *intent.message; let scope = intent.scope; let valid_scenario = |c: behavior::scenario::Context| { c.document == scope.document && c.scenario == scope.owner && c.revision == scope.revision && self.camera_toolbar_epoch(c.epoch) }; let valid_process = |c: behavior::topology::Context| { c.document == scope.document && c.owner == scope.owner && c.revision == scope.revision && self.camera_toolbar_epoch(c.epoch) }; // This whitelist contains only the production presentation controls. // A stamped toolbar cannot smuggle an arbitrary authoring command. match &mut message { Message::ScenarioCanvas( Scenario::Zoom(c, _) | Scenario::Choose(c, _) | Scenario::Shelf(c, _), ) if valid_scenario(*c) => { c.epoch = self.canvas_epoch; } Message::TopologyCanvas(Process::Zoom(c, _) | Process::Choose(c, _)) if valid_process(*c) => { c.epoch = self.canvas_epoch; } Message::Behavior(behavior::Event::Topology(behavior::topology::Event::Shelf(_))) if self.topology_context().is_some() => {} _ => return Task::none(), } let task = self.update(message); // Reapplying Fit or a tool also ends a held gesture. Focus is stamped // after the action, for Iced's final-view ordering. if self.camera_epoch == scope.epoch { self.camera_epoch = self.camera_epoch.wrapping_add(1); self.camera_boundary_epoch = self.canvas_epoch; } let focus = Scope::input_owner(self).map_or_else(Task::none, |scope| { canvas_workspace::focus_task(scope, Camera::ControlFocus(intent.name)) }); Task::batch([task, focus]) } fn camera_toolbar_epoch(&self, epoch: u64) -> bool { epoch >= self.camera_boundary_epoch && epoch <= self.canvas_epoch } fn camera_boundary(&self) -> impl PartialEq + use<> { ( self.canvas_epoch, self.canvas_document, self.document.revision(), crate::behavior::selected_owner(self), self.page, self.window_size, crate::behavior::canvas_workspace::keyboard_enabled(self), self.behavior.has_pending(), self.draft_dirty(), self.source_dirty(), self.behavior.topology.camera_boundary(), self.behavior.scenario.camera_boundary(), ) } fn update_inner(&mut self, message: Message) -> Task { if !self.source_input_envelope(&message) { return Task::none(); } let message = match message { Message::SourceFindClose(..) => { // Only the checked envelope may enter the legacy Close handler. Message::SourceNavigation(crate::source_navigation::Event::Close) } 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. if let Message::SourceSymbolsReady(completion) = message { return self.source_symbols_ready(completion); } if let Message::SourceRenameReady(completion) = message { return self.source_rename_ready(completion); } if let Message::DiagramUsagesReady(completion) = message { return self.diagram_usages_ready(completion); } if !matches!( &message, Message::CheckpointTick | Message::CheckpointWritten(..) | Message::Lint(_) | Message::Linted(..) | Message::SourceViewport(_) | Message::WindowResized(_) | Message::CanvasResized(_) | Message::DiagramRevealViewport(..) ) { self.source_interaction(); } if let Message::DiagramUsage(event) = message { return self.diagram_usage_event(event); } if let Some(panel) = &self.diagram_usages { if matches!(&message, Message::Request(Pending::Close(_))) { self.diagram_usages = None; return self.update(message); } if matches!(&message, Message::Escape) { return self.diagram_usage_event(crate::diagram_usage::Event::Close(panel.token)); } if !matches!( &message, Message::CheckpointTick | Message::CheckpointWritten(..) | Message::Lint(_) | Message::Linted(..) | Message::WindowResized(_) | Message::CanvasResized(_) | Message::SourceViewport(_) ) { return Task::none(); } } if let Some(task) = self.reference_message(&message) { return task; } if matches!(&message, Message::Connection(_)) && self .architecture .as_ref() .is_some_and(architecture_authoring::State::unnamed_tool) { self.architecture = None; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } // Named placement owns input. Passive geometry/checkpoints continue; // navigation and close retain raw input until explicit completion/cancel. if self.architecture.is_some() { if matches!(message, Message::Escape) { return self.architecture_event(architecture_authoring::Event::Cancel); } let placing = self.architecture.as_ref().is_some_and(|s| s.saved.placing); if !matches!( &message, Message::Architecture(_) | Message::CheckpointTick | Message::CheckpointWritten(..) | Message::Lint(_) | Message::Linted(..) | Message::WindowResized(_) | Message::CanvasResized(_) ) && !(placing && matches!(&message, Message::Pan(_) | Message::Zoom(_))) { self.status = "Finish placing this element or Cancel (Esc) before leaving. Your name is retained.".into(); return Task::none(); } } // Scrollable geometry can change while async work or an overlay blocks // commands. Keep the observed translation current; its passive handler // still validates document, scenario and revision before accepting it. if matches!( &message, Message::ScenarioCanvas( crate::behavior::scenario::CanvasEvent::Scrolled(..) | crate::behavior::scenario::CanvasEvent::Restored(..) | crate::behavior::scenario::CanvasEvent::Viewport(..) ) | Message::TopologyCanvas( crate::behavior::topology::CanvasEvent::Scrolled(..) | crate::behavior::topology::CanvasEvent::Viewport(..) ) ) { return self.handle(message); } // Global subscriptions also receive keys consumed by an overlay. if self.model_menu.is_some() && matches!(message, Message::Escape) { self.model_menu = None; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); return Task::none(); } if self.model_menu.is_some() && !matches!( message, Message::Menu(_) | Message::CheckpointWritten(..) | Message::CheckpointLoaded(..) | Message::CheckpointTick | Message::WindowResized(_) | Message::CanvasResized(_) | Message::Lint(_) | Message::Linted(_, _) | Message::SourceViewport(_) | Message::Request(Pending::Close(_)) ) { return Task::none(); } if matches!( message, Message::WindowResized(_) | Message::Request(Pending::Close(_)) ) { self.model_menu = None; } if (self.source_dirty() || self.behavior.has_pending()) && matches!( message, Message::Field(..) | Message::FieldText(..) | Message::FieldShape(..) | Message::FieldAppend(..) | Message::FieldValuePath(..) | Message::FieldVariant(..) | Message::FieldArray(..) | Message::ElementKind(_) | Message::RelationKind(_) ) { self.status = "Apply or cancel pending source and behavior drafts before editing properties." .into(); return Task::none(); } if self.busy && !matches!( message, Message::CheckpointWritten(..) | Message::CheckpointLoaded(..) | Message::Saved(_) | Message::Hosted(crate::hosted::Event::Completed(..)) | Message::Loaded(_) | Message::IncludesLoaded(_, _) | Message::SavePath(_) | Message::SourceRecovered(_) | Message::Linted(_, _) | Message::Exported(_) | Message::WindowResized(_) | Message::CanvasResized(_) | Message::SourceViewport(_) ) { return Task::none(); } // A modal owns input until it is dismissed. This also protects against // global shortcuts and controls that were focused before it opened. if matches!( message, Message::Escape | Message::Request(Pending::Close(_)) ) { if self.hosted.visible { self.hosted.visible = false; return Task::none(); } if self.workflow.take().is_some() { return Task::none(); } if self.file_prompt.take().is_some() { self.continue_after_save = false; return Task::none(); } if self.delete_pending { self.delete_pending = false; self.delete_target = None; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); return Task::none(); } if self.pending.take().is_some() { self.continue_after_save = false; return Task::none(); } } let modal_event = matches!( message, Message::CheckpointWritten(..) | Message::CheckpointTick | Message::CheckpointLoaded(..) | 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(_) ); if !modal_event && ((self.hosted.visible && !matches!(message, Message::Hosted(_))) || (self.workflow.is_some() && !matches!(message, Message::Workflow(_))) || (self.file_prompt.is_some() && !matches!( message, Message::FilePath(_) | Message::SubmitFile | Message::CancelFile )) || (self.delete_pending && !matches!(message, Message::ConfirmDelete(_) | Message::CancelDelete)) || (self.pending.is_some() && self.file_prompt.is_none() && !matches!( message, Message::Cancel | Message::Discard | Message::SaveAndContinue ))) { return Task::none(); } if self.source_rename.open() { if matches!( message, Message::Escape | Message::Request(Pending::Close(_)) ) { self.close_source_rename(); return Task::none(); } if !modal_event && !matches!(message, Message::SourceRename(_)) { return Task::none(); } } if matches!( message, Message::Page(_) | Message::Layer(_) | Message::Diagram(_) ) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.selected_bend = None; } if self.behavior.has_pending() && matches!( message, Message::Page(page) if page != Page::Behavior ) { self.status = "Apply or cancel the behavior form before leaving this view.".into(); return Task::none(); } if self.behavior.has_pending() && matches!( message, Message::BrowseGroup(_) | Message::SourceEdit(_) | Message::SourceCreateFile | Message::SourceCloseFile(_) | Message::SourceUndo | Message::SourceRedo | Message::SourceRecover | Message::SourceLoadIncludes | Message::Menu(_) | Message::Connection(_) | Message::PortAnchor(_) | Message::Route(_) ) { self.status = "Apply or cancel the behavior form before changing another view.".into(); return Task::none(); } let previous = self.selected; let previous_layer = self.layer; let entering_catalog = self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog)); if self.page != Page::Home { self.last_workspace_page = self.page; } let mut task = self.handle(message); self.refresh_source_rename_context(); self.reconcile_reference_chooser(); self.route_state_diagram_workspace(); if self.page != Page::Home { self.last_workspace_page = self.page; } if let Some(context) = self.topology_context() { self.behavior .topology .enter(context.document, context.owner); } if let Some(context) = self.scenario_context() { self.behavior .scenario .enter(context.document, context.scenario); } if self.connection.as_ref().is_some_and(|session| { Some(session.context) != self.route_context() || self.page != Page::Diagram || self.draft_dirty() || self.source_dirty() }) { self.connection = None; } if previous_layer != self.layer { self.data_state = Default::default(); self.explorer_index = crate::explorer::Index::new(self.project(), self.layer); self.explorer_focus = None; self.explorer_group = None; self.explorer_page = 0; task = task.chain(Task::batch(["model-explorer", "model-browser"].map(|id| { iced::widget::operation::snap_to( id, iced::widget::scrollable::RelativeOffset::START, ) }))); } self.normalize_creation(); if self.page == Page::Catalog && (self.selected != previous || entering_catalog) { self.explorer_focus = self .project() .elements .get(&self.selected) .filter(|element| element.layer() == self.layer) .map(|element| element.id); self.explorer_group = self.explorer_index.group(self.selected); self.explorer_page = 0; } if self.selected != previous { self.reveal_explorer_selection(); task.chain(iced::widget::operation::snap_to( "inspector", iced::widget::scrollable::RelativeOffset::START, )) } else { task } } pub(crate) fn scenario_context(&self) -> Option { let scenario = crate::behavior::selected_owner(self)?; (self.page == Page::Behavior && self.project().elements.get(&scenario)?.kind() == Kind::Scenario) .then_some(crate::behavior::scenario::Context { document: self.canvas_document, scenario, revision: self.document.revision(), epoch: self.canvas_epoch, }) } pub(crate) fn scenario_focus_owner(&self) -> Option { if self.busy || self.hosted.visible || self.workflow.is_some() || self.architecture.is_some() || self.model_menu.is_some() || self.file_prompt.is_some() || self.delete_pending || self.pending.is_some() { return None; } self.behavior.scenario.focus_stamp(self.scenario_context()?) } fn handle(&mut self, message: Message) -> Task { match message { Message::WorkDrag(event) => return self.allocation_event(event), Message::BehaviorControl(intent) => return self.behavior_control(intent), Message::Architecture(event) => return self.architecture_event(event), Message::Home(event) => return self.home_event(event), Message::CheckpointTick => return self.checkpoint_tick(), Message::CheckpointWritten(id, result) => { self.checkpoint_written(id, result); if let Some(window) = self.checkpoint_close.take() { self.retire_checkpoint(); return window::close(window); } } Message::CheckpointLoaded(result) => return self.checkpoint_loaded(result), Message::Hosted(event) => return self.hosted_event(event), Message::ScenarioCanvas(event) => { use crate::behavior::scenario::{self, CanvasEvent}; let context = event.context(); if let CanvasEvent::FormShown(_, mount) = event { if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario && current.revision == context.revision }) && self.scenario_focus_owner().is_some() && self.behavior.scenario.take_form_focus(mount) { return scenario::focus::task(self.scenario_focus_owner().unwrap()); } return Task::none(); } if let CanvasEvent::Viewport(_, size) = event { if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario && current.revision == context.revision }) && self.behavior.scenario.viewport(size) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } return Task::none(); } if let CanvasEvent::Restored(_, token, area, offset) = event { if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario }) && self .behavior .scenario .restored(context, token, area, offset) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } return Task::none(); } if let CanvasEvent::Scrolled(_, area, offset) = event { // Details may report more than once in a frame. Accept the // current owner's passive clamps across an epoch bump while // still rejecting another document, owner or revision. if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario && current.revision == context.revision }) && self.behavior.scenario.scrolled(area, offset) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } return Task::none(); } if let CanvasEvent::Camera(_, lifetime, input) = event { use crate::behavior::canvas_workspace::{self, Event}; if lifetime != self.camera_epoch || !self.scenario_context().is_some_and(|c| { c.document == context.document && c.scenario == context.scenario && c.revision == context.revision }) { return Task::none(); } let scope = canvas_workspace::Scope { document: context.document, owner: context.scenario, revision: context.revision, epoch: lifetime, }; if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() || !canvas_workspace::keyboard_enabled(self) { return Task::none(); } match input { Event::Traverse(_) | Event::ControlFocus(_) | Event::ToggleFocus | Event::Leave => { return canvas_workspace::focus_task(scope, input); } Event::CancelGesture => { self.canvas_epoch = self.canvas_epoch.wrapping_add(1) } input => { if self.behavior.scenario.camera(input) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } } } return Task::none(); } if self.scenario_context() != Some(context) { return Task::none(); } match event { CanvasEvent::Remove(_, id) => { if diagram_removal::enabled(self) && self.selected == id && self.behavior.scenario.canvas_tool(context.scenario) == crate::behavior::scenario::CanvasTool::Select && crate::behavior::scenario::removal_target( self.project(), context.scenario, id, ) { self.begin_model_delete(None); } } CanvasEvent::Shelf(_, shelf) => { self.behavior.scenario.toggle_shelf(shelf); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } CanvasEvent::Zoom(_, scale) => { let mut state = std::mem::take(&mut self.behavior.scenario); if state.camera_zoom(self.project(), context.scenario, scale) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } self.behavior.scenario = state; } CanvasEvent::OpenUse(_, occurrence) => { if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { self.status = "Apply or cancel pending edits before opening a referenced scenario.".into(); return Task::none(); } let mut state = std::mem::take(&mut self.behavior.scenario); let target = state.open_use(self.project(), context, occurrence); self.behavior.scenario = state; match target { Ok(target) => { // A command can preselect this target while the // caller remains visible, bypassing select below. self.behavior.active_owner = Some(target); self.select(target); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } Err(error) => self.status = error, } } CanvasEvent::Back(_) => { if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { self.status = "Apply or cancel pending edits before returning to the caller." .into(); return Task::none(); } let mut state = std::mem::take(&mut self.behavior.scenario); let caller = state.back(self.project(), context); self.behavior.scenario = state; if let Some(caller) = caller { // A command may already have selected this UUID while // leaving the referenced definition open. select's // same-UUID fast path must not keep that old owner. self.behavior.active_owner = Some(caller.owner); self.select(caller.occurrence); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); if let Some(context) = self.scenario_context() { self.behavior.scenario.restore_caller(context, caller); } } else { // An edit can select a retargeted caller outside the // definition that remains visible after rejected Back. if crate::behavior::selected_behavior(self.project(), self.selected) != Some(context.scenario) { self.select(context.scenario); } self.status = "The calling scenario reference is no longer available.".into(); } } CanvasEvent::Select(_, id) => { if !self.behavior.has_pending() && crate::behavior::selected_behavior(self.project(), id) == Some(context.scenario) { self.select(id); self.selected_node = None; } } CanvasEvent::Choose(_, tool) => { if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { self.status = "Apply or cancel pending edits before choosing a scenario tool." .into(); return Task::none(); } self.behavior.scenario.choose_tool(context.scenario, tool); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } mutation => { if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { self.status = "Apply or cancel pending edits before changing the scenario." .into(); return Task::none(); } match mutation { CanvasEvent::BeginOccurrence { tool, slot, lifeline, .. } => { let mut state = std::mem::take(&mut self.behavior.scenario); let result = if tool == scenario::Tool::Lifeline { state .update( self.project(), scenario::Event::Start(context.scenario, tool), ) .map(|_| ()) } else { state.begin_occurrence( self.project(), context, tool, slot, lifeline, ) }; self.behavior.scenario = state; match result { Ok(()) => { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.status = "Complete the selected behavior at the highlighted sequence position.".into(); } Err(error) => self.status = error, } } CanvasEvent::Begin { slot, sender, receiver, style, .. } => { let mut state = std::mem::take(&mut self.behavior.scenario); let result = state.begin_message( self.project(), context, slot, sender, receiver, style, ); self.behavior.scenario = state; match result { Ok(()) => { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.status = "Choose the message exchange and apply at the highlighted sequence position.".into(); } Err(error) => self.status = error, } } CanvasEvent::Reorder { occurrence, slot, .. } => { match scenario::reorder( self.project(), context.scenario, occurrence, slot, ) { Ok(command) => { if self.execute(command) { self.selected = occurrence; self.selected_node = None; self.refresh_draft(); self.status = "Occurrence reordered within its sequence · Undo restores the previous order".into(); } } Err(error) => self.status = error, } } _ => unreachable!(), } } } } Message::TopologyCanvas(event) => return self.edit_process_canvas(event), Message::StateDiagram(event) => return self.edit_state_diagram(event), Message::BehaviorOwner(choice) => { if self.apply() && self.project().elements.get(&choice.id).is_some_and(|e| { e.layer() == self.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine) }) { self.behavior.active_owner = Some(choice.id); self.behavior.diagram = Default::default(); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.select(choice.id); self.page = Page::Behavior; } } Message::BehaviorDiagram(choice) => { if self.apply() && let Some(owner) = crate::behavior::selected_owner(self) && self .project() .diagrams .get(&choice.id) .is_some_and(|d| d.projection.state_machine() == Some(owner)) { self.behavior.diagram.diagram = Some(choice.id); self.behavior.diagram.tool = crate::behavior::state_edit::Tool::Select; self.behavior.diagram.fit = true; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.fit_state_diagram(); } } Message::PortAnchor(event) => { use crate::port_edit::Event; let (captured, node, port, command) = match event { Event::Commit(c, node, anchor) => ( c, node, anchor.port, Command::SetPortAnchor { diagram: c.diagram, node, anchor, }, ), Event::Reset(c, node, port) => ( c, node, port, Command::RemovePortAnchor { diagram: c.diagram, node, port, }, ), }; if self.route_context() != Some(captured) || self.page != Page::Diagram || self.draft_dirty() || self.source_dirty() || self.connection.is_some() { return Task::none(); } let view = project_view(self.project(), captured.diagram); if !view .ports .iter() .any(|p| p.element == port && p.owner == node) { return Task::none(); } if self.execute(command) { self.selected_node = None; self.status = "Port position updated in this diagram · Undo restores its previous position".into(); } } Message::Connection(event) => self.edit_connection(event), Message::SourceTree(event) => match event { crate::source_tree::Event::ToggleFolder(path) => self.source_tree.toggle(&path), crate::source_tree::Event::OpenFile(path) => { return self.update(Message::SourceFile(path)); } crate::source_tree::Event::FileMenu(path, anchor) => { let mut request = crate::model_menu::Request::object(self.project().id, anchor); request.source_file = Some(path); return self.update(Message::Menu(crate::model_menu::Event::Open(request))); } }, Message::SourceTreeCollapse => self.source_tree.collapse_all(), Message::SourceTreeLocate => { self.source_tree.reveal(&self.source_workspace.active); return iced::widget::operation::scroll_to( "source-files", crate::source_tree::active_offset( &self.source_workspace, &self.source_diagnostics, &self.source_tree, ), ); } Message::Menu(crate::model_menu::Event::Open(request)) => { // 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 { return Task::none(); }; self.connection = None; self.menu_sequence = opened.token; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); let mut request = opened.request; if request.canvas.is_some() { request.canvas = if self.page == Page::Behavior { self.state_context() } else { self.route_context() }; } self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence); } Message::Menu(crate::model_menu::Event::Close) => { if self.model_menu.take().is_some() { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } } Message::Menu(crate::model_menu::Event::Invoke(token, action)) => { if self .model_menu .as_ref() .is_none_or(|menu| menu.token != token) { return Task::none(); } let Some(menu) = self.model_menu.take() else { return Task::none(); }; if menu.token != token || !menu.valid(self) { return Task::none(); } if menu.request.source_file.is_some() { return self.update(*action); } if self.draft_dirty() || self.source_dirty() { self.status = "Apply or revert pending edits before using modeling actions.".into(); return Task::none(); } self.select(menu.request.object); self.selected_node = menu.request.placement; self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i)); return self.update(*action); } Message::CreateContextElement(kind, child) => { self.create_kind = kind; return self.update(Message::Add(child)); } Message::Escape => { let restore_source_focus = self.source_navigation_available() && (self.source_navigation.open || self.source_symbols.open); if self.behavior.source_back() { self.status = "Returned to the unchanged parent form; no source was created.".into(); return Task::none(); } if self.behavior.source_active() { self.behavior.acknowledge_commit(); self.status = "Behavior form cancelled; no staged source was created.".into(); } else if self.behavior.creation.has_pending() { self.behavior.creation.acknowledge_commit(); self.status = "Behavior creation cancelled".into(); } 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; } if restore_source_focus { self.restore_source_editor_focus(); } self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.selected_bend = None; } Message::Guide(event) => { if self.guide.update(event) { return iced::widget::operation::snap_to( crate::guide::ARTICLE_SCROLL_ID, iced::widget::scrollable::RelativeOffset::START, ); } } Message::ExactStrings(enabled) => { if let Some(d) = &mut self.draft { match d.set_exact(enabled) { Ok(()) => self.exact_strings = enabled, Err(e) => self.status = e, } } } Message::Field(path, value) => { if let Some(d) = &mut self.draft { d.input(&path, value) } } Message::FieldText(path, action) => { if let Some(d) = &mut self.draft { d.edit_text(&path, action) } } Message::FieldShape(path, value) => { if let Some(d) = &mut self.draft && let Err(e) = d.shape(&path, value) { self.status = e; } } Message::FieldAppend(path, value) => { if let Some(draft) = &mut self.draft && let Err(error) = draft.append(&path, value) { self.status = error; } } Message::FieldValueBrowse(path, scope, page) => { if let Some(draft) = &mut self.draft && draft .value .pointer(&path) .is_some_and(|row| row.get("expression").is_some()) { draft .value_browsers .insert(path, crate::forms::ValueBrowser { scope, page }); } } Message::FieldReferenceOpen(_) | Message::FieldReferenceEvent(_) => { return Task::none(); } Message::FieldReferenceBrowse(path, query, page) => { if let Some(draft) = &mut self.draft { let page = if draft .reference_browsers .get(&path) .is_some_and(|b| b.query == query) { page } else { 0 }; draft .reference_browsers .insert(path, crate::forms::ReferenceBrowser { query, page }); } } Message::FieldValuePath(path, key) => { if let Some(draft) = &mut self.draft && let Err(error) = draft.value_path(&path, key) { self.status = error; } } Message::FieldVariant(path, value) => { if let Some(d) = &mut self.draft { let result = d.materialize().and_then(|current| { if current .pointer(&path) .is_some_and(|v| crate::forms::same_variant(v, &value)) { Ok(()) } else { d.shape(&path, value) } }); if let Err(e) = result { self.status = e; } } } Message::FieldArray(path, index, direction) => { if let Some(d) = &mut self.draft && let Err(e) = d.array(&path, index, direction) { self.status = e; } } Message::Apply => { self.apply(); } Message::RevertProperties => { self.refresh_draft(); self.status = "Property draft reverted".into(); } Message::Select(id) => self.select(id), Message::EditProperties(id) => { self.select(id); self.inspector_tab = crate::navigation::InspectorTab::Properties; } Message::SelectProject => self.select(self.project().id), Message::SelectNode(id) => { if (self.selected_node == Some(id) && self.active_diagram .and_then(|diagram| self.project().diagrams.get(&diagram)) .and_then(|diagram| diagram.nodes.get(&id)) .is_some_and(|node| self.selected == node.element)) // Defer a placement draft until release: it may change the // identity captured by the just-started canvas gesture. || (self.selected == id && self.draft.as_ref().is_some_and(Draft::dirty)) { return Task::none(); } let original = self.selected; if !self.apply() { return Task::none(); } let id = if id == original { self.selected } else { id }; if let Some(d) = self .active_diagram .and_then(|d| self.project().diagrams.get(&d)) && let Some(node) = d.nodes.get(&id) { self.selected = node.element; self.selected_node = Some(id); self.refresh_draft(); } } Message::Traceability(event) => { self.traceability.update(event); return iced::widget::operation::snap_to( "traceability-list", iced::widget::scrollable::RelativeOffset::START, ); } Message::Search(value) => { self.search = value; self.explorer_page = 0; return Task::batch(["model-browser", "model-explorer"].map(|id| { iced::widget::operation::snap_to( id, iced::widget::scrollable::RelativeOffset::START, ) })); } Message::Page(page) => { if page != Page::Text || !self.draft_dirty() || self.apply() { if page == Page::Diagram && self.active_diagram.is_some_and(|id| { self.project() .diagrams .get(&id) .is_some_and(|d| d.projection.state_machine().is_some()) }) { // Architecture remains reachable from a state workspace. // Its picker can still open a saved state view explicitly. self.active_diagram = self .project() .diagrams .values() .filter(|d| { d.layer == self.layer && d.projection.state_machine().is_none() }) .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) .map(|d| d.id); self.fit(); } if page == Page::Behavior && self.page != Page::Behavior { let previous_state_view = self.state_view_identity(); if let Some(owner) = crate::behavior::selected_behavior(self.project(), self.selected) { self.behavior.active_owner = Some(owner); } self.behavior.active_owner = crate::behavior::selected_owner(self); self.reset_state_view_after_navigation(previous_state_view); } self.page = page; self.selected_node = if page == Page::Behavior { None } else { self.selected_architecture_placement() }; if page == Page::Text { return self.request_lint(); } } } Message::CollapseExplorer(id) => { if !self.explorer_collapsed.remove(&id) { self.explorer_collapsed.insert(id); } } Message::ExplorerGroup(group) => { if !self.explorer_groups_collapsed.remove(&group) { self.explorer_groups_collapsed.insert(group); } } Message::ExplorerCollapseAll => { self.explorer_collapsed = self .project() .elements .values() .filter_map(|e| e.parent) .collect(); self.explorer_groups_collapsed = crate::explorer::Group::ALL.into_iter().collect(); self.search.clear(); return iced::widget::operation::snap_to( "model-explorer", iced::widget::scrollable::RelativeOffset::START, ); } Message::ExplorerLocate => { self.search.clear(); self.reveal_explorer_selection(); let offset = crate::explorer_view::selection_offset(self); return iced::widget::operation::scroll_to("model-explorer", offset); } Message::Explore(id) => { if self.apply() { self.explorer_focus = id.filter(|id| self.project().elements.contains_key(id)); self.explorer_group = self .explorer_focus .and_then(|id| self.explorer_index.group(id)); self.search.clear(); self.explorer_page = 0; self.page = Page::Catalog; self.select(self.explorer_focus.unwrap_or(self.project().id)); return iced::widget::operation::snap_to( "model-browser", iced::widget::scrollable::RelativeOffset::START, ); } } Message::BrowseGroup(group) => { self.explorer_focus = None; self.explorer_group = Some(group); self.search.clear(); self.explorer_page = 0; self.page = Page::Catalog; return iced::widget::operation::snap_to( "model-browser", iced::widget::scrollable::RelativeOffset::START, ); } Message::ExplorerPage(page) => { self.explorer_page = page; return iced::widget::operation::snap_to( "model-browser", iced::widget::scrollable::RelativeOffset::START, ); } Message::StartTransition => { if self.apply() { match crate::workflow::Workflow::transition(self.project(), self.selected) { Ok(w) => self.workflow = Some(w), Err(e) => self.status = e, } } } Message::StartSemanticReview(selected) => { if self.apply() { match crate::semantic_review::State::new(self.project(), selected) { Ok(state) => { self.workflow = Some(crate::workflow::Workflow::Semantic(Box::new(state))) } Err(error) => self.status = error, } } } Message::Workflow(event) => { if matches!(event, crate::workflow::Event::Cancel) { self.workflow = None; return Task::none(); } if let Some(mut workflow) = self.workflow.take() { if let crate::workflow::Workflow::Semantic(state) = &mut workflow { match event { crate::workflow::Event::Semantic(event) => { state.update(self.project(), *event) } crate::workflow::Event::Apply => { let focus = state.target(self.project()); match state.command(self.project()) { Ok(command) => { if self.execute(command) { if let Some(target) = focus && self.focus_after_review(target, None) && self.page == Page::Diagram { self.fit(); } self.status = "Semantic review applied · model, source and baseline form one undoable change".into(); return Task::none(); } state.error = Some(self.status.clone()); } Err(error) => { self.status = error.clone(); state.error = Some(error); } } } _ => {} } self.workflow = Some(workflow); return Task::none(); } let stay_in_catalog = self.page == Page::Catalog && matches!(&workflow, crate::workflow::Workflow::Ports(_)); let transition_focus = match &workflow { crate::workflow::Workflow::Ports(state) => Some(state.focus()), crate::workflow::Workflow::Transition(t) => t .request .sources .first() .and_then(|source| t.plan.changes.iter().find(|c| c.source == *source)) .map(|c| c.target), _ => None, }; let behavior_transition = matches!(&workflow, crate::workflow::Workflow::Transition(t) if t.request.sources.first().is_some_and(|source| crate::behavior::selected_behavior(self.project(), *source).is_some())); let previous_behavior_context = self.state_view_identity(); let requested_destination = match &workflow { crate::workflow::Workflow::Transition(t) => t.request.destination_diagram, _ => None, }; match workflow.update(self.project(), event) { Ok(Some(mut candidate)) => { let focus = transition_focus .or_else(|| { candidate .relations .keys() .find(|id| !self.project().relations.contains_key(id)) .copied() }) .or_else(|| { candidate .traces .keys() .find(|id| !self.project().traces.contains_key(id)) .copied() }); let behavior_owner = focus .filter(|_| behavior_transition) .and_then(|id| crate::behavior::selected_behavior(&candidate, id)); let destination = focus.and_then(|id| { candidate.layer_of(id).and_then(|layer| { requested_destination .filter(|view| { candidate.diagrams.get(view).is_some_and(|d| { (d.layer == layer || d.projection == ProjectionKind::Traceability) && d.nodes .values() .any(|node| node.element == id) }) }) .or_else(|| { preferred_diagram( &candidate, self.active_diagram, layer, id, ) }) }) }); if let (None, Some(id), Some(diagram)) = (behavior_owner, focus, destination) { reveal_port_owner(&mut candidate, diagram, id); } if self.execute(Command::Replace(candidate)) { if let Some(id) = focus { self.focus_after_review(id, destination); } if let Some(owner) = behavior_owner { self.selected_node = None; self.behavior.active_owner = Some(owner); self.page = Page::Behavior; if self.reset_state_view_after_navigation( previous_behavior_context, ) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } } else if !stay_in_catalog { self.page = Page::Diagram; self.fit(); } self.status="Reviewed change applied · Undo restores the complete operation".into(); } else { self.workflow = Some(workflow); } } Ok(None) => self.workflow = Some(workflow), Err(e) => { self.status = e; self.workflow = Some(workflow); } } } } Message::Layer(layer) => { if self.apply() { let previous_state_view = self.state_view_identity(); self.layer = layer; self.create_kind = if layer == Layer::Oa { Kind::Activity } else { Kind::Function }; self.active_diagram = self .project() .diagrams .values() .filter(|d| d.layer == layer) .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) .map(|d| d.id); self.selected = self.project().id; self.selected_node = None; self.behavior.active_owner = crate::behavior::selected_owner(self); self.reset_state_view_after_navigation(previous_state_view); self.refresh_draft(); self.fit(); } } Message::Data(event) => { if self.data_state.update(&event) { return Task::none(); } if self.apply() { match crate::data_view::command(self.project(), self.layer, event) { Ok(command) => { self.execute(command); } Err(error) => self.status = error, } } } Message::CreateKind(kind) => self.create_kind = kind, Message::Add(child) => { if self.apply() { self.add_element(child); } } Message::ElementKind(kind) => { if let Some(d) = &mut self.draft { let result = d .materialize() .and_then(|v| { serde_json::from_value::(v).map_err(|e| e.to_string()) }) .and_then(|element| { if element.kind() == kind { return Ok(()); } let body = ElementBody::new(kind, element.layer()) .ok_or("Kind is unavailable in this layer.")?; d.shape("/body", json!(body)) }); if let Err(e) = result { self.status = e; } } } Message::RelationKind(kind) => { if let Some(d) = &mut self.draft { let result = d .materialize() .and_then(|v| { serde_json::from_value::(v).map_err(|e| e.to_string()) }) .and_then(|relation| { if relation.kind() == kind { return Ok(()); } d.shape( "/body", json!(RelationBody::new( kind, relation.source(), relation.target() )), ) }); if let Err(e) = result { self.status = e; } } } Message::AddRelation => { if self.apply() { self.add_relation(); } } Message::PortAction(action) => { if self.apply() { match crate::port_workflow::State::new(self.project(), self.selected, action) { Ok(state) => { self.inspector_tab = crate::navigation::InspectorTab::Properties; self.workflow = Some(crate::workflow::Workflow::Ports(Box::new(state))) } Err(error) => self.status = error, } } } Message::AddRelationKind(kind, incoming) => { if self.apply() { self.workflow = Some(crate::workflow::Workflow::relationship_kind( self.project(), self.selected, self.layer, kind, incoming, )); } } Message::InspectorTab(tab) => self.inspector_tab = tab, Message::Behavior(event) => { if matches!(&event, behavior::Event::Creation(behavior::creation::Event::Open(layer)) if *layer != self.layer) { self.status = "The active layer changed. Choose New in the current layer.".into(); return Task::none(); } let creating = matches!( &event, behavior::Event::Creation(behavior::creation::Event::Open(..)) ); let focus_name = matches!( &event, behavior::Event::Creation(behavior::creation::Event::Choose(..)) ); if creating && (self.behavior.has_pending() || self.source_dirty() || self.draft_dirty()) { self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into(); return Task::none(); } if (event.starts_form() || (event.is_source() && !event.is_source_back())) && (self.source_dirty() || self.draft_dirty()) { self.status="Apply or cancel the other editors before starting this form. Your inputs are retained.".into(); return Task::none(); } let source_was_open = self.behavior.source_open(); if event.is_submit() && (self.source_dirty() || self.draft_dirty()) { self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into(); return Task::none(); } let mut state = std::mem::take(&mut self.behavior); let result = state.update(self.project(), event); self.behavior = state; match result { Ok(Some(command)) => { if self.execute(command) { self.behavior.acknowledge_commit(); if self.topology_context().is_some() { return self.reveal_process_selection(); } } } Ok(None) => { if !source_was_open && self.behavior.source_open() { return iced::widget::operation::focus( behavior::source_draft::NAME_INPUT, ); } if focus_name { return iced::widget::operation::focus(behavior::creation::NAME_INPUT); } } Err(error) => self.status = error, } } Message::RevealInDiagram(diagram, selected) => { let original = self.selected; if !self.apply() { return Task::none(); } let selected = if selected == original { self.selected } else { selected }; let Some(view) = self.project().diagrams.get(&diagram) else { return Task::none(); }; let layer = view.layer; let endpoints = self .project() .relations .get(&selected) .map(|r| vec![r.source(), r.target()]) .unwrap_or_else(|| vec![selected]) .into_iter() .map(|id| { self.project() .elements .get(&id) .filter(|e| e.kind() == Kind::Port) .and_then(|e| e.parent) .unwrap_or(id) }) .collect::>(); let nodes = view .nodes .values() .filter(|n| endpoints.contains(&n.element) || n.id == selected) .map(|n| n.id) .collect::>(); let commands = view .nodes .values() .filter(|n| { n.collapsed && { let children = oa_core::projection::descendant_nodes( self.project(), diagram, n.id, ); nodes.iter().any(|id| children.contains(id)) } }) .map(|n| { let mut node = n.clone(); node.collapsed = false; Command::EditNode { diagram, original: node.id, node, } }) .collect::>(); if !commands.is_empty() && !self.execute(Command::Batch(commands)) { return Task::none(); } self.active_diagram = Some(diagram); self.layer = layer; self.selected = selected; self.page = Page::Diagram; self.refresh_draft(); self.fit(); } Message::AddDiagram => { if self.apply() { let id = Id::new_v4(); let p = self.project(); let d = Diagram { id, alias: p.fresh_alias(&format!("{}_view", self.layer), "view", id), name: format!("{} architecture", self.layer), description: String::new(), layer: self.layer, projection: ProjectionKind::Allocation, routes: Vec::new(), view_options: Default::default(), nodes: Default::default(), }; if self.execute(Command::CreateDiagram(d)) { self.active_diagram = Some(id); self.page = Page::Diagram; } } } Message::DuplicateDiagram(source) => { if !self.project().diagrams.contains_key(&source) { self.status = "The saved view to duplicate no longer exists.".into(); return Task::none(); } let original = self.selected; if self.apply() { let source = if source == original { self.selected } else { source }; match crate::view_copy::command(self.project(), source) { Ok(command) => { if self.execute(command) { self.active_diagram = Some(self.selected); self.layer = self.project().diagrams[&self.selected].layer; self.selected_node = None; self.selected_bend = None; self.page = Page::Diagram; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.route_state_diagram_workspace(); if self.page == Page::Diagram { self.fit(); } self.status = "View copied; engineering elements are shared.".into(); } } Err(error) => self.status = error, } } } Message::AddArtifact => { if self.apply() { let id = Id::new_v4(); let p = self.project(); let a = ArtifactLink { id, alias: p.fresh_alias("artifact", "artifact", id), name: "Development artifact".into(), description: String::new(), owner: self.selected, relation: ArtifactRelation::References, uri: "./artifacts/design.md".into(), revision: None, fragment: None, }; self.execute(Command::CreateArtifact(a)); } } Message::AddTrace => { if self.apply() { self.add_trace(); } } Message::Command(command) => { let semantic_selection = match &command { Command::EditNode { diagram, node, .. } if self.state_context().is_some_and(|c| c.diagram == *diagram) => { Some(node.element) } _ => None, }; let original = self.selected; if self.apply() { let command = match command { Command::Layout(id) if id == original => Command::Layout(self.selected), Command::MoveStep(id, from, to) if id == original => { Command::MoveStep(self.selected, from, to) } command => command, }; if self.execute(command) && let Some(element) = semantic_selection { self.selected = element; self.refresh_draft(); } } } Message::DiagramRemove(intent) => self.remove_from_diagram(intent), Message::DiagramTraverse(back) => { return if back { iced::widget::operation::focus_previous() } else { iced::widget::operation::focus_next() }; } Message::Delete => { if self.selected != self.project().id && self.apply() { self.begin_model_delete(None); } } Message::ConfirmDelete(token) => self.confirm_model_delete(token), Message::CancelDelete => { self.delete_pending = false; self.delete_target = None; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } Message::Undo | Message::Redo => { let redo = matches!(message, Message::Redo); if self.apply() { let changed = if redo { self.document.redo() } else { self.document.undo() }; if changed { self.sync(); } self.status = if !changed && redo { "Nothing to redo" } else if !changed { "Nothing to undo" } else if redo { "Model change redone" } else { "Model change undone" } .into(); } } Message::ShortcutUndo(redo) => { return self.handle(if self.page == Page::Text { if redo { Message::SourceRedo } else { Message::SourceUndo } } else if redo { Message::Redo } else { Message::Undo }); } Message::Diagram(choice) => { // Reject a stale picker event before applying unrelated property input. if !self.project().diagrams.contains_key(&choice.id) { self.status = "Diagram no longer exists.".into(); return Task::none(); } let previous_diagram = self.active_diagram; let follow_view = previous_diagram == Some(self.selected); if self.apply() { // Apply may have changed the current diagram's UUID. A picker // event for that same view must follow its new identity. let diagram = if previous_diagram == Some(choice.id) { self.active_diagram.unwrap_or(choice.id) } else { choice.id }; self.active_diagram = Some(diagram); if let Some(d) = self.project().diagrams.get(&diagram) { self.layer = d.layer; } if follow_view { self.selected = diagram; } self.selected_node = None; self.refresh_draft(); self.fit(); } } Message::Projection(projection) => { if self.apply() && let Some(mut d) = self .active_diagram .and_then(|id| self.project().diagrams.get(&id)) .cloned() { d.projection = projection; self.execute(Command::EditDiagram(d.id, d)); self.fit(); } } Message::PlaceSelected => { if self.apply() && let Some(diagram) = self.active_diagram { let selected = self.selected; if let Some(node) = self .project() .diagrams .get(&diagram) .and_then(|d| d.nodes.values().find(|n| n.element == self.selected)) .map(|n| n.id) { self.selected_node = Some(node); self.status = "Element is already shown in this diagram".into(); return self.handle(Message::RevealInDiagram(diagram, selected)); } let mut p = self.project().clone(); let node = p.place(diagram, self.selected, Position { x: 60., y: 60. }); if let Some(n) = p .diagrams .get(&diagram) .and_then(|d| d.nodes.get(&node)) .cloned() { let mut commands = vec![Command::CreateNode { diagram, node: n }]; for owner in p.diagrams[&diagram].nodes.values().filter(|n| { n.collapsed && projection::descendant_nodes(&p, diagram, n.id).contains(&node) }) { let mut expanded = owner.clone(); expanded.collapsed = false; commands.push(Command::EditNode { diagram, original: owner.id, node: expanded, }); } if self.execute(Command::Batch(commands)) { self.selected_node = Some(node); return self.handle(Message::RevealInDiagram(diagram, selected)); } } } } Message::HideSelected => { if self.apply() && let Some(node) = self.selected_node { self.execute(Command::Delete(node)); self.selected_node = None; } } Message::ToggleCollapse => { if self.apply() && let Some(diagram) = self.active_diagram && let Some(mut n) = self .selected_node .and_then(|id| self.project().diagrams.get(&diagram)?.nodes.get(&id)) .cloned() { n.collapsed = !n.collapsed; let selected = self.selected; if self.execute(Command::EditNode { diagram, original: n.id, node: n, }) { self.selected = selected; self.refresh_draft(); } } } Message::Route(event) => self.edit_route(event), Message::MoveNode(node, position) => { let original = self.selected; if self.apply() && let Some(diagram) = self.active_diagram { let node = if node == original { self.selected } else { node }; if self.execute(Command::MoveNode { diagram, node, position, with_descendants: true, }) && let Some(placement) = self.project().diagrams[&diagram].nodes.get(&node) { // A drag edits a placement, but the canvas still selects // the engineering element represented by that placement. self.selected = placement.element; self.refresh_draft(); } } } Message::Zoom(factor) => { self.fit_to_view = false; self.zoom = (self.zoom * factor).clamp(0.0001, 3.); } Message::Pan(delta) => { self.fit_to_view = false; self.pan += delta; } Message::Fit => self.fit(), Message::SourceEdit(action) => { self.source_jump = None; let before = self.source_editor.text(); self.source_editor.perform(action); let after = self.source_editor.text(); if after != before { self.source_navigation.refresh(&after); self.source_workspace .edit(after.clone(), self.source_editor.cursor()); self.source_snapshot = Arc::from(after); self.source_version += 1; self.source_diagnostics.clear(); let version = self.source_version; return Task::perform( async move { tokio::time::sleep(std::time::Duration::from_millis(350)).await; version }, Message::Lint, ); } } Message::SourceFile(path) => { if path != self.source_workspace.active && self.source_workspace.files.contains_key(&path) { let departure = crate::source_navigation::selection::capture(&mut self.source_editor); self.source_workspace.switch(&path, departure); self.show_source_file(true); } } Message::SourceNewPath(path) => self.source_workspace.new_path = path, Message::SourceCreateFile => { if let Err(error) = self.create_source_file() { self.status = error; } else { return self.request_lint(); } } Message::SourceCloseFile(path) => { if self.source_workspace.discard_detached(&path) { self.source_version += 1; self.prune_closed_files(!self.document.dirty()); self.show_source_file(true); } } Message::SourceLoadIncludes => return self.load_includes(), Message::SourceRecover => { if !self.source_workspace.applied && self.source_workspace.has_edits() { self.status = "Revert or preserve your source drafts before reloading recovered files." .into(); return Task::none(); } if let Some(path) = self.pending_recovery.clone() { self.busy = true; return Task::perform( async move { tokio::task::spawn_blocking(move || recover_snapshot(path)) .await .map_err(|e| e.to_string())? }, Message::SourceRecovered, ); } } Message::SourceRecovered(result) => { self.busy = false; match result { Ok(receipt) => { if self.pending_recovery.as_ref() != Some(&receipt.target) { return Task::none(); } 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"); match receipt.disk { Ok(disk) if !self.source_workspace.applied => { self.open_source_project(disk) } Ok(disk) => { // Recovering an old Save As target must not redirect // the source project that was subsequently saved elsewhere. if self.path.is_none() || self.path.as_ref() == Some(&receipt.target) { if self.path.as_ref() != Some(&receipt.target) { self.source_workspace.reset_navigation_session(); } self.path = Some(receipt.target); self.document.bind_repair_baseline(disk.reachable_bundle()); self.disk_project = Some(disk); } self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into(); } Err(error) => { if self.path.is_none() || self.path.as_ref() == Some(&receipt.target) { self.document.invalidate_saved_baseline(); } self.status = format!( "Disk recovery completed; files could not be reloaded: {error}" ) } } } Err(error) => self.status = error, } } Message::IncludesLoaded(version, result) => { self.busy = false; if version == self.source_version { match result { Ok(disk) => { if let Some(target) = recovery_target(&disk) { self.pending_recovery = Some(target); } self.source_workspace .discovered(&disk.bundle, disk.reachable.clone()); self.source_version += 1; self.source_diagnostics = disk.diagnostics.clone(); self.disk_project = Some(disk); self.status = "Included files loaded; existing drafts retained.".into(); return self.request_lint(); } Err(error) => self.status = error, } } } Message::Lint(version) => { if version == self.source_version { return self.request_lint(); } } Message::Linted(version, result) => { if self.lint_running != Some(version) { return Task::none(); } self.lint_running = None; if version == self.source_version { match result { Ok(diagnostics) => self.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::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), Message::SourceRename(event) => return self.source_rename_event(event), Message::SourceRenameReady(completion) => return self.source_rename_ready(completion), Message::SourceSymbolsReady(completion) => { return self.source_symbols_ready(completion); } Message::DiagramUsage(event) => return self.diagram_usage_event(event), Message::DiagramUsagesReady(completion) => { return self.diagram_usages_ready(completion); } Message::DiagramRevealViewport(context, epoch, size) => { if self .pending_diagram_reveal .as_ref() .is_some_and(|(c, _, e)| *c == context && *e == epoch) && self .diagram_reveal_viewport(&Message::DiagramRevealViewport( context, epoch, size, )) .is_some() { return self.handle(Message::CanvasResized(size)); } } Message::ApplySource => { self.apply_source(); } Message::RevertSource => { self.source_workspace.revert_active(); self.show_source_file(true); self.source_version += 1; self.status = "This file's draft reverted; other files retained.".into(); return self.request_lint(); } Message::SourceUndo | Message::SourceRedo => { let redo = matches!(message, Message::SourceRedo); if self.source_workspace.undo(redo) { self.show_source_file(true); self.source_version += 1; self.source_diagnostics.clear(); return self.request_lint(); } } Message::SourceForSelection => { if self.apply() { self.page = Page::Text; if let Some(location) = self .document .compiled() .index .declarations .get(&self.selected) .cloned() { self.jump_to_source(location); self.status = "Source declaration selected.".into(); } return self.request_lint(); } } Message::SourceNavigation(event) => { use crate::source_navigation::Event; match event { Event::Open(go_to_line) => { if self.page != Page::Text && !self.apply() { return Task::none(); } self.page = Page::Text; self.source_find_instance = Id::new_v4(); self.source_navigation.open = true; self.source_navigation.refresh(&self.source_editor.text()); return iced::widget::operation::focus(if go_to_line { "source-line" } else { "source-find" }); } Event::Close => { let was_open = self.source_navigation.open; self.source_navigation.open = false; if was_open { self.restore_source_editor_focus(); } } Event::Query(query) => { self.source_navigation.query = query; self.source_navigation.refresh(&self.source_editor.text()); } Event::Next(backwards) if self.page == Page::Text => { self.source_jump = self.source_navigation.next(backwards); if let Some(cursor) = self.source_jump { self.queue_source_reveal(cursor, false); } if self.source_jump.is_none() { self.status = "No matching source text.".into(); } } Event::Line(line) => self.source_navigation.line = line, Event::Go => match self .source_navigation .destination(self.source_editor.line_count()) { Ok(cursor) => { self.queue_source_reveal(cursor, true); } Err(error) => self.status = error, }, _ => {} } } Message::Request(pending) => { if self.dirty() { self.pending = Some(pending); } else { return self.proceed(pending); } } Message::Cancel => { self.pending = None; self.continue_after_save = false; } Message::Discard => { if let Some(p) = self.pending.take() { return self.proceed(p); } } Message::SaveAndContinue => { self.continue_after_save = true; return self.handle(Message::Save(false)); } Message::Save(save_as) => { if !save_as && self.hosted.session.is_some() { return self.save_hosted(); } if !self.apply_behavior() || !self.apply_source() || !self.apply() { self.continue_after_save = false; self.pending = None; return Task::none(); } if !save_as && let Some(path) = self.path.clone() { return self.save_to(path); } return self.choose_file(FileOperation::Save); } Message::SavePath(path) => { self.busy = false; if let Some(path) = path { return self.save_to(path); } self.continue_after_save = false; self.status = "Save cancelled".into(); } Message::Saved(result) => { self.busy = false; match result { Ok(Some(receipt)) => { if receipt.document != self.canvas_document { return Task::none(); } if let Some(disk) = receipt.disk { if self.pending_recovery.as_ref() == Some(&disk.root.join(&disk.bundle.entry)) { self.pending_recovery = None; } if receipt.snapshot.bundle.entry != self.document.bundle().entry { if receipt.revision != self.document.revision() || self.source_dirty() { self.status = format!( "Snapshot saved to {}. Newer edits retain the current source location.", receipt.path.display() ); self.continue_after_save = false; return Task::none(); } if let Err(error) = self .document .adopt_saved_entry(&receipt.snapshot, receipt.revision) { self.status = error.to_string(); self.continue_after_save = false; return Task::none(); } self.sync_source(); } if self.path.as_ref() != Some(&receipt.path) { self.source_workspace.reset_navigation_session(); } self.disk_project = Some(disk); self.path = Some(receipt.path); self.hosted.session = None; self.hosted.review = None; self.hosted.uncertain = None; self.document.mark_saved(receipt.snapshot); self.remember_local(); if !self.dirty() { self.retire_checkpoint(); } self.prune_closed_files(!self.document.dirty()); self.status = "All source files saved".into(); } else { self.status = format!( "JSON exported to {}. Source files and their save state are retained.", receipt.path.display() ); self.continue_after_save = false; } if self.continue_after_save { self.continue_after_save = false; if let Some(p) = self.pending.take() { return self.proceed(p); } } } Ok(None) => { self.status = "Save cancelled".into(); self.continue_after_save = false; } Err(e) => { if e.document != self.canvas_document { return Task::none(); } if let Some(target) = e.recovery { self.pending_recovery = Some(target); } self.status = e.message; if self.pending_recovery.is_some() { self.page = Page::Text; } self.continue_after_save = false; } } } Message::Loaded(result) => { self.busy = false; match result { Ok(Some(LoadedSnapshot::Legacy(path, decoded, _bytes))) => { self.reset(decoded.project, None); let name = self.project().name.clone(); if let Err(error) = self.home.remember(&path, &name) { self.home.notice = Some(error); } self.migration = decoded.migration; if self.migration.is_some() { self.status = "Imported earlier schema · review migration details in Model checks before saving".into(); self.page = Page::Validation; } else { self.status = format!( "Imported {}. Save as an Arcadia source project to retain file structure and comments.", path.display() ); } } Ok(Some(LoadedSnapshot::Source(disk))) => self.open_source_project(disk), Ok(None) => self.status = "Open cancelled".into(), Err(e) => self.status = e, } } Message::FilePath(value) => { if let Some(p) = &mut self.file_prompt { p.path = value; p.overwrite = false; } } Message::CancelFile => { self.file_prompt = None; self.continue_after_save = false; } Message::SubmitFile => { if let Some(prompt) = &mut self.file_prompt { if prompt.path.trim().is_empty() { self.status = "Enter a file path.".into(); return Task::none(); } let path = PathBuf::from(prompt.path.trim()); if prompt.operation == FileOperation::Export && path.exists() && !prompt.overwrite { prompt.overwrite = true; return Task::none(); } let operation = prompt.operation; self.file_prompt = None; return match operation { FileOperation::Open => self.load_from(path), FileOperation::Save => self.save_to(path), FileOperation::Export => self.export_to(path), }; } } Message::Export => { if self.apply() { return self.choose_file(FileOperation::Export); } } Message::Exported(result) => { self.busy = false; self.status = match result { Ok(true) => "Diagram exported as SVG".into(), Ok(false) => "Export cancelled".into(), Err(e) => e, }; } Message::WindowResized(size) => { // The canvas can retain its capped size during a real window // resize. Invalidate any sequence pointer/slot gesture anyway. if self.window_size != size && (self.scenario_context().is_some() || self.topology_context().is_some()) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } self.window_size = size; } Message::CanvasResized(size) => { let first_measurement = self.canvas_size.is_none(); let size_changed = self.canvas_size != Some(size); self.canvas_size = Some(size); // Modal overlays can remount the sensor and repeat on_show at // the same size. Keep the viewport stable after model edits. if first_measurement || (size_changed && self.fit_to_view) { self.fit(); } } } Task::none() } fn add_element(&mut self, child: bool) { if self.create_kind == Kind::Port { self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into(); return; } let allocated = child && self.create_kind.is_work() && self .project() .elements .get(&self.selected) .is_some_and(|e| e.layer() == self.layer && e.kind().is_performer()); let parent = if child && !allocated { Some(self.selected) } else { None }; let Some(e) = self.project().make_element( self.create_kind, self.layer, &format!("New {}", self.create_kind.label().to_lowercase()), parent, ) else { return; }; let id = e.id; let mut commands = vec![Command::CreateElement(e)]; let mut diagram_target = self.active_diagram; let preferred = if self.create_kind.is_work() || self.create_kind.is_performer() { Some(ProjectionKind::Allocation) } else if ProjectionKind::Capabilities.includes(self.create_kind) { Some(ProjectionKind::Capabilities) } else { None }; if self.page == Page::Diagram && self .active_diagram .and_then(|id| self.project().diagrams.get(&id)) .is_some_and(|d| !d.projection.includes(self.create_kind)) && let Some(preferred) = preferred { diagram_target = self .project() .diagrams .values() .find(|d| d.layer == self.layer && d.projection == preferred) .map(|d| d.id); if diagram_target.is_none() { let view = Id::new_v4(); commands.push(Command::CreateDiagram(Diagram { id: view, alias: self.project().fresh_alias("engineering_view", "view", view), name: format!("{} {preferred}", self.layer), description: String::new(), layer: self.layer, projection: preferred, routes: Vec::new(), view_options: Default::default(), nodes: Default::default(), })); diagram_target = Some(view); } } if allocated { commands.push(Command::Connect( RelationKind::Allocation, "Performs".into(), self.selected, id, )); } if self.create_kind == Kind::Occurrence && let Some(owner) = parent.and_then(|id| self.project().elements.get(&id)) && let Some(steps) = owner.steps() { let mut steps = steps.clone(); steps.push(id); commands.push(Command::SetSteps(owner.id, steps)); } if self.page == Page::Diagram && self.create_kind != Kind::Port && let Some(diagram) = diagram_target && self .project() .diagrams .get(&diagram) .is_none_or(|d| d.projection.includes(self.create_kind)) { let mut p = self.project().clone(); for command in &commands { if let Command::CreateDiagram(d) = command { p.diagrams.insert(d.id, d.clone()); } } p.elements.insert( id, match &commands[0] { Command::CreateElement(e) => e.clone(), _ => unreachable!(), }, ); let owner = if allocated { Some(self.selected) } else { parent }; // Insertion measures an expanded owner path and commits that reveal // with the new child, so folded contents cannot overlap the new row. if let Some(owner) = owner { let mut expanded = p.diagrams[&diagram].clone(); for node in expanded.nodes.values_mut() { node.collapsed = false; } let expanded_view = oa_core::projection::project_diagram(&p, &expanded); let mut current = expanded_view .nodes .iter() .find(|n| n.element == owner) .map(|n| n.id); while let Some(id) = current { let node = p .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&id) .unwrap(); if node.collapsed { node.collapsed = false; commands.push(Command::EditNode { diagram, original: id, node: node.clone(), }); } current = expanded_view .nodes .iter() .find(|n| n.id == id) .and_then(|n| n.visual_parent); } } // Reserve the expanded size of folded child containers so inserting // a sibling does not overlap their contents when they are unfolded. let mut expanded = p.diagrams[&diagram].clone(); for node in expanded.nodes.values_mut() { node.collapsed = false; } let view = oa_core::projection::project_diagram(&p, &expanded); let position = if let Some(container) = view.nodes.iter().find(|n| Some(n.element) == owner) { let y = view .nodes .iter() .filter(|n| n.visual_parent == Some(container.id)) .map(|n| n.position.y + n.size.height + 16.) .fold(container.position.y + 64., f32::max); Position { x: container.position.x + 24., y, } } else { Position { x: view .nodes .iter() .map(|n| n.position.x + n.size.width + 240.) .fold(60., f32::max), y: 60., } }; let node = p.place(diagram, id, position); commands.push(Command::CreateNode { diagram, node: p.diagrams[&diagram].nodes[&node].clone(), }); } if self.page == Page::Diagram && let Some(diagram) = diagram_target && let Ok(candidate) = execute_batch(self.project(), &commands) { commands.extend(crate::diagram::insertion_adjustments( &candidate, diagram, id, )); } if self.execute(Command::Batch(commands)) { self.active_diagram = diagram_target; self.selected = id; self.inspector_tab = crate::navigation::InspectorTab::Properties; self.refresh_draft(); if self.page == Page::Diagram { self.fit(); } self.status = "Element created · enter its engineering name and properties".into(); } } fn add_relation(&mut self) { self.workflow = Some(crate::workflow::Workflow::relationship( self.project(), self.selected, self.layer, )); } fn add_trace(&mut self) { match crate::workflow::Workflow::manual_trace(self.project(), self.selected) { Ok(workflow) => self.workflow = Some(workflow), Err(e) => self.status = e, } } fn proceed(&mut self, pending: Pending) -> Task { self.pending = None; match pending { Pending::Recent => self .recent_target .take() .map_or_else(Task::none, |path| self.load_from(path)), Pending::Recover(id) => self.load_checkpoint(id), Pending::HostedOpen => self.open_hosted(), Pending::New => { self.reset(Project::empty(), None); Task::none() } Pending::Demo => { self.reset(Project::demo(), None); Task::none() } Pending::Walkthrough => match crate::home::walkthrough_path() { Ok(path) => self.load_from(path), Err(error) => { self.status = error; Task::none() } }, Pending::Open => self.choose_file(FileOperation::Open), Pending::Close(id) => { self.retire_checkpoint(); if self.recovery.writing { self.checkpoint_close = Some(id); self.busy = true; Task::none() } else { window::close(id) } } } } fn choose_file(&mut self, operation: FileOperation) -> Task { if cfg!(target_os = "linux") || operation == FileOperation::Export { self.file_prompt = Some(FilePrompt { operation, path: self .path .clone() .unwrap_or_else(|| { PathBuf::from(if operation == FileOperation::Export { "architecture.svg" } else { "system.arcadia" }) }) .with_extension(if operation == FileOperation::Export { "svg" } else { "arcadia" }) .to_string_lossy() .into_owned(), overwrite: false, }); return Task::none(); } self.busy = true; if operation == FileOperation::Open { return Task::perform( async { let file = rfd::AsyncFileDialog::new() .add_filter("Arcadia model", &["arcadia", "json"]) .pick_file() .await; match file { None => Ok(None), Some(file) => { let path = file.path().to_path_buf(); tokio::task::spawn_blocking(move || read_snapshot(path).map(Some)) .await .map_err(|e| e.to_string())? } } }, Message::Loaded, ); } let name = self.source_workspace.entry.clone(); Task::perform( async move { let file = rfd::AsyncFileDialog::new() .add_filter("Arcadia source", &["arcadia"]) .add_filter("Model JSON", &["json"]) .set_file_name(name) .save_file() .await; file.map(|file| file.path().to_path_buf()) }, Message::SavePath, ) } fn load_from(&mut self, path: PathBuf) -> Task { self.busy = true; Task::perform( async move { tokio::task::spawn_blocking(move || read_snapshot(path).map(Some)) .await .map_err(|e| e.to_string())? }, Message::Loaded, ) } fn save_to(&mut self, path: PathBuf) -> Task { if !path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) { let result = path .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| "Choose a portable .arcadia entry file name.".to_owned()) .and_then(|entry| { self.document .check_save_entry(entry) .map_err(|e| e.to_string()) }); if let Err(error) = result { self.status = error; self.continue_after_save = false; return Task::none(); } } self.busy = true; let snapshot = self.document.snapshot(); let document = self.canvas_document; let revision = self.document.revision(); let previous = self.disk_project.clone(); let failed_target = path.clone(); Task::perform( async move { tokio::task::spawn_blocking(move || { write_snapshot(document, revision, snapshot, path, previous).map(Some) }) .await .map_err(|e| save_failure(document, &failed_target, e.to_string()))? }, Message::Saved, ) } fn export_to(&mut self, path: PathBuf) -> Task { let Some(diagram) = self.active_diagram else { self.status = "Create a diagram first.".into(); return Task::none(); }; let svg = crate::diagram::export_svg(self.project(), diagram); self.busy = true; Task::perform( async move { tokio::task::spawn_blocking(move || { std::fs::write(path, svg) .map(|()| true) .map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())? }, Message::Exported, ) } pub fn issue_count(&self) -> usize { self.model_issues.len() } fn open_source_project(&mut self, disk: DiskProject) { let pending_recovery = recovery_target(&disk); let path = disk.root.join(&disk.bundle.entry); let candidate = if !disk .diagnostics .iter() .any(|issue| issue.diagnostic.severity == oa_core::validation::Severity::Error) { Document::from_bundle(disk.reachable_bundle(), LIMITS) } else { Err(crate::document::DocumentError::Source( disk.diagnostics.clone(), )) }; match candidate { Ok(document) => { self.reset(document.project().clone(), Some(path)); self.document = document; self.source_workspace = Workspace::new(self.document.bundle(), true); self.source_workspace .discovered(&disk.bundle, disk.reachable.clone()); self.show_source_file(false); self.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(); self.page = Page::Validation; } else { self.status = "Source project opened. Included files and comments are preserved.".into(); } } Err(error) => { // Installing a local repair workspace changes its destination, // even while the last valid graphical model stays visible. A // failed/cancelled Open that installs nothing retains its binding. self.hosted.leave_document(); self.canvas_document = Id::new_v4(); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.path = Some(path); self.document.bind_repair_baseline(disk.reachable_bundle()); self.source_workspace = Workspace::new(&disk.bundle, false); self.source_workspace.set_reachable(disk.reachable.clone()); self.show_source_file(false); self.source_diagnostics = error.diagnostics().to_vec(); self.source_version += 1; self.lint_requested = None; self.page = Page::Text; self.refresh_draft(); self.status = "Source needs repair. Loaded drafts are retained; the graphical model is unchanged.".into(); } } self.pending_recovery = pending_recovery; self.disk_project = Some(disk); if self.source_workspace.applied { self.remember_local(); } } fn prune_closed_files(&mut self, prune_expectations: bool) { if let Some(disk) = &mut self.disk_project { let retain = |path: &String| { self.document.bundle().files.contains_key(path) || self.source_workspace.files.contains_key(path) }; disk.bundle.files.retain(|path, _| retain(path)); if prune_expectations { disk.expectations.retain(|path, _| retain(path)); disk.reachable.retain(retain); } } } fn jump_to_source(&mut self, location: SourceLocation) { let Some(target) = self.source_workspace.files.get(&location.path) else { self.status = format!( "{} has not been loaded. Use Load includes or create the missing file.", location.path ); return; }; let source = &target.text; let mut offset = location.span.start.min(source.len()); while !source.is_char_boundary(offset) { offset -= 1; } let prefix = &source[..offset]; let line = prefix.bytes().filter(|b| *b == b'\n').count(); let column = prefix .rfind('\n') .map_or(offset, |start| offset - start - 1); let departure = crate::source_navigation::selection::capture(&mut self.source_editor); self.source_workspace.switch(&location.path, departure); self.show_source_file(false); self.queue_source_reveal( text_editor::Cursor { position: text_editor::Position { line, column }, selection: None, }, true, ); } fn create_source_file(&mut self) -> Result<(), String> { let path = self.source_workspace.new_path.trim().to_owned(); if source_project::resolve_include("", &path).as_deref() != Ok(path.as_str()) { return Err("Use a relative portable path such as sa/functions.arcadia.".into()); } if self.source_workspace.files.contains_key(&path) { return Err("This file is already open.".into()); } if self.source_workspace.files.len() >= LIMITS.max_files { return Err("The project file limit has been reached.".into()); } let bundle = self.source_workspace.bundle(); let already_included = bundle.files.iter().any(|(p, t)| { source_project::inspect_includes(p, t, p == &bundle.entry, LIMITS) .is_ok_and(|includes| includes.iter().any(|include| include.target == path)) }); let mut changed = bundle.clone(); let mut changed_entry = None; if !already_included { if self.source_dirty() { return Err( "Apply current drafts first, or add an include for the new file in the entry." .into(), ); } let at = self.document.compiled().index.project_body.span.end; let entry = changed .files .get_mut(&changed.entry) .ok_or("The entry file is missing.")?; entry.insert_str( at, &format!( "\n include {};\n", serde_json::to_string(&path).map_err(|e| e.to_string())? ), ); changed_entry = Some(entry.clone()); } // All fallible preparation is complete. The intermediate entry buffer // is not displayed, so it must not receive the outgoing editor's cursor. let original_path = self.source_workspace.active.clone(); let departure = crate::source_navigation::selection::capture(&mut self.source_editor); if let Some(entry) = changed_entry { self.source_workspace.switch(&bundle.entry, departure); let cursor = if self.source_workspace.active == original_path { departure } else { self.source_workspace.files[&self.source_workspace.active].cursor }; self.source_workspace.edit(entry, cursor); } changed.files.insert(path.clone(), String::new()); self.source_workspace .discovered(&changed, changed.files.keys().cloned().collect()); let cursor = if self.source_workspace.active == original_path { departure } else { self.source_workspace.files[&self.source_workspace.active].cursor }; self.source_workspace.switch(&path, cursor); self.source_workspace.new_path.clear(); self.show_source_file(true); self.source_version += 1; self.status = "Source file created. Add declarations, then Apply all.".into(); Ok(()) } fn load_includes(&mut self) -> Task { let Some(disk) = self.disk_project.clone() else { self.status = "Save the source project first to establish its include directory. New files can be created here.".into(); return Task::none(); }; let overlays = self.source_workspace.bundle().files; let version = self.source_version; self.busy = true; Task::perform( async move { tokio::task::spawn_blocking(move || { source_files::discover_missing(&disk, &overlays, LIMITS) .map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())? }, move |result| Message::IncludesLoaded(version, result), ) } fn request_lint(&mut self) -> Task { self.lint_requested = Some(self.source_version); self.start_pending_lint() } fn start_pending_lint(&mut self) -> Task { if self.lint_running.is_some() { return Task::none(); } let Some(version) = self.lint_requested.take() else { return Task::none(); }; if version != self.source_version { return Task::none(); } self.lint_running = Some(version); let source = self.source_workspace.bundle(); Task::perform( async move { tokio::task::spawn_blocking(move || source_workspace::lint(&source)) .await .map_err(|error| error.to_string()) }, move |result| Message::Linted(version, result), ) } pub fn route_context(&self) -> Option { Some(crate::route_edit::Context { document: self.canvas_document, diagram: self.active_diagram?, revision: self.document.revision(), epoch: self.canvas_epoch, }) } fn edit_connection(&mut self, event: crate::connection_edit::Event) { use crate::connection_edit::{Event, Session}; let Some(context) = self.route_context() else { return; }; if matches!(event, Event::Choose(None)) { self.connection = None; self.status = "Select · drag elements, ports and connection routes to arrange the diagram".into(); return; } if self.page != Page::Diagram || self.source_dirty() || self.draft_dirty() { self.status = "Apply or revert pending edits before drawing a connection.".into(); return; } match event { Event::Begin(tool, source) => { self.edit_connection(Event::Choose(Some(tool))); if let Some(context) = self.route_context() { self.edit_connection(Event::Endpoint(context, source)); } } Event::Choose(Some(tool)) => { if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) { return; } self.canvas_epoch = self.canvas_epoch.wrapping_add(1); let context = self.route_context().unwrap(); self.connection = Some(Session::new( self.project(), &project_view(self.project(), context.diagram), context, tool, )); self.selected_bend = None; self.status = tool.hint().into(); } Event::Endpoint(captured, id) => { if captured != context { return; } let Some(mut session) = self.connection.take() else { return; }; if session.context != context { return; } if !session.candidates.contains(&id) { self.status = "Choose a highlighted compatible endpoint · Esc cancels drawing".into(); self.connection = Some(session); return; } if let Some(source) = session.source { match session.tool.command(self.project(), source, id) { Ok(command) => { if self.execute(command) { self.selected_node = None; self.inspector_tab = Default::default(); self.status = format!( "{} created · edit its name and carried items in Properties · Undo restores the complete operation", session.tool.label() ); } else { self.connection = Some(session); } } Err(error) => { self.status = error.to_string(); self.connection = Some(session); } } } else { session.start( self.project(), &project_view(self.project(), context.diagram), id, ); self.select(id); self.status = if session.candidates.is_empty() { "No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into() } else { format!( "From {} · choose a highlighted target · Esc cancels", self.project().named(id) ) }; self.connection = Some(session); } } Event::Restart(captured) => { if captured == context && let Some(session) = &self.connection { self.edit_connection(Event::Choose(Some(session.tool))); } } Event::Choose(None) => {} } } fn edit_route(&mut self, event: crate::route_edit::Event) { use crate::route_edit::{Event, insert_pin, stored_route}; if self.connection.is_some() { return; } let Some(context) = self.route_context() else { return; }; if self.source_dirty() || self.draft_dirty() { self.status = "Apply or revert your draft before editing a connection route.".into(); return; } let diagram = &self.project().diagrams[&context.diagram]; let view = project_view(self.project(), context.diagram); let Some(projected_edge) = view.edges.iter().find(|e| e.id == self.selected) else { return; }; if projection::edge_endpoint_placements(self.project(), diagram, self.selected).is_some_and( |(source, target)| source != projected_edge.source || target != projected_edge.target, ) { self.status = "Expand the endpoint containers to edit this connection’s saved route.".into(); return; } let mut route = stored_route(diagram, self.selected); let mut selected_bend = self .selected_bend .filter(|(id, _)| *id == self.selected) .map(|(_, i)| i); let command = match event { Event::SelectBend(id, index) => { if id == self.selected && index < route.waypoints.len() { self.selected_bend = Some((id, index)); } return; } Event::Commit(expected, changed, bend) => { if expected != context || changed.edge != self.selected { self.status = "Route gesture cancelled because the model or view changed.".into(); return; } route = changed; selected_bend = bend; Command::SetEdgeRoute { diagram: context.diagram, route, } } Event::AddBend | Event::AddBendAt(_) => { if route.waypoints.len() >= 128 { return; } let geometry = oa_core::routing::route_diagram(self.project(), diagram, &view); let edge = &geometry.edges[&self.selected]; // Mandatory port leads are inside the card's clearance. A pin // starts beyond those leads, including coincident short stubs. let segment = edge .points .windows(2) .enumerate() .filter(|(index, _)| *index > 0 && index + 2 < edge.points.len()) .map(|(_, span)| span) .max_by(|a, b| { let len = |s: &[Position]| (s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs(); len(a).total_cmp(&len(b)) }); let point = if let Event::AddBendAt(point) = event { if !point.x.is_finite() || !point.y.is_finite() { return; } point } else if let Some(segment) = segment { Position { x: (segment[0].x + segment[1].x) / 2., y: (segment[0].y + segment[1].y) / 2., } } else if let Some(stub) = edge.points.get(1) { *stub } else { return; }; selected_bend = Some(insert_pin(&mut route, edge, point)); Command::SetEdgeRoute { diagram: context.diagram, route, } } Event::RemoveBend => { let Some(index) = selected_bend.filter(|i| *i < route.waypoints.len()) else { return; }; route.waypoints.remove(index); selected_bend = None; Command::SetEdgeRoute { diagram: context.diagram, route, } } Event::Reset => { selected_bend = None; Command::RemoveEdgeRoute { diagram: context.diagram, edge: self.selected, } } }; let selected = self.selected; if self.execute(command) { self.selected = selected; self.selected_bend = selected_bend.map(|index| (selected, index)); self.refresh_draft(); self.status = "Connection route updated · Undo restores the previous route".into(); } } pub fn fit(&mut self) { self.fit_to_view = true; let view = self .active_diagram .map(|id| project_view(self.project(), id)) .unwrap_or_default(); if view.nodes.is_empty() { self.zoom = 1.; self.pan = iced::Vector::new(30., 30.); return; } let geometry = oa_core::routing::route_diagram( self.project(), &self.project().diagrams[&self.active_diagram.unwrap()], &view, ); let left = geometry.bounds.position.x; let top = geometry.bounds.position.y; let right = left + geometry.bounds.size.width; let bottom = top + geometry.bounds.size.height; let size = self.canvas_size.unwrap_or_else(|| { iced::Size::new( self.window_size.width - 230. - 320., self.window_size.height - 284., ) }); let width = (size.width - 48.).max(1.); let height = (size.height - 48.).max(1.); self.zoom = (width / (right - left).max(1.)) .min(height / (bottom - top).max(1.)) .clamp(0.0001, 1.5); self.pan = iced::Vector::new( 24. + (width - (right - left) * self.zoom) / 2. - left * self.zoom, 24. + (height - (bottom - top) * self.zoom) / 2. - top * self.zoom, ); } } fn preferred_diagram(p: &Project, active: Option, layer: Layer, element: Id) -> Option { if let Some(owner) = p .elements .get(&element) .filter(|e| e.kind() == Kind::Port) .and_then(|e| e.parent) { return p .diagrams .values() .filter(|d| d.layer == layer) .min_by_key(|d| { let view = project_view(p, d.id); ( !d.projection.includes(p.elements[&owner].kind()), !view.ports.iter().any(|port| port.element == element), !d.nodes.values().any(|node| node.element == owner), active != Some(d.id), ) }) .map(|d| d.id); } p.diagrams .values() .filter(|d| d.layer == layer) .min_by_key(|d| { ( !p.elements .get(&element) .is_none_or(|e| d.projection.includes(e.kind())), !d.nodes.values().any(|n| n.element == element), d.projection != ProjectionKind::Allocation, ) }) .map(|d| d.id) } fn reveal_port_owner(p: &mut Project, diagram: Id, element: Id) { let Some(owner) = p .elements .get(&element) .filter(|e| e.kind() == Kind::Port) .and_then(|e| e.parent) else { return; }; let Some(owner_node) = p.diagrams[&diagram] .nodes .values() .find(|n| n.element == owner) .map(|n| n.id) else { return; }; let unfold: Vec<_> = p.diagrams[&diagram] .nodes .values() .filter(|n| { n.collapsed && projection::descendant_nodes(p, diagram, n.id).contains(&owner_node) }) .map(|n| n.id) .collect(); for id in unfold { p.diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&id) .unwrap() .collapsed = false; } } fn write_snapshot( document: Id, revision: u64, snapshot: Arc, path: PathBuf, previous: Option, ) -> Result { let target = path.clone(); write_snapshot_inner(document, revision, snapshot, path, previous) .map_err(|message| save_failure(document, &target, message)) } fn save_failure(document: Id, path: &std::path::Path, message: String) -> SaveFailure { // Discovery is read-only, bounded and checks physical source ownership. Do // not infer journal names from an error string or recover inside a save. let recovery = path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia")) .then(|| source_files::load_project(path, LIMITS).ok()) .flatten() .and_then(|disk| recovery_target(&disk)); SaveFailure { document, message, recovery, } } fn recovery_target(disk: &DiskProject) -> Option { disk.diagnostics .iter() .any(|issue| issue.diagnostic.code == "pending_save") .then(|| disk.root.join(&disk.bundle.entry)) } fn recover_snapshot(target: PathBuf) -> Result { if !source_files::recover_project(&target, LIMITS).map_err(|e| e.to_string())? { return Err(format!( "No pending save was found at {}. Your model and drafts are retained.", target.display() )); } let disk = source_files::load_project(&target, LIMITS).map_err(|e| e.to_string()); Ok(RecoverySnapshot { target, disk }) } fn write_snapshot_inner( document: Id, revision: u64, mut snapshot: Arc, path: PathBuf, previous: Option, ) -> Result { let is_json = path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("json")); let disk = if is_json { if path.exists() { return Err( "The JSON destination already exists. Choose a new file to preserve it.".into(), ); } persistence::save_checked(&path, &snapshot.decoded.project, None) .map_err(|e| e.to_string())?; None } else { let entry = path .file_name() .and_then(|p| p.to_str()) .ok_or("Choose a portable .arcadia entry file name.")?; if source_project::resolve_include("", entry).as_deref() != Ok(entry) { return Err("Choose a portable .arcadia entry file name.".into()); } if entry != snapshot.bundle.entry { let mut bundle = snapshot.bundle.clone(); if bundle.files.contains_key(entry) { return Err("That entry name is already used by an included file.".into()); } let text = bundle .files .remove(&bundle.entry) .ok_or("The accepted entry is missing.")?; bundle.entry = entry.into(); bundle.files.insert(entry.into(), text); snapshot = Arc::new( source_project::compile_bundle(&bundle, LIMITS) .map_err(|errors| crate::document::DocumentError::Source(errors).to_string())?, ); } let previous = previous.filter(|disk| { path.parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or(std::path::Path::new(".")) .canonicalize() .ok() .as_ref() == Some(&disk.root) && entry == disk.bundle.entry }); let mut disk = match previous { Some(disk) => disk, None => source_files::new_project(&path, LIMITS).map_err(|e| e.to_string())?, }; for path in snapshot.bundle.files.keys() { if !disk.expectations.contains_key(path) { source_files::expect_new_file(&mut disk, path, LIMITS) .map_err(|e| e.to_string())?; } } Some(source_files::save_project(&disk, &snapshot, LIMITS).map_err(|e| e.to_string())?) }; Ok(FileSnapshot { document, revision, snapshot, disk, path, }) } fn read_snapshot(path: PathBuf) -> Result { use std::io::Read; let is_source = path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia")); if is_source { // The entry is written last. An interrupted first save can therefore // have a valid journal without an entry file to open yet. let disk = source_files::load_project(&path, LIMITS).map_err(|e| e.to_string())?; if recovery_target(&disk).is_none() && let Some(source_files::DiskExpectation::Bytes(bytes)) = disk.expectations.get(&disk.bundle.entry) && let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model) // Source1/2 require the historical single-document adapters. // Source3+ must retain its authored bundle and token migration. && decoded.migration.as_ref().is_some_and(|report| report.from_schema <= 2) { return Ok(LoadedSnapshot::Legacy( path, Box::new(decoded), Arc::from(bytes.clone()), )); } return Ok(LoadedSnapshot::Source(disk)); } if !std::fs::symlink_metadata(&path) .map_err(|e| e.to_string())? .is_file() { return Err("Open a regular model file; links and special files are not supported.".into()); } let mut bytes = Vec::new(); std::fs::File::open(&path) .map_err(|e| e.to_string())? .take(LIMITS.model.max_source_bytes as u64 + 1) .read_to_end(&mut bytes) .map_err(|e| e.to_string())?; if bytes.len() > LIMITS.model.max_source_bytes { return Err("The model exceeds the source byte limit.".into()); } let decoded = persistence::decode_with_report_limited(&bytes, LIMITS.model).map_err(|e| e.to_string())?; Ok(LoadedSnapshot::Legacy( path, Box::new(decoded), Arc::from(bytes), )) } #[cfg(test)] #[path = "app/save_as_tests.rs"] mod save_as_tests; #[cfg(test)] #[path = "app/repair_save_state_tests.rs"] mod repair_save_state_tests; #[cfg(test)] mod source_project_tests { use super::*; struct Directory(PathBuf); impl Directory { fn new() -> Self { let path = std::env::temp_dir().join(format!("arcadia-source-ui-{}", Id::new_v4())); std::fs::create_dir(&path).unwrap(); Self(path.canonicalize().unwrap()) } } impl Drop for Directory { fn drop(&mut self) { let temp = std::env::temp_dir().canonicalize().unwrap(); assert_eq!(self.0.parent(), Some(temp.as_path())); assert!( self.0 .file_name() .unwrap() .to_string_lossy() .starts_with("arcadia-source-ui-") ); std::fs::remove_dir_all(&self.0).unwrap(); } } fn send(app: &mut App, message: Message) { let _ = app.update(message); } fn edit(app: &mut App, text: &str) { send(app, Message::SourceEdit(text_editor::Action::SelectAll)); send( app, Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste( Arc::new(text.into()), ))), ); } fn save(app: &mut App, path: PathBuf) -> Result<(), String> { let receipt = write_snapshot( app.canvas_document, app.document.revision(), app.document.snapshot(), path, app.disk_project.clone(), ) .map_err(|error| error.message)?; send(app, Message::Saved(Ok(Some(receipt)))); Ok(()) } #[test] fn source3_native_open_preserves_report_comments_nesting_and_save_reopen() { let original = include_str!("../../oa-core/tests/fixtures/v3-data-contract.arcadia"); for split in [false, true] { let directory = Directory::new(); let path = directory.0.join("project.arcadia"); let mut authored = std::collections::BTreeMap::new(); if split { let start = original.find("{\n").unwrap() + 2; let end = original.rfind('}').unwrap(); authored.insert( "project.arcadia", format!( "{} include \"dictionary.arcadia\";\n}}\n", &original[..start] ), ); authored.insert("dictionary.arcadia", original[start..end].to_owned()); } else { authored.insert("project.arcadia", original.to_owned()); } for (file, source) in &authored { std::fs::write(directory.0.join(file), source).unwrap(); } let mut app = App::with_project(Project::empty()); send( &mut app, Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))), ); assert_eq!(app.migration.as_ref().unwrap().from_schema, 3); assert_eq!(app.page, Page::Validation); assert_eq!(app.path, Some(path.clone())); assert_eq!(app.document.bundle().files.len(), authored.len()); assert!( app.dirty(), "accepted source migration needs explicit saving" ); for (file, source) in &authored { assert_eq!( std::fs::read_to_string(directory.0.join(file)).unwrap(), *source ); } let text = app .document .bundle() .files .values() .cloned() .collect::(); assert!(text.contains("// Reused record parameters retain their own identities.")); assert!(text.contains("// Frozen authored schema3 compatibility input.")); let packet = app .project() .elements .values() .find(|e| e.alias == "packet") .unwrap() .id; let origin = app .project() .elements .values() .find(|e| e.alias == "origin") .unwrap() .id; assert_eq!( app.document.compiled().index.lexical_owners.get(&origin), Some(&packet) ); let before = app.document.snapshot(); save(&mut app, path.clone()).unwrap(); assert!(!app.dirty()); for (file, source) in &before.bundle.files { assert_eq!( std::fs::read_to_string(directory.0.join(file)).unwrap(), *source ); } let mut reopened = App::with_project(Project::empty()); send( &mut reopened, Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))), ); assert!(reopened.migration.is_none()); assert_eq!(reopened.project(), app.project()); assert_eq!(reopened.document.bundle(), app.document.bundle()); assert!(!reopened.dirty()); } } fn pending_journal(directory: &Directory, before: Option<&str>) -> (PathBuf, PathBuf) { let path = directory.0.join("project.arcadia"); let journal = directory.0.join(".arcadia-save-project.arcadia.journal"); std::fs::create_dir(&journal).unwrap(); let metadata = json!({"version":1,"entry":"project.arcadia","files":[{ "path":"project.arcadia","before":before.is_some(),"write":true }]}); std::fs::write( journal.join("journal.json"), serde_json::to_vec(&metadata).unwrap(), ) .unwrap(); if let Some(before) = before { std::fs::write(&path, before).unwrap(); std::fs::write(journal.join("0.before"), before).unwrap(); } std::fs::write( journal.join("0.after"), "arcadia 3; project p \"After\" {}\n", ) .unwrap(); (path, journal) } #[test] fn recovery_open_finds_first_save_journal_without_an_entry_file() { let directory = Directory::new(); let (path, journal) = pending_journal(&directory, None); let mut app = App::with_project(Project::empty()); let before = app.document.snapshot(); send( &mut app, Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))), ); assert_eq!(app.pending_recovery.as_ref(), Some(&path)); assert_eq!(app.page, Page::Text); assert!(!app.source_workspace.applied); assert!(Arc::ptr_eq(&before, &app.document.snapshot())); assert!(journal.exists()); send(&mut app, Message::SourceRecover); assert!(app.busy); send( &mut app, Message::SourceRecovered(recover_snapshot(path.clone())), ); assert!(app.pending_recovery.is_none()); assert!(!journal.exists()); assert!(!path.exists()); assert!(Arc::ptr_eq(&before, &app.document.snapshot())); } #[test] fn recovery_after_first_save_failure_retains_target_then_retries_without_reentering_path() { let directory = Directory::new(); let (path, journal) = pending_journal(&directory, None); let mut app = App::with_project(Project::empty()); let before = app.document.snapshot(); let error = write_snapshot( app.canvas_document, app.document.revision(), app.document.snapshot(), path.clone(), None, ) .unwrap_err(); assert_eq!(error.recovery.as_ref(), Some(&path)); send(&mut app, Message::Saved(Err(error))); assert!(app.path.is_none()); assert_eq!(app.pending_recovery.as_ref(), Some(&path)); assert_eq!(app.page, Page::Text); send(&mut app, Message::SourceRecover); assert!( app.busy, "Recovery must use its own target when App.path is absent" ); send( &mut app, Message::SourceRecovered(recover_snapshot(path.clone())), ); assert_eq!(app.path.as_ref(), Some(&path)); assert!(app.pending_recovery.is_none()); assert!(!journal.exists()); assert!(Arc::ptr_eq(&before, &app.document.snapshot())); save(&mut app, path.clone()).unwrap(); assert!(path.is_file()); assert!(!app.dirty()); } #[test] fn recovery_target_survives_edit_lint_apply_and_rejected_recovery_until_success() { let directory = Directory::new(); let initial = "arcadia 3; project p \"Before\" {}\n"; let (path, journal) = pending_journal(&directory, Some(initial)); let mut app = App::with_project(Project::empty()); send( &mut app, Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))), ); edit(&mut app, &format!("{initial}// Added rationale\n")); assert!(app.source_diagnostics.is_empty()); assert_eq!(app.pending_recovery.as_ref(), Some(&path)); let version = app.source_version; send(&mut app, Message::Lint(version)); let diagnostics = source_workspace::lint(&app.source_workspace.bundle()); send(&mut app, Message::Linted(version, Ok(diagnostics))); assert_eq!(app.pending_recovery.as_ref(), Some(&path)); assert!(app.apply_source()); let accepted = app.document.snapshot(); assert_eq!(app.pending_recovery.as_ref(), Some(&path)); std::fs::write(&path, "External file edit that must survive").unwrap(); send( &mut app, Message::SourceRecovered(recover_snapshot(path.clone())), ); assert_eq!(app.pending_recovery.as_ref(), Some(&path)); assert!(journal.exists()); assert_eq!( std::fs::read_to_string(&path).unwrap(), "External file edit that must survive" ); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); std::fs::write(&path, initial).unwrap(); send( &mut app, Message::SourceRecovered(recover_snapshot(path.clone())), ); assert!(app.pending_recovery.is_none()); assert!(!journal.exists()); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); assert!(app.source_editor.text().contains("Added rationale")); } #[test] fn recovery_of_previous_save_as_target_does_not_redirect_current_project() { let directory = Directory::new(); let (target, journal) = pending_journal(&directory, None); let mut app = App::with_project(Project::empty()); let error = write_snapshot( app.canvas_document, app.document.revision(), app.document.snapshot(), target.clone(), None, ) .unwrap_err(); send(&mut app, Message::Saved(Err(error))); let destination = directory.0.join("another.arcadia"); save(&mut app, destination.clone()).unwrap(); assert_eq!(app.pending_recovery.as_ref(), Some(&target)); let saved = app.document.snapshot(); send(&mut app, Message::SourceRecovered(recover_snapshot(target))); assert!(app.pending_recovery.is_none()); assert!(!journal.exists()); assert_eq!(app.path.as_ref(), Some(&destination)); assert_eq!( app.disk_project.as_ref().unwrap().bundle.entry, "another.arcadia" ); assert!(Arc::ptr_eq(&saved, &app.document.snapshot())); save(&mut app, destination).unwrap(); } #[test] fn two_file_apply_graphical_edit_exact_undo_save_as_and_reopen() { let directory = Directory::new(); let mut project = Project::empty(); let activity = project .add_element(Kind::Activity, Layer::Oa, "Assess incident", None) .unwrap(); let mut app = App::with_project(project); let fragment = app.document.compiled().index.declarations[&activity] .path .clone(); send(&mut app, Message::SourceFile(fragment.clone())); let edited = format!( "// Engineering rationale: preserve this comment.\n{}", app.source_editor .text() .replace("Assess incident", "Review incident") ); edit(&mut app, &edited); let entry = app.source_workspace.entry.clone(); send(&mut app, Message::SourceFile(entry.clone())); let entry_text = format!("{}\n// Project rationale.\n", app.source_editor.text()); edit(&mut app, &entry_text); assert!(app.apply_source(), "{}", app.status); let accepted = app.document.snapshot(); send( &mut app, Message::Command(Command::Rename(activity, "Evaluate incident".into())), ); assert_eq!(app.project().elements[&activity].name, "Evaluate incident"); assert!(app.document.bundle().files[&fragment].starts_with("// Engineering rationale")); send(&mut app, Message::Undo); assert_eq!(app.document.bundle(), &accepted.bundle); send(&mut app, Message::Redo); let model = app.project().clone(); let path = directory.0.join("study.arcadia"); save(&mut app, path.clone()).unwrap(); assert!(!app.dirty()); assert_eq!(app.source_workspace.entry, "study.arcadia"); assert!(!app.source_workspace.files.contains_key(&entry)); let mut reopened = App::with_project(Project::empty()); send( &mut reopened, Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))), ); assert_eq!( reopened.project(), &model, "{}: {:?}", reopened.status, reopened.source_diagnostics ); assert_eq!(reopened.document.bundle(), app.document.bundle()); assert!(!reopened.dirty()); } #[test] fn inactive_invalid_draft_blocks_graphical_changes_and_diagnostic_switches_files() { let mut project = Project::empty(); let activity = project .add_element(Kind::Activity, Layer::Oa, "Assess incident", None) .unwrap(); let mut app = App::with_project(project.clone()); let fragment = app.document.compiled().index.declarations[&activity] .path .clone(); send(&mut app, Message::SourceFile(fragment.clone())); edit(&mut app, "? invalid source"); let entry = app.source_workspace.entry.clone(); send(&mut app, Message::SourceFile(entry)); send(&mut app, Message::Page(Page::Diagram)); send( &mut app, Message::Command(Command::Rename(activity, "Must not happen".into())), ); assert_eq!(app.project(), &project); assert!(!app.apply_source()); assert_eq!(app.source_diagnostics[0].path, fragment); send(&mut app, Message::SourceDiagnostic(0)); assert_eq!(app.source_workspace.active, fragment); assert_eq!(app.source_editor.text(), "? invalid source"); send(&mut app, Message::RevertSource); assert!(!app.source_dirty()); assert_eq!(app.project(), &project); } #[test] fn missing_include_open_keeps_old_model_and_can_be_repaired_and_saved() { let directory = Directory::new(); let compiled = source_project::compile_bundle( &source_project::format_bundle(&Project::empty()), LIMITS, ) .unwrap(); let mut entry = compiled.bundle.files[&compiled.bundle.entry].clone(); entry.insert_str( compiled.index.project_body.span.end, "\n include \"missing.arcadia\";\n", ); let path = directory.0.join(&compiled.bundle.entry); std::fs::write(&path, &entry).unwrap(); let mut app = App::with_project(Project::demo()); let original = app.project().clone(); send( &mut app, Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))), ); assert_eq!(app.project(), &original); assert!(!app.source_workspace.applied); assert_eq!(app.source_editor.text(), entry); send(&mut app, Message::SourceNewPath("missing.arcadia".into())); send(&mut app, Message::SourceCreateFile); assert!(app.apply_source(), "{}", app.status); assert_eq!(app.project(), &compiled.decoded.project); save(&mut app, path).unwrap(); assert_eq!( std::fs::read(directory.0.join("missing.arcadia")).unwrap(), Vec::::new() ); assert!(!app.dirty()); } #[test] fn closed_detached_file_keeps_first_save_conflict_then_can_be_reloaded_fresh() { let directory = Directory::new(); let mut app = App::with_project(Project::empty()); send(&mut app, Message::SourceNewPath("spare.arcadia".into())); send(&mut app, Message::SourceCreateFile); assert!(app.apply_source()); let path = directory.0.join("project.arcadia"); save(&mut app, path.clone()).unwrap(); send(&mut app, Message::SourceFile("project.arcadia".into())); let removed = app .source_editor .text() .replace(" include \"spare.arcadia\";\n", ""); edit(&mut app, &removed); assert!(app.apply_source()); send(&mut app, Message::SourceCloseFile("spare.arcadia".into())); assert!(!app.source_workspace.files.contains_key("spare.arcadia")); assert!( app.disk_project .as_ref() .unwrap() .expectations .contains_key("spare.arcadia") ); std::fs::write(directory.0.join("spare.arcadia"), "// external edit\n").unwrap(); assert!(save(&mut app, path.clone()).is_err()); assert!( std::fs::read_to_string(&path) .unwrap() .contains("include \"spare.arcadia\"") ); std::fs::write(directory.0.join("spare.arcadia"), "").unwrap(); save(&mut app, path.clone()).unwrap(); assert!( !app.disk_project .as_ref() .unwrap() .expectations .contains_key("spare.arcadia") ); std::fs::write(directory.0.join("spare.arcadia"), "// new external text\n").unwrap(); let id = app.project().id; send( &mut app, Message::Command(Command::Rename(id, "New project name".into())), ); save(&mut app, path).unwrap(); let mut entry = app.source_editor.text(); entry.insert_str( app.document.compiled().index.project_body.span.end, "\n include \"spare.arcadia\";\n", ); edit(&mut app, &entry); let disk = source_files::discover_missing( app.disk_project.as_ref().unwrap(), &app.source_workspace.bundle().files, LIMITS, ) .unwrap(); let version = app.source_version; send(&mut app, Message::IncludesLoaded(version, Ok(disk))); assert_eq!( app.source_workspace.files["spare.arcadia"].text, "// new external text\n" ); assert!(app.apply_source()); } #[test] fn json_export_and_rejected_save_as_preserve_source_location_history_and_bytes() { let directory = Directory::new(); let mut app = App::with_project(Project::empty()); let id = app.project().id; send( &mut app, Message::Command(Command::Rename(id, "Unsaved model".into())), ); let before = app.document.snapshot(); let revision = app.document.revision(); save(&mut app, directory.0.join("export.json")).unwrap(); assert!(app.path.is_none()); assert!(app.dirty()); assert!(Arc::ptr_eq(&before, &app.document.snapshot())); let path = directory.0.join("collision.arcadia"); std::fs::write(&path, "Preserve existing file").unwrap(); assert!(save(&mut app, path.clone()).is_err()); assert_eq!( std::fs::read_to_string(path).unwrap(), "Preserve existing file" ); assert_eq!(app.document.revision(), revision); assert!(Arc::ptr_eq(&before, &app.document.snapshot())); } } #[cfg(test)] mod port_view_tests { use super::*; #[test] fn catalog_creation_follows_the_new_port_and_keeps_group_browsing_explicit() { let mut project = Project::empty(); let actor = project .add_element(Kind::SystemActor, Layer::Sa, "External operator", None) .unwrap(); let mut app = App::with_project(project); let _ = app.update(Message::Explore(Some(actor))); app.explorer_page = 3; app.inspector_tab = crate::navigation::InspectorTab::Connections; let _ = app.update(Message::PortAction(crate::port_workflow::Action::Component)); let _ = app.update(Message::Workflow(crate::workflow::Event::Apply)); assert_eq!(app.page, Page::Catalog); assert_eq!(app.project().elements[&app.selected].parent, Some(actor)); assert_eq!(app.explorer_focus, Some(app.selected)); assert_eq!(app.explorer_group, app.explorer_index.group(app.selected)); assert_eq!(app.explorer_page, 0); assert_eq!( app.inspector_tab, crate::navigation::InspectorTab::Properties ); let selected = app.selected; let group = app.explorer_group.unwrap(); let _ = app.update(Message::BrowseGroup(group)); assert_eq!(app.selected, selected); assert_eq!(app.explorer_group, Some(group)); assert!(app.explorer_focus.is_none()); let _ = app.update(Message::Page(Page::Guide)); let _ = app.update(Message::Page(Page::Catalog)); assert_eq!(app.explorer_focus, Some(selected)); assert_eq!(app.explorer_page, 0); } #[test] fn filtered_active_view_cannot_hide_a_created_port_when_eligible_owner_view_is_folded() { let mut p = Project::empty(); let system = p .add_element(Kind::System, Layer::Sa, "System", None) .unwrap(); let function = p .add_element(Kind::Function, Layer::Sa, "Function", None) .unwrap(); p.relate(RelationKind::Allocation, "Performs", system, function); let architecture = p.ensure_diagram(Layer::Sa); p.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation; let container = p.place(architecture, system, Position { x: 40., y: 40. }); p.place(architecture, function, Position { x: 70., y: 130. }); let capabilities = Id::new_v4(); p.diagrams.insert( capabilities, Diagram { id: capabilities, alias: "filtered_view".into(), name: "Filtered view".into(), description: String::new(), layer: Layer::Sa, projection: ProjectionKind::Capabilities, routes: Vec::new(), view_options: Default::default(), nodes: Default::default(), }, ); p.place(capabilities, system, Position { x: 40., y: 40. }); p.place(capabilities, function, Position { x: 70., y: 130. }); p.diagrams .get_mut(&architecture) .unwrap() .nodes .get_mut(&container) .unwrap() .collapsed = true; let mut app = App::with_project(p.clone()); let _ = app.update(Message::Select(function)); app.active_diagram = Some(capabilities); app.refresh_draft(); app.inspector_tab = crate::navigation::InspectorTab::Connections; let _ = app.update(Message::PortAction(crate::port_workflow::Action::Output)); let _ = app.update(Message::Workflow(crate::workflow::Event::Apply)); assert!(app.workflow.is_none(), "{}", app.status); assert_eq!( app.inspector_tab, crate::navigation::InspectorTab::Properties ); assert_eq!(app.active_diagram, Some(architecture)); assert!( project_view(app.project(), architecture) .ports .iter() .any(|port| port.element == app.selected) ); assert!(!app.project().diagrams[&architecture].nodes[&container].collapsed); let _ = app.update(Message::Undo); assert_eq!( app.project(), &p, "Creation and the eligible owner reveal remain one transaction" ); } } #[cfg(test)] mod lint_tests { use super::*; fn edit(app: &mut App, value: &str) { let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll)); let _ = app.update(Message::SourceEdit(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(value.into())), ))); } #[test] fn lint_coalesces_work_and_rejects_obsolete_results() { let mut app = App::with_project(Project::empty()); let _ = app.request_lint(); let first = app.source_version; edit(&mut app, "?"); let latest = app.source_version; let _ = app.update(Message::Lint(latest)); assert_eq!(app.lint_running, Some(first)); assert_eq!(app.lint_requested, Some(latest)); let errors = source_workspace::lint(&app.source_workspace.bundle()); let _ = app.update(Message::Linted(first, Ok(errors.clone()))); assert!(app.source_diagnostics.is_empty()); assert_eq!(app.lint_running, Some(latest)); let _ = app.update(Message::Linted(latest, Ok(errors.clone()))); assert_eq!(app.source_diagnostics, errors); assert_eq!(app.lint_running, None); assert_eq!(app.source_editor.text(), "?"); } #[test] fn prior_document_lint_cannot_enter_reopened_model_or_block_next_checks() { let mut app = App::with_project(Project::empty()); edit(&mut app, "?"); let _ = app.request_lint(); let old_version = app.source_version; app.reset(Project::empty(), None); assert!(app.source_version > old_version); let _ = app.request_lint(); app.busy = true; let _ = app.update(Message::Linted( old_version, Ok(source_workspace::lint(&app.source_workspace.bundle())), )); assert!(app.source_diagnostics.is_empty()); assert_eq!(app.lint_running, Some(app.source_version)); let _ = app.update(Message::Linted(app.source_version, Ok(vec![]))); assert_eq!(app.lint_running, None); } #[test] fn committed_check_cache_follows_edit_and_undo_while_drafts_stay_separate() { let mut app = App::with_project(Project::empty()); let original = app.issue_count(); let element = app .project() .make_element(Kind::Function, Layer::Sa, "Assess request", None) .unwrap(); let _ = app.update(Message::Command(Command::CreateElement(element))); assert!( app.model_issues .iter() .any(|issue| issue.code == "missing_system") ); assert_eq!(app.issue_count(), validate(app.project()).len()); let after = app.issue_count(); edit(&mut app, "?"); assert_eq!(app.issue_count(), after); let _ = app.update(Message::RevertSource); let _ = app.update(Message::Undo); assert_eq!(app.issue_count(), original); assert!(app.project().elements.is_empty()); } } #[cfg(test)] mod data_paging_lifecycle_tests { use super::*; #[test] fn paging_preserves_same_context_and_resets_on_layer_or_document_replacement() { let mut p = Project::empty(); let mut oa = None; for layer in [Layer::Oa, Layer::Sa] { for index in 0..70 { let id = p .add_element( Kind::Interface, layer, &format!("Interface {layer} {index}"), None, ) .unwrap(); if layer == Layer::Oa { oa = Some(id); } } } let mut app = App::with_project(p.clone()); let page = Message::Data(crate::data_view::Event::Page { section: crate::data_view::Section::Catalog, owner: None, page: 2, }); let _ = app.update(Message::Page(Page::Data)); let _ = app.update(page.clone()); let retained = app.data_state.clone(); let _ = app.update(Message::Page(Page::Catalog)); let _ = app.update(Message::Page(Page::Data)); let _ = app.update(Message::Layer(Layer::Oa)); assert_eq!(app.data_state, retained); let _ = app.update(Message::Layer(Layer::Sa)); assert_eq!(app.data_state, crate::data_view::State::default()); let _ = app.update(page.clone()); let _ = app.update(Message::Select(oa.unwrap())); assert_eq!(app.layer, Layer::Oa); assert_eq!(app.data_state, crate::data_view::State::default()); let _ = app.update(page); app.reset(p, None); assert_eq!(app.data_state, crate::data_view::State::default()); } }