use crate::forms::Draft; use iced::{Element, Subscription, Task, keyboard, widget::text_editor, window}; use oa_core::{projection::project_view, validation::validate, *}; use serde_json::{Value, json}; use std::{collections::BTreeSet, path::PathBuf, sync::Arc}; type FileSnapshot = (PathBuf, Project, Arc<[u8]>); type LoadedSnapshot = (PathBuf, persistence::DecodeResult, Arc<[u8]>); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { 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 { New, Demo, 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 { ExactStrings(bool), Page(Page), Guide(crate::guide::Event), Behavior(crate::behavior::Event), Layer(Layer), Select(Id), EditProperties(Id), SelectNode(Id), SelectProject, Search(String), StartTransition, Workflow(crate::workflow::Event), CollapseExplorer(Id), CreateKind(Kind), Add(bool), AddRelation, PortAction(crate::port_workflow::Action), AddRelationKind(RelationKind, bool), InspectorTab(crate::navigation::InspectorTab), RevealInDiagram(Id, Id), AddDiagram, AddArtifact, AddTrace, Field(String, String), FieldText(String, text_editor::Action), FieldShape(String, Value), FieldVariant(String, Value), FieldArray(String, Option, i32), Apply, RevertProperties, ElementKind(Kind), RelationKind(RelationKind), Command(Command), Delete, ConfirmDelete, CancelDelete, Undo, Redo, Diagram(Choice), Projection(ProjectionKind), PlaceSelected, HideSelected, ToggleCollapse, MoveNode(Id, Position), Zoom(f32), Pan(iced::Vector), Fit, SourceEdit(text_editor::Action), ApplySource, RevertSource, SourceUndo, SourceRedo, Lint(u64), Linted(u64, Result, String>), SourceDiagnostic(usize), SourceViewport, SourceForSelection, SourceNavigation(crate::source_navigation::Event), Save(bool), Saved(Result, String>), 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 exact_strings: bool, pub session: Session, pub page: Page, pub guide: crate::guide::State, pub behavior: crate::behavior::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 status: String, pub busy: bool, pub pending: Option, pub delete_pending: bool, pub path: Option, file_snapshot: 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, source_baseline: String, pub source_snapshot: Arc, pub source_diagnostics: Vec, pub source_jump: Option, pub source_navigation: crate::source_navigation::State, pub model_issues: Vec, pub migration: Option, source_past: Vec, source_future: Vec, source_version: u64, lint_running: Option, lint_requested: Option, pub explorer_collapsed: BTreeSet, pub workflow: Option, } impl App { pub fn new() -> Self { let mut app = Self::with_project(Project::demo()); app.fit(); app } pub fn with_project(project: Project) -> Self { let source = language::format(&project); 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); Self { exact_strings: false, session: Session::new(project), page: Page::Diagram, guide: crate::guide::State::default(), behavior: Default::default(), inspector_tab: Default::default(), layer, selected, selected_node: None, draft, active_diagram, search: String::new(), create_kind: if layer == Layer::Sa { Kind::Function } else { Kind::Activity }, status: "Coastal rescue example · explore both engineering perspectives".into(), busy: false, pending: None, delete_pending: false, path: None, file_snapshot: 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_baseline: source.clone(), source_snapshot: Arc::from(source), source_diagnostics: vec![], source_jump: None, source_navigation: crate::source_navigation::State::default(), model_issues, migration: None, source_past: vec![], source_future: vec![], source_version: 0, lint_running: None, lint_requested: None, explorer_collapsed: BTreeSet::new(), workflow: None, } } pub fn project(&self) -> &Project { self.session.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_editor.text() != self.source_baseline } pub fn draft_dirty(&self) -> bool { self.draft.as_ref().is_some_and(Draft::dirty) } pub fn dirty(&self) -> bool { self.session.dirty() || self.draft_dirty() || self.source_dirty() } fn sync_source(&mut self) { self.source_jump = None; let source = language::format(self.project()); self.source_navigation.refresh(&source); self.source_editor = text_editor::Content::with_text(&source); self.source_baseline = source.clone(); self.source_snapshot = Arc::from(source); self.source_diagnostics.clear(); self.source_past.clear(); self.source_future.clear(); self.source_version += 1; self.lint_requested = None; } fn refresh_draft(&mut self) { self.selected_node = 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); 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.model_issues = validate(self.project()); self.normalize_creation(); if !self.project().contains(self.selected) { self.selected = self.project().id; self.selected_node = None; } 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(); } 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.source_dirty() { return true; } match language::parse_with_report(&self.source_editor.text()) { Ok(decoded) => match self.session.execute(Command::Replace(decoded.project)) { Ok(_) => { if decoded.migration.is_some() { self.migration = decoded.migration; } self.sync(); self.status = "Text 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.apply_source() { 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.session .execute(command) .map(|_| id) .map_err(|e| e.to_string()) }) { Ok(id) => { if self.active_diagram == Some(original) { self.active_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; 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 { match self.session.execute(command) { Ok(selected) => { if let Some(id) = selected { self.selected = id; } self.sync(); self.status = "Model updated".into(); true } Err(e) => { self.status = e.to_string(); false } } } fn reset(&mut self, project: Project, path: Option) { 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 guide = std::mem::take(&mut self.guide); *self = Self::with_project(project); self.window_size = window_size; self.canvas_size = canvas_size; self.source_version = source_version; self.lint_running = lint_running; 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) } fn select(&mut self, id: Id) { if self.selected == id { return; } if !self.apply() { return; } if !self.project().contains(id) { return; } 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(); } self.refresh_draft(); } pub fn view(&self) -> Element<'_, Message> { crate::views::view(self) } pub fn subscription(&self) -> Subscription { Subscription::batch([ window::close_requests().map(|id| Message::Request(Pending::Close(id))), window::resize_events().map(|(_, size)| Message::WindowResized(size)), iced::event::listen_with(|event, _, _| { let iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers: mods, .. }) = event else { return None; }; if key == keyboard::Key::Named(keyboard::key::Named::Escape) { 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 { if self.busy && !matches!( message, Message::Saved(_) | Message::Loaded(_) | 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.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; return Task::none(); } if self.pending.take().is_some() { self.continue_after_save = false; return Task::none(); } } let modal_event = matches!( message, Message::WindowResized(_) | Message::CanvasResized(_) | Message::SourceViewport | Message::Lint(_) | Message::Linted(_, _) | Message::Saved(_) | Message::Loaded(_) | Message::Exported(_) ); if !modal_event && ((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(); } let previous = self.selected; let task = self.handle(message); self.normalize_creation(); if self.selected != previous { task.chain(iced::widget::operation::snap_to( "inspector", iced::widget::scrollable::RelativeOffset::START, )) } else { task } } fn handle(&mut self, message: Message) -> Task { match message { Message::Escape => self.source_navigation.open = false, Message::Guide(event) => self.guide.update(event), 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::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::Search(value) => self.search = value, Message::Page(page) => { if page == Page::Guide || (if page == Page::Text { self.apply() } else { self.apply_source() }) { self.page = page; if page == Page::Text { return self.request_lint(); } } } Message::CollapseExplorer(id) => { if !self.explorer_collapsed.remove(&id) { self.explorer_collapsed.insert(id); } } 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::Workflow(event) => { if matches!(event, crate::workflow::Event::Cancel) { self.workflow = None; return Task::none(); } if let Some(mut workflow) = self.workflow.take() { let transition_focus = match &workflow { crate::workflow::Workflow::Ports(state) => Some(state.focus()), crate::workflow::Workflow::Transition(t) => { t.plan.changes.first().map(|c| c.target) } _ => 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 destination = focus.and_then(|id| { candidate.layer_of(id).and_then(|layer| { preferred_diagram(&candidate, self.active_diagram, layer, id) }) }); if let (Some(id), Some(diagram)) = (focus, destination) { reveal_port_owner(&mut candidate, diagram, id); } if self.execute(Command::Replace(candidate)) { if let Some(id) = focus { self.selected = id; if let Some(layer) = self.project().layer_of(id) { self.layer = layer; self.active_diagram = destination.or_else(|| self.diagram_for(layer, id)); } self.refresh_draft(); } 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() { 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.refresh_draft(); self.fit(); } } 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.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 event.is_submit() && !self.apply() { 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)) => return self.handle(Message::Command(command)), Ok(None) => {} 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, nodes: Default::default(), }; if self.execute(Command::CreateDiagram(d)) { self.active_diagram = Some(id); self.page = Page::Diagram; } } } 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 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, }; self.execute(command); } } Message::Delete => { if self.selected != self.project().id && self.apply() { self.delete_pending = true; } } Message::ConfirmDelete => { self.delete_pending = false; self.execute(Command::Delete(self.selected)); } Message::CancelDelete => self.delete_pending = false, Message::Undo | Message::Redo => { let redo = matches!(message, Message::Redo); if self.apply() { if redo { self.session.redo(); } else { self.session.undo(); } self.sync(); self.status = 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) => { if self.apply() { self.active_diagram = Some(choice.id); if let Some(d) = self.project().diagrams.get(&choice.id) { self.layer = d.layer; } 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 { 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 Task::none(); } 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() && self.execute(Command::CreateNode { diagram, node: n }) { self.selected_node = Some(node); } } } 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::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_past.push(before); if self.source_past.len() > 100 { self.source_past.remove(0); } self.source_future.clear(); 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::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(diagnostic) = self.source_diagnostics.get(index) { let line = diagnostic.line.saturating_sub(1); let column = self.source_editor.line(line).map_or(0, |text| { text.text .char_indices() .nth(diagnostic.column.saturating_sub(1)) .map_or(text.text.len(), |(offset, _)| offset) }); self.source_jump = Some(text_editor::Cursor { position: text_editor::Position { line, column }, selection: None, }); } } Message::SourceViewport => { if let Some(cursor) = self.source_jump.take() { self.source_editor.move_to(cursor); if cursor.selection.is_none() { self.source_editor.perform(text_editor::Action::SelectLine); } return iced::widget::operation::focus("source-editor"); } } Message::ApplySource => { self.apply_source(); } Message::RevertSource => { self.sync_source(); self.status = "Source draft reverted".into(); } Message::SourceUndo | Message::SourceRedo => { let redo = matches!(message, Message::SourceRedo); let value = if redo { self.source_future.pop() } else { self.source_past.pop() }; if let Some(value) = value { let current = self.source_editor.text(); if redo { self.source_past.push(current); } else { self.source_future.push(current); } self.source_editor = text_editor::Content::with_text(&value); self.source_navigation.refresh(&value); self.source_jump = None; self.source_snapshot = Arc::from(value); self.source_version += 1; self.source_diagnostics.clear(); return self.request_lint(); } } Message::SourceForSelection => { if self.apply() { self.page = Page::Text; let alias = self .project() .identities() .into_iter() .find(|(id, _)| *id == self.selected) .map(|(_, a)| a.to_string()); if let Some(alias) = alias { let source = self.source_editor.text(); if let Some(declaration) = language::analyze(&source) .declarations .iter() .find(|d| d.alias == alias) { let prefix = &source[..declaration.span.start.min(source.len())]; let line = prefix.bytes().filter(|b| *b == b'\n').count(); self.source_jump = Some(text_editor::Cursor { position: text_editor::Position { line, column: 0 }, selection: None, }); } self.status = format!("Source declaration: @{alias}"); } 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_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 => self.source_navigation.open = false, 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 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.source_jump = Some(cursor), 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 !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::Saved(result) => { self.busy = false; match result { Ok(Some((path, project, bytes))) => { self.file_snapshot = Some(bytes); self.path = Some(path); if self.project() == &project { self.session.mark_saved(project); } self.status = "Project saved".into(); 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) => { self.status = e; self.continue_after_save = false; } } } Message::Loaded(result) => { self.busy = false; match result { Ok(Some((path, decoded, bytes))) => { self.reset(decoded.project, Some(path)); 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; } self.file_snapshot = Some(bytes); } 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::Open && 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) => self.window_size = size, Message::CanvasResized(size) => { let first_measurement = self.canvas_size.is_none(); self.canvas_size = Some(size); if first_measurement || 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, 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::New => { self.reset(Project::empty(), None); Task::none() } Pending::Demo => { self.reset(Project::demo(), None); Task::none() } Pending::Open => self.choose_file(FileOperation::Open), Pending::Close(id) => 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 p = self.project().clone(); Task::perform( async move { let file = rfd::AsyncFileDialog::new() .add_filter("Arcadia source", &["arcadia"]) .add_filter("Model JSON", &["json"]) .set_file_name("system.arcadia") .save_file() .await; match file { None => Ok(None), Some(file) => { let path = file.path().to_path_buf(); tokio::task::spawn_blocking(move || { persistence::save_checked(&path, &p, None) .map(|bytes| Some((path, p, Arc::from(bytes)))) .map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())? } } }, Message::Saved, ) } 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 { let expected = if self.path.as_ref() == Some(&path) { self.file_snapshot.clone() } else { None }; self.busy = true; let p = self.project().clone(); Task::perform( async move { tokio::task::spawn_blocking(move || { persistence::save_checked(&path, &p, expected.as_deref()) .map(|bytes| Some((path, p, Arc::from(bytes)))) .map_err(|e| e.to_string()) }) .await .map_err(|e| 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 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_snapshot.clone(); Task::perform( async move { tokio::task::spawn_blocking(move || language::lint(&source)) .await .map_err(|error| error.to_string()) }, move |result| Message::Linted(version, result), ) } 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 left = view .nodes .iter() .map(|n| n.position.x) .fold(f32::INFINITY, f32::min); let top = view .nodes .iter() .map(|n| n.position.y) .fold(f32::INFINITY, f32::min); let right = view .nodes .iter() .map(|n| n.position.x + n.size.width) .fold(f32::NEG_INFINITY, f32::max); let bottom = view .nodes .iter() .map(|n| n.position.y + n.size.height) .fold(f32::NEG_INFINITY, f32::max); 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 read_snapshot(path: PathBuf) -> Result { let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; let decoded = persistence::decode_with_report(&bytes).map_err(|e| e.to_string())?; Ok((path, decoded, Arc::from(bytes))) } #[cfg(test)] mod port_view_tests { use super::*; #[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, 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(); 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.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 = language::lint("?"); 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(language::lint("?")))); 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()); } }