use super::*; use crate::behavior::topology::{self, CanvasEvent}; impl App { pub(crate) fn topology_context(&self) -> Option { let owner = crate::behavior::selected_owner(self)?; (self.page == Page::Behavior && matches!( self.project().elements.get(&owner)?.kind(), Kind::Process | Kind::FunctionalChain )) .then_some(topology::Context { document: self.canvas_document, owner, revision: self.document.revision(), epoch: self.canvas_epoch, }) } pub(super) fn reveal_process_selection(&mut self) -> Task { if let Some(context) = self.topology_context() { let mut state = std::mem::take(&mut self.behavior.topology); if state.reveal_selection(self.project(), context.owner, self.selected) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } self.behavior.topology = state; } Task::none() } pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task { let context = event.context(); if let CanvasEvent::Scrolled(_, area, offset) = event { if self.topology_context().is_some_and(|c| { c.document == context.document && c.owner == context.owner && c.revision == context.revision }) && self.behavior.topology.scrolled(area, offset) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } return Task::none(); } if let CanvasEvent::Viewport(_, size) = event { if self.topology_context().is_some_and(|c| { c.document == context.document && c.owner == context.owner && c.revision == context.revision }) && self.behavior.topology.viewport(size) { 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.topology_context().is_some_and(|c| { c.document == context.document && c.owner == context.owner && c.revision == context.revision }) { return Task::none(); } let scope = canvas_workspace::Scope { document: context.document, owner: context.owner, 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.topology.camera(input) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } } } return Task::none(); } if self.topology_context() != Some(context) { return Task::none(); } self.behavior .topology .enter(context.document, context.owner); match event { CanvasEvent::Remove(_, id) => { if diagram_removal::enabled(self) && self.selected == id && self.behavior.topology.selecting() { if self .behavior .topology .has_qualified_selection(self.project(), context.owner) { self.status = "Open the referenced definition to delete its contents, or select the outer reference to remove that use.".into(); } else if self.behavior.topology.removal_target( self.project(), context.owner, id, ) { self.begin_model_delete(None); } } } CanvasEvent::ExpandQualified(_, endpoint) => { let mut state = std::mem::take(&mut self.behavior.topology); let changed = state.expand_context(self.project(), context.owner, endpoint); self.behavior.topology = state; if changed { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } } CanvasEvent::OpenQualified(_, endpoint) => { if self.behavior.has_pending() || !self.apply() { return Task::none(); } let resolved = oa_core::topology_context::TopologyIndex::new(self.project()) .resolve(context.owner, &endpoint); if let Ok(resolved) = resolved { self.behavior.topology.remember( context.owner, endpoint .references .first() .copied() .unwrap_or(endpoint.occurrence), resolved.defining_sequence, ); self.behavior.active_owner = Some(resolved.defining_sequence); self.select(endpoint.occurrence); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } } CanvasEvent::SelectQualified(_, endpoint) => { if !self.behavior.has_pending() && !self.draft_dirty() && !self.source_dirty() && let Ok(resolved) = oa_core::topology_context::TopologyIndex::new(self.project()) .resolve(context.owner, &endpoint) { let reference = endpoint .references .first() .copied() .unwrap_or(endpoint.occurrence); self.select(reference); self.behavior.topology.select_context(resolved.address); } } CanvasEvent::Select(_, id) => { if !self.behavior.has_pending() && crate::behavior::selected_behavior(self.project(), id) == Some(context.owner) { self.select(id); self.selected_node = None; self.behavior.topology.clear_context(); } } CanvasEvent::Choose(_, tool) => { if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { self.status = "Apply or cancel pending edits before choosing a process tool.".into(); return Task::none(); } self.behavior.topology.choose(tool); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } CanvasEvent::Zoom(_, zoom) => { let mut state = std::mem::take(&mut self.behavior.topology); state.camera_zoom(self.project(), context.owner, zoom); self.behavior.topology = state; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } CanvasEvent::Expand(_, id) => { let mut state = std::mem::take(&mut self.behavior.topology); let changed = state.expand(self.project(), context.owner, id); self.behavior.topology = state; if changed { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } } CanvasEvent::Open(_, occurrence) => { if self.behavior.has_pending() || !self.apply() { return Task::none(); } if let Some(target) = topology::referenced(self.project(), context.owner, occurrence) { if self .project() .elements .get(&target) .is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) { self.behavior .topology .remember(context.owner, occurrence, target); self.behavior.active_owner = Some(target); } self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.select(target); } } CanvasEvent::Back(_) => { if self.behavior.has_pending() || !self.apply() { return Task::none(); } let mut state = std::mem::take(&mut self.behavior.topology); let back = state.back(self.project()); self.behavior.topology = state; if let Some((owner, occurrence)) = back { self.behavior.active_owner = Some(owner); self.select(occurrence); 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 process.".into(); return Task::none(); } match mutation { CanvasEvent::Begin { tool, control, insertion, .. } => { let mut state = std::mem::take(&mut self.behavior.topology); let result = state.begin(self.project(), context, tool, control, insertion); self.behavior.topology = state; match result { Ok(()) => { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.status="Choose the contextual contribution and apply at the captured position.".into(); } Err(e) => self.status = e, } } CanvasEvent::AppendExchange(_) => { let mut state = std::mem::take(&mut self.behavior.topology); let result = state.append_exchange(self.project(), context.owner); self.behavior.topology = state; match result { Ok(()) => { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into(); } Err(e) => self.status = e, } } CanvasEvent::Connect { source, target, .. } => { let mut state = std::mem::take(&mut self.behavior.topology); let result = state.connect(self.project(), context.owner, source, target); self.behavior.topology = state; match result { Ok(()) => { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.status = "Describe the path condition, then Create.".into(); } Err(e) => self.status = e, } } CanvasEvent::Reconnect { link, end, target, .. } => { match topology::reconnect(self.project(), context.owner, link, end, target) { Ok(command) => { if self.execute(command) { self.selected = link; self.refresh_draft(); return self.reveal_process_selection(); } } Err(e) => self.status = e, } } CanvasEvent::Reorder { occurrence, before, .. } => { match topology::reorder(self.project(), context.owner, occurrence, before) { Ok(command) => { if self.execute(command) { self.selected = occurrence; self.refresh_draft(); return self.reveal_process_selection(); } } Err(e) => self.status = e, } } _ => unreachable!(), } } } Task::none() } } #[cfg(test)] mod tests { use super::*; use crate::behavior::{ self, topology::{ CanvasTool, Event, Insertion, Tool, direct_tests::{self, Fixture}, }, }; fn setup() -> (App, Fixture) { let f = direct_tests::fixture(Layer::Oa); let mut app = App::with_project(f.p.clone()); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(Page::Behavior)); assert_eq!(app.topology_context().unwrap().owner, f.owner); (app, f) } fn form(app: &mut App, event: Event) { let _ = app.update(Message::Behavior(behavior::Event::Topology(event))); } fn begin(app: &App) -> CanvasEvent { CanvasEvent::Begin { context: app.topology_context().unwrap(), tool: Tool::Work, control: ControlKind::Choice, insertion: Insertion::default(), } } #[test] fn process_zoom_retains_rejected_draft_and_guards_selection() { let (mut app, f) = setup(); let original = app.document.snapshot(); let initial = app.selected; let event = begin(&app); let _ = app.update(Message::TopologyCanvas(event)); form( &mut app, Event::Name(" unfinished contextual work ".into()), ); let camera = app.behavior.topology.camera_boundary(); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom( context, Some(0.75), ))); assert_ne!(app.canvas_epoch, context.epoch); assert!(app.behavior.topology.camera_boundary() != camera); let raw = app.behavior.recovery_snapshot().unwrap(); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select( context, f.nodes[1], ))); assert_eq!(app.selected, initial); form(&mut app, Event::Submit); assert!(app.behavior.has_pending()); assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); assert!(Arc::ptr_eq(&original, &app.document.snapshot())); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))); assert_ne!(app.canvas_epoch, context.epoch); assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); } #[test] fn direct_process_append_empty_first_second_exchange_has_exact_source_history() { for layer in [Layer::Oa, Layer::Sa] { let (f, next) = direct_tests::empty_exchange_fixture(layer); let mut app = App::with_project(f.p.clone()); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(Page::Behavior)); let empty = app.document.snapshot(); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin { context, tool: Tool::Exchange, control: ControlKind::Choice, insertion: Insertion::default(), })); assert!(app.behavior.has_pending()); form( &mut app, Event::Target(Choice { id: f.exchange, label: "Assessment".into(), }), ); form(&mut app, Event::Name("First contribution".into())); form(&mut app, Event::Submit); assert!(!app.behavior.has_pending(), "{}", app.status); let first_id = app.selected; let first = app.document.snapshot(); assert_eq!( app.project().elements[&f.owner].steps().unwrap(), &[first_id] ); assert_eq!(app.project().relations, empty.decoded.project.relations); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange( context, ))); assert!( app.behavior.has_pending(), "reachable single-exchange continuation: {}", app.status ); assert!(Arc::ptr_eq(&first, &app.document.snapshot())); form( &mut app, Event::Target(Choice { id: next, label: "Response".into(), }), ); form(&mut app, Event::Name("Second contribution".into())); form(&mut app, Event::Submit); assert!(!app.behavior.has_pending(), "{}", app.status); let second_id = app.selected; let second = app.document.snapshot(); assert_ne!(first_id, second_id); assert_eq!( app.project().elements[&f.owner].steps().unwrap(), &[first_id, second_id] ); let links = app .project() .relations .values() .filter(|r| r.kind() == RelationKind::SequenceLink) .collect::>(); assert_eq!(links.len(), 1); assert_eq!( (links[0].source(), links[0].target()), (first_id, second_id) ); assert_eq!( app.project().elements.len(), empty.decoded.project.elements.len() + 2 ); for (id, relation) in &empty.decoded.project.relations { assert_eq!(app.project().relations[id], *relation); } for work in f.work { assert_eq!( app.project().elements[&work], empty.decoded.project.elements[&work] ); } let reopened = Document::from_bundle( second.bundle.clone(), oa_core::source_project::ProjectLimits::default(), ) .unwrap(); assert_eq!(reopened.project(), app.project()); assert_eq!(reopened.bundle(), &second.bundle); for expected in [&first, &empty] { let _ = app.update(Message::Undo); assert_eq!(app.document.bundle(), &expected.bundle); assert_eq!(app.project(), &expected.decoded.project); } assert!( !app.document.can_undo(), "each Create must add exactly one history entry" ); for expected in [&first, &second] { let _ = app.update(Message::Redo); assert_eq!(app.document.bundle(), &expected.bundle); assert_eq!(app.project(), &expected.decoded.project); } assert!(!app.document.can_redo()); } } #[test] fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() { let (mut app, _) = setup(); let snapshot = app.document.snapshot(); for field in 0..4 { let mut event = begin(&app); let CanvasEvent::Begin { context, .. } = &mut event else { unreachable!() }; match field { 0 => context.document = Id::new_v4(), 1 => context.owner = Id::new_v4(), 2 => context.revision += 1, _ => context.epoch += 1, }; let _ = app.update(Message::TopologyCanvas(event)); assert!(!app.behavior.has_pending()); assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); } let _ = app.update(Message::Field("/name".into(), "Pending owner name".into())); let event = begin(&app); let _ = app.update(Message::TopologyCanvas(event)); assert!(!app.behavior.has_pending()); assert!(app.draft_dirty()); let context = app.topology_context().unwrap(); for event in [ CanvasEvent::Zoom(context, Some(0.7)), CanvasEvent::Viewport(context, iced::Size::new(510., 360.)), ] { let _ = app.update(Message::TopologyCanvas(event)); assert!(app.draft_dirty()); assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); } } #[test] fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() { 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::() + 3000; app.document = Document::from_bundle(bundle.clone(), limits).unwrap(); app.sync(); let event = begin(&app); let _ = app.update(Message::TopologyCanvas(event)); form( &mut app, Event::Target(Choice { id: f.work[0], label: "Assess".into(), }), ); form(&mut app, Event::Name("Retain this long input".repeat(1000))); 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 contribution".into())); form(&mut app, Event::Submit); assert!(!app.behavior.has_pending()); assert_ne!(app.selected, f.owner); assert_eq!(app.project().named(app.selected), "Recovered contribution"); let created = app.selected; let after = app.document.bundle().clone(); let _ = app.update(Message::Undo); assert_eq!(app.document.bundle(), &bundle); let _ = app.update(Message::Redo); assert_eq!(app.document.bundle(), &after); assert!(app.project().contains(created)); } #[test] fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() { let (mut app, f) = setup(); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose( context, CanvasTool::Connect, ))); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select( context, f.nodes[0], ))); assert_eq!(app.topology_context(), Some(context)); let other = app .project() .make_element(Kind::Process, Layer::Oa, "Other", None) .unwrap(); let other_id = other.id; assert!(app.execute(Command::CreateElement(other))); let _ = app.update(Message::BehaviorOwner(Choice { id: other_id, label: "Other".into(), })); assert_eq!(app.topology_context().unwrap().owner, other_id); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect { context, source: f.nodes[0], target: f.nodes[2], })); assert!(!app.behavior.has_pending()); } #[test] fn direct_process_app_save_details_resize_and_passive_geometry_keep_drafts_and_camera() { let (mut app, f) = setup(); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera( context, app.camera_epoch, behavior::canvas_workspace::Event::Pan(iced::Vector::new(25., 100.)), ))); let context = app.topology_context().unwrap(); let original = app.document.snapshot(); app.busy = true; let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled( context, behavior::scenario::ScrollArea::Page, iced::Vector::new(0., 40.), ))); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport( context, iced::Size::new(500., 300.), ))); let camera = app.behavior.topology.camera_boundary(); assert_eq!( app.behavior.topology.details_offset(), iced::Vector::new(0., 40.) ); assert_ne!(app.topology_context(), Some(context)); assert!(app.busy); let mut stale = context; stale.document = Id::new_v4(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled( stale, behavior::scenario::ScrollArea::Page, iced::Vector::new(0., 900.), ))); assert_eq!( app.behavior.topology.details_offset(), iced::Vector::new(0., 40.) ); let root = PathBuf::from("process-scroll-save-fixture"); let receipt = FileSnapshot { document: context.document, revision: context.revision, snapshot: original.clone(), path: root.join(&original.bundle.entry), disk: Some(source_files::DiskProject { root, bundle: original.bundle.clone(), expectations: Default::default(), reachable: original.bundle.files.keys().cloned().collect(), diagnostics: vec![], }), }; let _ = app.update(Message::Saved(Ok(Some(receipt)))); assert!(!app.busy); assert!(!app.document.can_undo()); assert_eq!(app.project(), &f.p); assert!(app.behavior.topology.camera_boundary() == camera); let context = app.topology_context().unwrap(); let size = app.window_size; let _ = app.update(Message::WindowResized(size)); assert_eq!(app.topology_context(), Some(context)); let _ = app.update(Message::WindowResized(iced::Size::new( size.width + 20., size.height, ))); assert_ne!(app.topology_context(), Some(context)); let event = begin(&app); let _ = app.update(Message::TopologyCanvas(event)); form(&mut app, Event::Name("Keep this form".into())); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled( context, behavior::scenario::ScrollArea::Page, iced::Vector::new(0., 80.), ))); assert!(app.behavior.has_pending()); assert_eq!(app.document.bundle(), &original.bundle); } #[test] fn direct_process_app_open_source_and_back_preserve_local_reference_identity() { let (mut app, f) = setup(); let source = app .project() .make_element(Kind::Process, Layer::Oa, "Reusable source", None) .unwrap(); let source_id = source.id; assert!(app.execute(Command::CreateElement(source))); let mut occurrence = app .project() .make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner)) .unwrap(); let occurrence_id = occurrence.id; if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() { *body = OccurrenceBody::SequenceReference { sequence: Some(source_id), }; } assert!(app.execute(Command::CreateElement(occurrence))); let snapshot = app.document.snapshot(); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open( context, occurrence_id, ))); assert_eq!(app.selected, source_id); assert_eq!(app.topology_context().unwrap().owner, source_id); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context))); assert_eq!(app.selected, occurrence_id); assert_eq!(app.topology_context().unwrap().owner, f.owner); assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); } } #[cfg(test)] #[test] fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() { let f = topology::direct_tests::fixture(Layer::Oa); let mut app = App::with_project(f.p.clone()); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(Page::Behavior)); let original = app.document.snapshot(); let edited = format!( "{}\n// Retained source draft\n", app.source_workspace.text() ); let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll)); let _ = app.update(Message::SourceEdit(text_editor::Action::Edit( text_editor::Edit::Paste(edited.clone().into()), ))); assert!(app.source_dirty()); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin { context, tool: topology::Tool::Work, control: ControlKind::Choice, insertion: topology::Insertion::default(), })); assert!(!app.behavior.has_pending()); assert!(app.source_dirty()); assert_eq!(app.source_workspace.text(), edited); assert!(Arc::ptr_eq(&original, &app.document.snapshot())); }