From a306d6b2a83ecf7d78b1feebd3cfe322362b19db Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 05:12:38 +0300 Subject: [PATCH 1/5] Plan direct process and chain editing milestone --- harness/STATE.md | 9 +++++ harness/release-process-editing.md | 55 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 harness/release-process-editing.md diff --git a/harness/STATE.md b/harness/STATE.md index e66a2bb9..b8023289 100644 --- a/harness/STATE.md +++ b/harness/STATE.md @@ -2,6 +2,14 @@ ## Objective and phase +Direct process/chain editing is now being implemented on `codex/process-direct` +at base 1bf8ebc in the scenario checkout and its dedicated target. The previous +scenario branch is retained. `release-process-editing.md` records the adopted +direct occurrence/link, guard-preserving insertion, bounded picker, derived +nesting and lifetime contract. This is desktop-only work; core/source/API types +stay unchanged. Automated implementation evidence and root-owned native trials +are pending. Cross-reference inner-chain linking remains an explicit semantic +extension rather than a claim of full Capella chain equivalence. Direct scenario work is now in isolated `codex/scenario-direct`, checkout `D:/CodexBuilds/Capella2-scenario-direct`, base ab661f2, dedicated target `D:/CodexBuilds/Capella2-scenario-target`. Scope and acceptance are recorded in @@ -181,3 +189,4 @@ Call/Reply history were verified; flat property paths prompted the latest fix. The independent bounded-browser review reports no remaining medium/high finding. Linux native R6 workflows remain a root-owned acceptance gate. See release-data-verification.md. Main remains a8f451e; R6 is not yet accepted. + diff --git a/harness/release-process-editing.md b/harness/release-process-editing.md new file mode 100644 index 00000000..7d4afd58 --- /dev/null +++ b/harness/release-process-editing.md @@ -0,0 +1,55 @@ +# Direct process / functional-chain editing + +Status: implementation started on 2026-09-06 in `codex/process-direct`, based on +1bf8ebc in `D:/CodexBuilds/Capella2-scenario-direct`; dedicated target remains +`D:/CodexBuilds/Capella2-scenario-target`. The prior scenario branch is retained. +No native acceptance is claimed; root owns Windows/Linux native operation. + +## Decisions and observable outcome + +An engineer can involve existing work/exchanges more than once, draw guarded +sequence links and controls, insert on a selected path, reconnect its endpoint, +inspect/reuse a subprocess, and undo/save/reopen with contextual identities intact. +Extend the existing derived topology graph and ordinary typed drafts. No core +schema, persisted geometry, source grammar or REST route is added. + +Palette tools capture document/owner/revision/epoch and a specific contribution, +link or presentation slot. Canvas click-click and drag connect local occurrence +IDs. A link insertion preserves the captured link UUID, alias, description and +guard on the upstream segment, creates the downstream segment and new occurrence, +and updates `steps` in one validated Batch. Membership/presentation order remains +separate from SequenceLink topology. Incomplete control guards/cardinalities are +warnings, as in the core. Reconnection retains the link's authored properties. + +Select always means the local occurrence/relation. Shared source navigation uses +explicit Open; reusable previews expand without copying or reparenting source +contributions. Inner preview endpoints are not caller-owned endpoints. Capella's +richer cross-reference hierarchy linking is an unresolved semantic extension, +not implied by this milestone. Existing explicit legacy-path conversion remains. + +Keep canvas and bounded draft independently scrollable, use searchable paged +contextual choices, and default to readable zoom with explicit Fit and scrolling. +Transient drawing cancels on context, tool, bounds, scroll, resize or focus changes; +form inputs survive failed validation/source updates. Reuse passive scroll +observations including async-save delivery. Commit through Document::execute, +acknowledging and selecting the created occurrence only after model/source success. + +## Ownership and verification + +Own `behavior/topology.rs` and local tests, with narrow `behavior.rs` and `app.rs` +routing/lifecycle changes. Do not change DATA/Linux/semantic checkouts or perform +native input. Verify production pointer events, planners, App and model/source: + +1. OA/SA repeated work/exchange involvement preserves shared records. +2. Alternative/parallel/loop creation, guards and incomplete warnings. +3. Specific guarded-link insertion preserves metadata; incompatible adjacency rejects. +4. Reconnect/delete/Undo/Redo retain unrelated paths and exact source. +5. Repeated subprocess reuse, explicit navigation, expansion and recursive rejection. +6. Legacy conversion and presentation reorder independent of explicit topology. +7. Dirty/source-failure/stale/scroll-save/resize/Escape/focus lifetimes retain drafts. +8. Nested source save/reopen and ordinary serialized command/model parity. + +Run focused invariants, all desktop tests, strict all-target desktop Clippy and +optimized desktop build with --locked --offline and the dedicated target. Record +actual evidence and remaining native tasks before freezing. Root independently +reviews and native-tests the freeze; full-release completeness remains separate. From 7a1e12bc7c18c5693de688f054293bacd095f4d8 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 05:33:42 +0300 Subject: [PATCH 2/5] Add direct process and chain occurrence editing --- crates/oa-desktop/src/app.rs | 27 +- .../src/app/behavior_lifecycle_tests.rs | 16 +- crates/oa-desktop/src/app/process_canvas.rs | 471 ++++++++++ crates/oa-desktop/src/behavior.rs | 54 +- crates/oa-desktop/src/behavior/topology.rs | 610 ++++--------- .../src/behavior/topology/direct.rs | 736 +++++++++++++++ .../src/behavior/topology/direct_tests.rs | 850 ++++++++++++++++++ .../oa-desktop/src/behavior/topology/view.rs | 464 ++++++++++ 8 files changed, 2798 insertions(+), 430 deletions(-) create mode 100644 crates/oa-desktop/src/app/process_canvas.rs create mode 100644 crates/oa-desktop/src/behavior/topology/direct.rs create mode 100644 crates/oa-desktop/src/behavior/topology/direct_tests.rs create mode 100644 crates/oa-desktop/src/behavior/topology/view.rs diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 622ef214..bd2979f4 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -224,6 +224,7 @@ mod direct_scenario_tests { #[cfg(test)] mod behavior_lifecycle_tests; +mod process_canvas; mod state_diagram; #[cfg(test)] mod state_diagram_tests; @@ -359,6 +360,7 @@ pub enum Message { BehaviorDiagram(Choice), StateDiagram(crate::behavior::state_edit::Event), ScenarioCanvas(crate::behavior::scenario::CanvasEvent), + TopologyCanvas(crate::behavior::topology::CanvasEvent), PlaceSelected, HideSelected, ToggleCollapse, @@ -791,7 +793,11 @@ impl App { } fn execute(&mut self, command: Command) -> bool { let command = crate::port_edit::repair_owner_change(self.project(), command); - let scenario_selection = self.behavior.scenario.created_selection(&command); + let scenario_selection = self + .behavior + .scenario + .created_selection(&command) + .or_else(|| self.behavior.topology.created_selection(&command)); match self.document.execute(command) { Ok(selected) => { if let Some(id) = scenario_selection.or(selected) { @@ -956,6 +962,10 @@ impl App { if matches!( &message, Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..)) + | Message::TopologyCanvas( + crate::behavior::topology::CanvasEvent::Scrolled(..) + | crate::behavior::topology::CanvasEvent::Viewport(..) + ) ) { return self.handle(message); } @@ -1116,6 +1126,11 @@ impl App { let entering_catalog = self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog)); let mut task = self.handle(message); + if let Some(context) = self.topology_context() { + self.behavior + .topology + .enter(context.document, context.owner); + } if self.connection.as_ref().is_some_and(|session| { Some(session.context) != self.route_context() || self.page != Page::Diagram @@ -1267,6 +1282,7 @@ impl App { } } } + Message::TopologyCanvas(event) => return self.edit_process_canvas(event), Message::StateDiagram(event) => return self.edit_state_diagram(event), Message::BehaviorOwner(choice) => { if self.apply() @@ -1854,6 +1870,9 @@ impl App { Ok(Some(command)) => { if self.execute(command) { self.behavior.acknowledge_commit(); + if self.topology_context().is_some() { + return self.reveal_process_selection(); + } } } Ok(None) => {} @@ -2581,8 +2600,10 @@ impl App { } Message::WindowResized(size) => { // The canvas can retain its capped size during a real window - // resize. Invalidate any scenario pointer/slot gesture anyway. - if self.window_size != size && self.scenario_context().is_some() { + // 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; diff --git a/crates/oa-desktop/src/app/behavior_lifecycle_tests.rs b/crates/oa-desktop/src/app/behavior_lifecycle_tests.rs index bf691589..3219c766 100644 --- a/crates/oa-desktop/src/app/behavior_lifecycle_tests.rs +++ b/crates/oa-desktop/src/app/behavior_lifecycle_tests.rs @@ -322,14 +322,18 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() { Message::Field("/name".into(), "Unapplied state name".into()), ); assert!(app.draft_dirty()); + let context = behavior::topology::Context { + document: app.canvas_document, + owner: fixture.machine, + revision: app.document.revision(), + epoch: app.canvas_epoch, + }; for event in [ - behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new( - 900., 600., - ))), - behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)), - behavior::Event::Topology(behavior::topology::Event::Fit), + behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)), + behavior::topology::CanvasEvent::Zoom(context, Some(0.7)), + behavior::topology::CanvasEvent::Zoom(context, None), ] { - send(app, Message::Behavior(event)); + send(app, Message::TopologyCanvas(event)); assert!(Arc::ptr_eq(&before, &app.document.snapshot())); assert!(app.draft_dirty()); assert!(!app.behavior.has_pending()); diff --git a/crates/oa-desktop/src/app/process_canvas.rs b/crates/oa-desktop/src/app/process_canvas.rs new file mode 100644 index 00000000..8db6ec68 --- /dev/null +++ b/crates/oa-desktop/src/app/process_canvas.rs @@ -0,0 +1,471 @@ +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(&self) -> Task { + let Some(context) = self.topology_context() else { + return Task::none(); + }; + let Some(offset) = + self.behavior + .topology + .selection_offset(self.project(), context.owner, self.selected) + else { + return Task::none(); + }; + iced::widget::operation::scroll_to("process-canvas", offset) + } + 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 self.topology_context() != Some(context) { + return Task::none(); + } + self.behavior + .topology + .enter(context.document, context.owner); + match event { + 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; + } + } + CanvasEvent::Choose(_, tool) => { + if self.behavior.has_pending() || !self.apply() { + return Task::none(); + } + self.behavior.topology.choose(tool); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + CanvasEvent::Zoom(_, zoom) => { + self.behavior.topology.zoom(zoom); + 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); + 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::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 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_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() { + let (mut app, f) = setup(); + let context = app.topology_context().unwrap(); + let original = app.document.snapshot(); + app.busy = true; + for (area, offset) in [ + ( + behavior::scenario::ScrollArea::Canvas, + iced::Vector::new(25., 100.), + ), + ( + behavior::scenario::ScrollArea::Page, + iced::Vector::new(0., 40.), + ), + ] { + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled( + context, area, offset, + ))); + } + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport( + context, + iced::Size::new(500., 300.), + ))); + assert_eq!( + app.behavior.topology.scroll_offset(), + iced::Vector::new(25., 140.) + ); + 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::Canvas, + iced::Vector::new(0., 900.), + ))); + assert_eq!( + app.behavior.topology.scroll_offset(), + iced::Vector::new(25., 140.) + ); + 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); + 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())); +} diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index da4cf658..2682b415 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -38,14 +38,33 @@ mod scenario_scroll { content: content.into(), context, area, + process: false, + }) + } + pub(super) fn observe_topology<'a>( + context: topology::Context, + area: scenario::ScrollArea, + content: impl Into>, + ) -> Element<'a, Message> { + Element::new(Observer { + content: content.into(), + context: scenario::Context { + document: context.document, + scenario: context.owner, + revision: context.revision, + epoch: context.epoch, + }, + area, + process: true, }) } struct Observer<'a, R = Renderer> { content: Element<'a, Message, Theme, R>, context: scenario::Context, area: scenario::ScrollArea, + process: bool, } - type Report = Option<(Id, Id, Vector)>; + type Report = Option<(bool, Id, Id, Vector)>; #[derive(Default)] struct Translation(Option); impl widget::Operation for Translation { @@ -77,15 +96,33 @@ mod scenario_scroll { &mut offset, ); if let Some(offset) = offset.0 { - let next = Some((self.context.document, self.context.scenario, offset)); + let next = Some(( + self.process, + self.context.document, + self.context.scenario, + offset, + )); let previous = tree.state.downcast_mut::(); if *previous != next { *previous = next; - shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled( - self.context, - self.area, - offset, - ))); + shell.publish(if self.process { + Message::TopologyCanvas(topology::CanvasEvent::Scrolled( + topology::Context { + document: self.context.document, + owner: self.context.scenario, + revision: self.context.revision, + epoch: self.context.epoch, + }, + self.area, + offset, + )) + } else { + Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled( + self.context, + self.area, + offset, + )) + }); } } } @@ -220,6 +257,7 @@ mod scenario_scroll { Observer { context, area: scenario::ScrollArea::Canvas, + process: false, content: scrollable(iced::widget::Space::new().width(200.).height(height)) .width(200.) .height(200.) @@ -342,7 +380,7 @@ impl Event { pub struct State { pub active_owner: Option, pub diagram: state_view::State, - topology: topology::State, + pub(crate) topology: topology::State, pub(crate) scenario: scenario::State, states: states::State, } diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs index d3de8c6c..6ebb58f6 100644 --- a/crates/oa-desktop/src/behavior/topology.rs +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -1,8 +1,16 @@ //! Process/chain authoring and projection use contextual occurrences and explicit //! SequenceLinks. Presentation order never invents control-flow relationships. use super::*; -use iced::widget::{Column, column, text_input}; +use iced::widget::{Column, text_input}; use std::collections::BTreeSet; +mod direct; +mod view; +use direct::Input; +pub(crate) use direct::{ + CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder, +}; +#[cfg(test)] +pub(crate) mod direct_tests; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tool { @@ -40,9 +48,8 @@ pub enum Event { Guard(String), AlternateGuard(String), Control(ControlChoice), - Viewport(Size), - Zoom(f32), - Fit, + Search(usize, String), + Page(usize, usize), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ControlChoice(pub ControlKind); @@ -59,11 +66,25 @@ impl std::fmt::Display for ControlChoice { } #[derive(Default)] pub struct State { + identity: Option<(Id, Id)>, + tool: CanvasTool, + expanded: BTreeSet, + scroll_offsets: [iced::Vector; 2], + return_to: Vec<(Id, Id)>, draft: Option, viewport: Option, zoom: Option, + fit: bool, +} +#[derive(Default)] +struct Browser { + search: String, + page: usize, } struct Draft { + insertion: Insertion, + fixed: bool, + browsers: [Browser; 4], owner: Id, tool: Tool, name: String, @@ -99,9 +120,6 @@ impl State { return Err(PENDING_DRAFT.into()); } match event { - Event::Viewport(size) => self.viewport = Some(size), - Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)), - Event::Fit => self.zoom = None, Event::Start(owner, tool, selected) => { let e = p .elements @@ -127,6 +145,9 @@ impl State { } }); self.draft = Some(Draft { + insertion: Insertion::default(), + fixed: false, + browsers: Default::default(), owner, tool, name: String::new(), @@ -154,6 +175,11 @@ impl State { Event::Guard(value) => d.guard = value, Event::AlternateGuard(value) => d.alternate_guard = value, Event::Control(value) => d.control = value.0, + Event::Search(index, value) if index < 4 => { + d.browsers[index].search = value; + d.browsers[index].page = 0; + } + Event::Page(index, value) if index < 4 => d.browsers[index].page = value, _ => {} } } @@ -163,12 +189,13 @@ impl State { } fn scale(&self, graph: Size, fallback_width: f32) -> f32 { - self.zoom.unwrap_or_else(|| { - let width = self.viewport.map_or(fallback_width, |s| s.width); - ((width - 24.).max(1.) / graph.width.max(1.)) - .min(450. / graph.height.max(1.)) - .clamp(0.05, 1.) - }) + if !self.fit { + return self.zoom.unwrap_or(1.); + } + let width = self.viewport.map_or(fallback_width, |s| s.width); + ((width - 24.).max(1.) / graph.width.max(1.)) + .min(450. / graph.height.max(1.)) + .clamp(0.05, 1.) } } fn is_contribution(p: &Project, owner: Id, id: Id) -> bool { @@ -247,13 +274,24 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec { }) .map(|e| Choice { id: e.id, - label: format!("{} · {}", e.name, e.alias), + label: format!("{} · {}", e.name, semantic_path(p, e.id)), }) .collect() }; choices.sort_by(|a, b| a.label.cmp(&b.label)); choices } +fn semantic_path(p: &Project, id: Id) -> String { + let mut path = Vec::new(); + let mut current = Some(id); + while let Some(id) = current { + let Some(e) = p.elements.get(&id) else { break }; + path.push(e.alias.as_str()); + current = e.parent; + } + path.reverse(); + path.join(" / ") +} fn contributions(p: &Project, owner: Id) -> Vec { p.elements .get(&owner) @@ -394,6 +432,40 @@ impl Draft { if self.before.is_some() && self.before == self.after { return Err("Choose different preceding and following contributions.".into()); } + let captured = self + .insertion + .link + .map(|id| { + p.relations + .get(&id) + .filter(|r| { + r.kind() == RelationKind::SequenceLink + && is_contribution(p, self.owner, r.source()) + && is_contribution(p, self.owner, r.target()) + }) + .ok_or("The captured path no longer exists in this process.") + }) + .transpose()?; + if captured + .is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after) + { + return Err("The captured path changed. Choose its position again.".into()); + } + let matches: Vec<_> = sequence_links(p, self.owner) + .into_iter() + .filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after) + .collect(); + let split_link = if self.tool == Tool::Connect { + None + } else if captured.is_some() { + captured + } else if !self.fixed && matches.len() == 1 { + Some(matches[0]) + } else if !self.fixed && matches.len() > 1 { + return Err("Select the specific sequence link to insert on.".into()); + } else { + None + }; let mut commands = preserved_order( p, self.owner, @@ -418,7 +490,6 @@ impl Draft { { return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into()); } - require_guard(p, source, &self.guard)?; commands.push(link(p, source, target, &self.guard)); return Ok(unique_batch(p, commands)); } @@ -459,12 +530,6 @@ impl Draft { return Err("Choose same-layer work for the second path.".into()); } let parallel = self.control == ControlKind::Fork; - if !parallel && (self.guard.trim().is_empty() || self.alternate_guard.trim().is_empty()) - { - return Err( - "Describe both alternative guards; use 'else' for the fallback path.".into(), - ); - } let split = occurrence( p, self.owner, @@ -552,30 +617,47 @@ impl Draft { commands.push(Command::CreateElement(e)); (id, id) }; - if let Some(before) = self.before { - // Branch guards belong to the split's two outgoing paths. A branch - // inserted after another choice must first use an explicit link. - let guard = if self.tool == Tool::Branch { - "" - } else { - &self.guard - }; - require_guard(p, before, guard)?; - commands.push(link(p, before, first, guard)); + if let Some(existing) = split_link { + let mut updated = existing.clone(); + if let RelationBody::SequenceLink { target, .. } = &mut updated.body { + *target = first; + } + commands.push(Command::EditRelation(existing.id, updated)); + } else if let Some(before) = self.before { + commands.push(link( + p, + before, + first, + if self.tool == Tool::Branch { + "" + } else { + &self.guard + }, + )); } if let Some(after) = self.after { commands.push(link(p, last, after, "")); } - // Inserting between two selected contributions replaces their direct - // precedence while preserving other links and their semantic identities. - if let (Some(before), Some(after)) = (self.before, self.after) { - commands.extend( - sequence_links(p, self.owner) - .into_iter() - .filter(|r| r.source() == before && r.target() == after) - .map(|r| Command::Delete(r.id)), - ); - } + let new_ids: Vec<_> = commands + .iter() + .filter_map(|c| match c { + Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id), + _ => None, + }) + .collect(); + let mut order = owner.steps().expect("process steps").clone(); + let slot = self.insertion.before.or(self.after); + let index = slot + .map(|id| { + order + .iter() + .position(|v| *v == id) + .ok_or("The presentation slot changed.") + }) + .transpose()? + .unwrap_or(order.len()); + order.splice(index..index, new_ids); + commands.push(Command::SetSteps(self.owner, order)); Ok(unique_batch(p, commands)) } } @@ -598,23 +680,6 @@ pub(super) fn unique_batch(p: &Project, mut commands: Vec) -> Command { } Command::Batch(commands) } -fn require_guard(p: &Project, source: Id, guard: &str) -> Result<(), String> { - if guard.trim().is_empty() - && matches!( - p.elements.get(&source).and_then(|e| e.shared()), - Some(SharedBody::Occurrence { - body: OccurrenceBody::Control { - kind: ControlKind::Choice | ControlKind::Loop - }, - .. - }) - ) - { - Err("Describe this choice / repeat condition, or enter 'else'.".into()) - } else { - Ok(()) - } -} fn event(e: Event) -> Message { Message::Behavior(if matches!(e, Event::Submit) { super::Event::Submit @@ -622,307 +687,9 @@ fn event(e: Event) -> Message { super::Event::Topology(e) }) } -fn picker<'a>( - choices: Vec, - value: Option, - placeholder: &'a str, - change: fn(Choice) -> Event, -) -> Element<'a, Message> { - let selected = value - .and_then(|id| choices.iter().find(|c| c.id == id)) - .cloned(); - pick_list(choices, selected, move |v| event(change(v))) - .placeholder(placeholder) - .padding(9) - .text_size(12) - .width(Fill) - .into() +pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> { + view::view(app, owner, out) } -pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> { - let p = app.project(); - let selected = is_contribution(p, owner, app.selected).then_some(app.selected); - if !p.elements[&owner].description.is_empty() { - out = out.push( - text(&p.elements[&owner].description) - .size(12) - .color(style::MUTED), - ); - } - out = out - .push( - row![ - action("+ Work", event(Event::Start(owner, Tool::Work, selected))), - action( - "+ Exchange", - event(Event::Start(owner, Tool::Exchange, selected)) - ), - action("Reuse…", event(Event::Start(owner, Tool::Reuse, selected))), - ] - .spacing(7), - ) - .push( - row![ - action( - "+ Control", - event(Event::Start(owner, Tool::Control, selected)) - ), - action( - "Branch / parallel…", - event(Event::Start(owner, Tool::Branch, selected)) - ), - action( - "Connect…", - event(Event::Start(owner, Tool::Connect, selected)) - ), - action("Properties", Message::EditProperties(owner)), - ] - .spacing(7), - ); - if let Some(d) = app - .behavior - .topology - .draft - .as_ref() - .filter(|d| d.owner == owner) - { - let mut form = column![text(d.tool.title()).size(16)].spacing(9); - let legacy = legacy_order(p, owner); - if legacy.len() > 1 { - form = form.push(label(format!("This edit will preserve the {} ordered contributions as explicit sequence links in the same undoable operation. Inserting between two contributions replaces their direct link.", legacy.len()))); - } - if matches!( - d.tool, - Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch - ) { - let choices: Vec<_> = targets(p, owner, d.tool) - .into_iter() - .filter(|choice| compatible_source(p, d, choice.id)) - .collect(); - if choices.is_empty() { - form = form.push(label("No compatible sources for these connections. Change the insertion points, or create the matching work, exchange or reusable process first.")); - } - form = form.push(picker( - choices, - d.target, - "Choose an existing source…", - Event::Target, - )); - } - if d.tool != Tool::Connect { - form = form.push( - text_input("Contribution name (defaults to source name)", &d.name) - .on_input(|s| event(Event::Name(s))) - .padding(9) - .size(12), - ); - } - if matches!(d.tool, Tool::Control | Tool::Branch) { - let kinds = if d.tool == Tool::Branch { - vec![ControlKind::Choice, ControlKind::Fork] - } else { - vec![ - ControlKind::Choice, - ControlKind::Merge, - ControlKind::Fork, - ControlKind::Join, - ControlKind::Loop, - ] - }; - form = form.push( - pick_list( - kinds.into_iter().map(ControlChoice).collect::>(), - Some(ControlChoice(d.control)), - |v| event(Event::Control(v)), - ) - .padding(9) - .text_size(12) - .width(Fill), - ); - } - if d.tool == Tool::Branch { - form = form.push(picker( - targets(p, owner, Tool::Work), - d.alternate, - "Work on the second path…", - Event::Alternate, - )); - if d.control != ControlKind::Fork { - form = form.push( - row![ - text_input("First path condition", &d.guard) - .on_input(|s| event(Event::Guard(s))) - .padding(9) - .size(12), - text_input("Second path condition", &d.alternate_guard) - .on_input(|s| event(Event::AlternateGuard(s))) - .padding(9) - .size(12), - ] - .spacing(7), - ); - } - form = form.push(label("Creates split, two work contributions, merge and their links in one undoable operation.")); - } - form = form - .push( - row![ - picker( - contributions(p, owner), - d.before, - if d.tool == Tool::Connect { - "From contribution…" - } else { - "After contribution (optional)…" - }, - Event::Before - ), - action("Clear", event(Event::ClearBefore)), - ] - .spacing(7), - ) - .push( - row![ - picker( - contributions(p, owner), - d.after, - if d.tool == Tool::Connect { - "To contribution…" - } else { - "Before contribution (optional)…" - }, - Event::After - ), - action("Clear", event(Event::ClearAfter)), - ] - .spacing(7), - ); - if d.tool != Tool::Branch { - form = form.push( - text_input("Sequence guard (for a choice or loop)", &d.guard) - .on_input(|s| event(Event::Guard(s))) - .padding(9) - .size(12), - ); - } - if let Err(problem) = d.command(p) { - form = form.push(label(problem)); - } - form = form.push( - row![ - button("Create") - .padding(9) - .on_press_maybe(d.command(p).is_ok().then_some(event(Event::Submit))), - action("Cancel", event(Event::Cancel)) - ] - .spacing(7), - ); - out = out.push(container(form).padding(14).style(style::card)); - } - let (canvas, size): (Element<'a, Message>, Size) = if uses_explicit_topology(p, owner) - || p.elements[&owner].steps().is_none_or(Vec::is_empty) - { - let mut graph = Graph::new(p, owner, app.selected); - if graph.nodes.is_empty() { - out = out.push(container(column![ - text("Start this process").size(18), - label("Use + Work to involve an existing activity, or + Exchange for an interaction. Then connect contributions or insert alternative and parallel paths."), - ].spacing(12)).padding(24).width(Fill).style(style::card)); - return scrollable(out.padding(24)).into(); - } else if graph.edges.is_empty() { - out = out.push(label( - "No explicit sequence links. Connect contributions to describe topology.", - )); - } - graph.scale = app - .behavior - .topology - .scale(graph.size, (app.window_size.width - 600.).max(200.)); - let scale = graph.scale; - let size = Size::new(graph.size.width * scale, graph.size.height * scale); - out = out.push( - row![ - action("Fit", event(Event::Fit)), - action("−", event(Event::Zoom(scale / 1.25))), - label(format!("{:.0}%", scale * 100.)), - action("+", event(Event::Zoom(scale * 1.25))), - action("100%", event(Event::Zoom(1.))), - ] - .spacing(8) - .align_y(iced::Center), - ); - ( - canvas::Canvas::new(graph) - .width(size.width) - .height(size.height) - .into(), - size, - ) - } else { - let scene = Scene::sequence(p, owner, app.selected); - let size = scene.size; - let conversion = preserved_order(p, owner, None); - out = out.push(label( - "Ordered exchange path · the stored contribution order defines this sequence.", - )); - if !conversion.is_empty() { - out = out.push(action( - "Convert ordered path to explicit links", - Message::Command(unique_batch(p, conversion)), - )); - } - ( - canvas::Canvas::new(scene) - .width(size.width) - .height(size.height) - .into(), - size, - ) - }; - out = out.push( - iced::widget::sensor( - container( - scrollable(canvas) - .direction(scrollable::Direction::Both { - vertical: scrollable::Scrollbar::default(), - horizontal: scrollable::Scrollbar::default(), - }) - .height(size.height.clamp(200., 470.)), - ) - .padding(8) - .width(Fill) - .style(style::card), - ) - .on_show(|size| event(Event::Viewport(size))) - .on_resize(|size| event(Event::Viewport(size))), - ); - if let Some(SharedBody::Occurrence { - body: OccurrenceBody::SequenceReference { - sequence: Some(target), - }, - .. - }) = p.elements.get(&app.selected).and_then(|e| e.shared()) - { - out = out.push(action( - &format!("Open source: {} →", p.named(*target)), - Message::Select(*target), - )); - } - for r in sequence_links(p, owner) { - let guard = match &r.body { - RelationBody::SequenceLink { guard, .. } if !guard.is_empty() => format!(" [{guard}]"), - _ => String::new(), - }; - out = out.push( - action( - &format!("{} → {}{}", p.named(r.source()), p.named(r.target()), guard), - Message::Select(r.id), - ) - .width(Fill), - ); - } - scrollable(out.padding(24)).into() -} - fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> { p.relations .values() @@ -942,6 +709,7 @@ struct GraphNode { source: Option, preview: Vec<(Id, String)>, preview_links: Vec<(Id, Id)>, + preview_total: usize, } struct GraphEdge { id: Id, @@ -951,15 +719,21 @@ struct GraphEdge { points: Vec, label: Rectangle, } -struct Graph { +struct Graph<'a> { + project: &'a Project, + interaction: Option<(Context, CanvasTool, bool)>, + scroll_offset: iced::Vector, nodes: Vec, edges: Vec, size: Size, selected: Id, scale: f32, } -impl Graph { - fn new(p: &Project, owner: Id, selected: Id) -> Self { +impl<'a> Graph<'a> { + fn new(p: &'a Project, owner: Id, selected: Id) -> Self { + Self::expanded(p, owner, selected, &BTreeSet::new()) + } + fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet) -> Self { let choices = contributions(p, owner); let links = sequence_links(p, owner); let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect(); @@ -1000,17 +774,17 @@ impl Graph { fallback += 1; } } - let mut rows: BTreeMap = BTreeMap::new(); + let mut rows: BTreeMap = BTreeMap::new(); let mut nodes = vec![]; let mut size: Size = Size::new(640., 220.); for c in choices { let e = &p.elements[&c.id]; let rank = ranks[&c.id]; - let slot = rows.entry(rank).or_default(); + let slot = rows.entry(rank).or_insert(24.); let mut node = GraphNode { id: e.id, rect: Rectangle::new( - Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.), + Point::new(24. + rank as f32 * 420., *slot), Size::new(270., 104.), ), title: e.name.clone(), @@ -1019,8 +793,8 @@ impl Graph { source: None, preview: vec![], preview_links: vec![], + preview_total: 0, }; - *slot += 1; if let Some(SharedBody::Occurrence { body, .. }) = e.shared() { match body { OccurrenceBody::Action { action, .. } => { @@ -1051,11 +825,19 @@ impl Graph { .map(|id| format!("ref · {}", p.named(id))) .unwrap_or("Choose source in Properties".into()); if let Some(source) = sequence { - node.preview = contributions(p, *source) + let contributions = contributions(p, *source); + node.preview_total = contributions.len(); + node.preview = contributions .into_iter() - .take(4) + .take(if expanded.contains(&e.id) { + usize::MAX + } else { + 4 + }) .map(|c| (c.id, p.named(c.id).to_owned())) .collect(); + node.rect.height = + (132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.); let preview_ids: BTreeSet<_> = node.preview.iter().map(|(id, _)| *id).collect(); node.preview_links = sequence_links(p, *source) @@ -1071,11 +853,13 @@ impl Graph { _ => {} } } + *slot += node.rect.height + 36.; size.width = size.width.max(node.rect.x + node.rect.width + 28.); size.height = size.height.max(node.rect.y + node.rect.height + 42.); nodes.push(node); } let mut edges = vec![]; + let mut routed_pairs = BTreeSet::new(); for (i, r) in links.into_iter().enumerate() { let a = nodes .iter() @@ -1089,11 +873,14 @@ impl Graph { .rect; let start = Point::new(a.x + a.width, a.center_y()); let end = Point::new(b.x, b.center_y()); - let points = if end.x > start.x { + // Parallel records keep distinct visible paths and guard hit targets. + // Their semantic endpoints and IDs remain unchanged. + let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target())); + let points = if forward { let mid = (start.x + end.x) / 2.; vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end] } else { - let below = size.height + 24. + i as f32 * 30.; + let below = size.height + 24. + i as f32 * 42.; vec![ start, Point::new(start.x + 18., start.y), @@ -1107,7 +894,7 @@ impl Graph { RelationBody::SequenceLink { guard, .. } => guard.clone(), _ => String::new(), }; - let label_at = if end.x > start.x { + let label_at = if forward { Point::new(end.x - 138., end.y - 43.) } else { Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.) @@ -1128,6 +915,9 @@ impl Graph { } } Self { + project: p, + interaction: None, + scroll_offset: iced::Vector::ZERO, nodes, edges, size, @@ -1204,47 +994,24 @@ fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) { Stroke::default().with_color(color).with_width(width), ); } -impl canvas::Program for Graph { - type State = (); +impl canvas::Program for Graph<'_> { + type State = Input; fn update( &self, - _: &mut (), + state: &mut Input, event: &canvas::Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { - if matches!( - event, - canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - ) && let Some(point) = cursor.position_in(bounds) - { - let point = Point::new(point.x / self.scale, point.y / self.scale); - if let Some(n) = self.nodes.iter().rev().find(|n| n.rect.contains(point)) { - let id = if point.y > n.rect.y + 75. { - n.source.unwrap_or(n.id) - } else { - n.id - }; - return Some(Action::publish(Message::Select(id)).and_capture()); - } - if let Some(e) = self.edges.iter().find(|e| { - (!e.guard.is_empty() && e.label.contains(point)) - || e.points - .windows(2) - .any(|v| crate::diagram::distance_to_segment(point, v[0], v[1]) < 8.) - }) { - return Some(Action::publish(Message::Select(e.id)).and_capture()); - } - } - None + self.input(state, event, bounds, cursor) } fn draw( &self, - _: &(), + state: &Input, renderer: &Renderer, _: &Theme, bounds: Rectangle, - _: mouse::Cursor, + cursor: mouse::Cursor, ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); @@ -1327,7 +1094,7 @@ impl canvas::Program for Graph { if n.source.is_some() { draw_text( &mut f, - "Open source →".into(), + format!("Source preview · {} / {}", n.preview.len(), n.preview_total), Point::new(n.rect.x + 12., n.rect.y + 94.), 11., style::TEAL, @@ -1400,11 +1167,12 @@ impl canvas::Program for Graph { ); } } + self.overlay(state, &mut f, cursor, bounds); vec![f.into_geometry()] } fn mouse_interaction( &self, - _: &(), + _: &Input, bounds: Rectangle, cursor: mouse::Cursor, ) -> mouse::Interaction { @@ -1478,6 +1246,9 @@ mod tests { } fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft { Draft { + insertion: Insertion::default(), + fixed: false, + browsers: Default::default(), owner, tool: Tool::Branch, name: String::new(), @@ -1666,7 +1437,7 @@ mod tests { let command = branch(owner, a, b, before, after).command(&p).unwrap(); session.execute(command).unwrap(); let result = session.project(); - assert!(!result.relations.contains_key(&old_link)); + assert!(result.relations.contains_key(&old_link)); assert_eq!(result.elements[&owner].steps().unwrap().len(), 6); assert_eq!(sequence_links(result, owner).len(), 6); assert_eq!( @@ -1777,10 +1548,26 @@ mod tests { draft: Some(branch(owner, a, b, before, after)), ..State::default() }; - state.update(&p, Event::Guard(String::new())).unwrap(); + state + .update( + &p, + Event::Target(Choice { + id: Id::nil(), + label: "Missing".into(), + }), + ) + .unwrap(); assert!(state.update(&p, Event::Submit).is_err()); assert!(state.draft.is_some()); - state.update(&p, Event::Guard("ready".into())).unwrap(); + state + .update( + &p, + Event::Target(Choice { + id: a, + label: "Work".into(), + }), + ) + .unwrap(); let command = state.update(&p, Event::Submit).unwrap().unwrap(); assert!(state.draft.is_some()); assert!(matches!(command, Command::Batch(_))); @@ -1836,9 +1623,8 @@ mod tests { .unwrap(); let mut graph = Graph::new(&p, owner, owner); let mut state = State::default(); - state - .update(&p, Event::Viewport(Size::new(486., 460.))) - .unwrap(); + state.viewport(Size::new(486., 460.)); + state.zoom(None); graph.scale = state.scale(graph.size, 500.); assert!(graph.size.width * graph.scale <= 462.1); assert!(graph.size.height * graph.scale <= 450.1); @@ -1849,7 +1635,7 @@ mod tests { ); let result = canvas::Program::update( &graph, - &mut (), + &mut Input::default(), &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Rectangle::new(Point::ORIGIN, Size::new(486., 460.)), mouse::Cursor::Available(click), @@ -1858,12 +1644,10 @@ mod tests { .into_inner() .0; assert!(matches!(result, Some(Message::Select(id)) if id == node.id)); - state.update(&p, Event::Zoom(1.)).unwrap(); - state - .update(&p, Event::Viewport(Size::new(800., 460.))) - .unwrap(); + state.zoom(Some(1.)); + state.viewport(Size::new(800., 460.)); assert_eq!(state.scale(graph.size, 500.), 1.); - state.update(&p, Event::Fit).unwrap(); + state.zoom(None); assert!(state.scale(graph.size, 500.) < 1.); } } diff --git a/crates/oa-desktop/src/behavior/topology/direct.rs b/crates/oa-desktop/src/behavior/topology/direct.rs new file mode 100644 index 00000000..b60ccd9c --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -0,0 +1,736 @@ +//! Local pointer state and ordinary command proposals for the derived process graph. +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Context { + pub document: Id, + pub owner: Id, + pub revision: u64, + pub epoch: u64, +} +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Insertion { + pub link: Option, + pub before: Option, +} +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum CanvasTool { + #[default] + Select, + Add(Tool, ControlKind), + Connect, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum End { + Source, + Target, +} +#[derive(Debug, Clone)] +pub enum CanvasEvent { + Scrolled(Context, scenario::ScrollArea, iced::Vector), + Choose(Context, CanvasTool), + Select(Context, Id), + Begin { + context: Context, + tool: Tool, + control: ControlKind, + insertion: Insertion, + }, + Connect { + context: Context, + source: Id, + target: Id, + }, + Reconnect { + context: Context, + link: Id, + end: End, + target: Id, + }, + Reorder { + context: Context, + occurrence: Id, + before: Id, + }, + Expand(Context, Id), + Open(Context, Id), + Back(Context), + Zoom(Context, Option), + Viewport(Context, Size), +} +impl CanvasEvent { + pub fn context(&self) -> Context { + match self { + Self::Scrolled(c, ..) + | Self::Choose(c, ..) + | Self::Select(c, ..) + | Self::Expand(c, ..) + | Self::Open(c, ..) + | Self::Back(c) + | Self::Zoom(c, ..) + | Self::Viewport(c, ..) => *c, + Self::Begin { context, .. } + | Self::Connect { context, .. } + | Self::Reconnect { context, .. } + | Self::Reorder { context, .. } => *context, + } + } +} +impl State { + pub(crate) fn enter(&mut self, document: Id, owner: Id) { + if self.identity != Some((document, owner)) { + self.identity = Some((document, owner)); + self.tool = CanvasTool::Select; + self.zoom = Some(1.); + self.fit = false; + self.viewport = None; + self.expanded.clear(); + self.scroll_offsets = [iced::Vector::ZERO; 2]; + } + } + pub(crate) fn scroll_offset(&self) -> iced::Vector { + self.scroll_offsets[0] + self.scroll_offsets[1] + } + pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool { + let index = match area { + scenario::ScrollArea::Canvas => 0, + scenario::ScrollArea::Page => 1, + }; + let changed = self.scroll_offsets[index] != offset; + self.scroll_offsets[index] = offset; + changed + } + pub(crate) fn choose(&mut self, tool: CanvasTool) { + self.tool = tool; + if tool != CanvasTool::Select && self.fit { + self.fit = false; + self.zoom = Some(1.); + } + } + pub(crate) fn zoom(&mut self, value: Option) { + self.fit = value.is_none(); + self.zoom = value.map(|v| v.clamp(0.5, 2.)); + } + pub(crate) fn viewport(&mut self, size: Size) -> bool { + let changed = self.viewport != Some(size); + self.viewport = Some(size); + changed + } + pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool { + if !is_contribution(p, owner, id) + || !matches!( + p.elements[&id].shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { .. }, + .. + }) + ) + { + return false; + } + if !self.expanded.remove(&id) { + self.expanded.insert(id); + } + true + } + pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) { + self.return_to.push((owner, occurrence)); + } + pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> { + while let Some((owner, occurrence)) = self.return_to.pop() { + if is_contribution(p, owner, occurrence) { + return Some((owner, occurrence)); + } + } + None + } + pub(crate) fn created_selection(&self, command: &Command) -> Option { + let owner = self.pending_owner()?; + let Command::Batch(commands) = command else { + return None; + }; + commands.iter().find_map(|c| match c { + Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id), + _ => None, + }) + } + pub(crate) fn begin( + &mut self, + p: &Project, + context: Context, + tool: Tool, + control: ControlKind, + insertion: Insertion, + ) -> Result<(), String> { + if self.draft.is_some() { + return Err(PENDING_DRAFT.into()); + } + if !matches!( + tool, + Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control + ) { + return Err("Choose a contribution tool.".into()); + } + if !legacy_order(p, context.owner).is_empty() { + return Err("Convert the ordered path to explicit links before drawing.".into()); + } + let link = insertion + .link + .map(|id| { + p.relations + .get(&id) + .filter(|r| { + r.kind() == RelationKind::SequenceLink + && is_contribution(p, context.owner, r.source()) + && is_contribution(p, context.owner, r.target()) + }) + .ok_or("Choose a sequence link in this process.") + }) + .transpose()?; + if insertion + .before + .is_some_and(|id| !is_contribution(p, context.owner, id)) + { + return Err("Choose a presentation slot in this process.".into()); + } + self.update(p, Event::Start(context.owner, tool, None))?; + let d = self.draft.as_mut().expect("started form"); + d.fixed = true; + d.insertion = insertion; + d.control = control; + if let Some(r) = link { + d.before = Some(r.source()); + d.after = Some(r.target()); + } + Ok(()) + } + pub(crate) fn connect( + &mut self, + p: &Project, + owner: Id, + source: Id, + target: Id, + ) -> Result<(), String> { + if self.draft.is_some() { + return Err(PENDING_DRAFT.into()); + } + if !is_contribution(p, owner, source) + || !is_contribution(p, owner, target) + || !validation::can_connect(p, RelationKind::SequenceLink, source, target) + { + return Err("Choose compatible contributions directly owned by this process.".into()); + } + self.update(p, Event::Start(owner, Tool::Connect, None))?; + let d = self.draft.as_mut().expect("started form"); + d.fixed = true; + d.before = Some(source); + d.after = Some(target); + Ok(()) + } +} +pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option { + if !is_contribution(p, owner, occurrence) { + return None; + } + match p.elements[&occurrence].shared()? { + SharedBody::Occurrence { + body: OccurrenceBody::Action { action, .. }, + .. + } => *action, + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { exchange, .. }, + .. + } => *exchange, + SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence }, + .. + } => *sequence, + _ => None, + } +} +pub(crate) fn reconnect( + p: &Project, + owner: Id, + id: Id, + end: End, + endpoint: Id, +) -> Result { + let mut r = p + .relations + .get(&id) + .filter(|r| { + r.kind() == RelationKind::SequenceLink + && is_contribution(p, owner, r.source()) + && is_contribution(p, owner, r.target()) + }) + .ok_or("Choose a sequence link in this process.")? + .clone(); + if !is_contribution(p, owner, endpoint) { + return Err("Choose a local contribution endpoint.".into()); + } + if let RelationBody::SequenceLink { source, target, .. } = &mut r.body { + match end { + End::Source => *source = endpoint, + End::Target => *target = endpoint, + } + } + let command = Command::EditRelation(id, r); + execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; + Ok(command) +} +pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result { + if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) { + return Err("Reorder contributions in this process only.".into()); + } + let graph = Graph::new(p, owner, id); + let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x); + if position(id) != position(before) { + return Err("Reorder within this column; links determine the path columns.".into()); + } + let mut steps = p.elements[&owner].steps().unwrap().clone(); + if id != before { + steps.retain(|v| *v != id); + let index = steps + .iter() + .position(|v| *v == before) + .ok_or("This slot no longer exists.")?; + steps.insert(index, id); + } + let command = Command::SetSteps(owner, steps); + execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; + Ok(command) +} + +#[derive(Default)] +pub(super) struct Input { + context: Option, + tool: CanvasTool, + pointer: Option, + bounds: Option, + source: Option, + press: Option, +} +#[derive(Clone, Copy)] +enum Press { + Connect { + source: Id, + first: bool, + start: Point, + moved: bool, + }, + Reconnect { + link: Id, + end: End, + start: Point, + moved: bool, + }, + Reorder { + occurrence: Id, + start: Point, + moved: bool, + }, + Add { + insertion: Insertion, + }, +} +impl Input { + fn cancel(&mut self) { + self.source = None; + self.press = None; + } +} +impl Graph<'_> { + fn node_at(&self, point: Point) -> Option { + self.nodes + .iter() + .rev() + .find(|n| n.rect.contains(point)) + .map(|n| n.id) + } + fn edge_at(&self, point: Point) -> Option { + self.edges + .iter() + .find(|e| { + (!e.guard.is_empty() && e.label.contains(point)) + || e.points.windows(2).any(|v| { + crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale + }) + }) + .map(|e| e.id) + } + fn handle(&self, id: Id) -> Option { + let n = self.nodes.iter().find(|n| n.id == id)?; + Some(Rectangle::new( + Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.), + Size::new(23., 24.), + )) + } + fn endpoint_at(&self, point: Point) -> Option<(Id, End)> { + let e = self.edges.iter().find(|e| e.id == self.selected)?; + [(e.points[0], End::Source), (*e.points.last()?, End::Target)] + .into_iter() + .find(|(p, _)| p.distance(point) < 10. / self.scale) + .map(|(_, end)| (e.id, end)) + } + fn connectable(&self, source: Id, target: Id) -> bool { + validation::can_connect(self.project, RelationKind::SequenceLink, source, target) + } + pub(super) fn input( + &self, + state: &mut Input, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + let (context, tool, editable) = self + .interaction + .map_or((None, CanvasTool::Select, false), |(c, t, e)| { + (Some(c), t, e) + }); + if state.context != context + || state.tool != tool + || state.bounds.is_some_and(|b| b != bounds) + { + state.cancel(); + state.pointer = None; + state.context = context; + state.tool = tool; + } + state.bounds = Some(bounds); + if !editable { + state.cancel(); + } + let visible = cursor.position().is_some(); + if !visible + && matches!( + event, + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + ) + { + state.cancel(); + return Some(Action::request_redraw()); + } + let cursor = match event { + canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => { + state.pointer = Some(*position); + if let Some( + Press::Connect { start, moved, .. } + | Press::Reconnect { start, moved, .. } + | Press::Reorder { start, moved, .. }, + ) = &mut state.press + { + *moved |= position.distance(*start) >= 3.; + } + mouse::Cursor::Available(*position + self.scroll_offset) + } + canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft) + | canvas::Event::Window( + iced::window::Event::Resized(_) | iced::window::Event::Unfocused, + ) => { + state.cancel(); + state.pointer = None; + return Some(Action::request_redraw()); + } + canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + .. + }) => { + state.cancel(); + return Some(Action::request_redraw().and_capture()); + } + canvas::Event::Mouse( + mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), + ) => state + .pointer + .map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)), + canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { + state.pointer = cursor.position().map(|p| p - self.scroll_offset); + cursor + } + _ => cursor, + }; + let world = cursor + .position_in(bounds) + .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); + let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture()); + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => { + let point = world?; + let Some(context) = context else { + return self + .node_at(point) + .or_else(|| self.edge_at(point)) + .map(|id| Action::publish(Message::Select(id)).and_capture()); + }; + if !editable { + return None; + } + let start = cursor.position()? - self.scroll_offset; + match tool { + CanvasTool::Add(..) => { + state.press = Some(Press::Add { + insertion: Insertion { + link: self + .edge_at(point) + .filter(|_| self.node_at(point).is_none()), + before: self.node_at(point), + }, + }); + } + CanvasTool::Connect => { + let first = state.source.is_none(); + let source = state.source.or_else(|| self.node_at(point))?; + state.source = Some(source); + state.press = Some(Press::Connect { + source, + first, + start, + moved: false, + }); + } + CanvasTool::Select => { + if let Some((link, end)) = self.endpoint_at(point) { + state.press = Some(Press::Reconnect { + link, + end, + start, + moved: false, + }); + } else if self + .handle(self.selected) + .is_some_and(|r| r.contains(point)) + { + state.press = Some(Press::Reorder { + occurrence: self.selected, + start, + moved: false, + }); + } else { + return self + .node_at(point) + .or_else(|| self.edge_at(point)) + .and_then(|id| publish(CanvasEvent::Select(context, id))); + } + } + } + Some(Action::request_redraw().and_capture()) + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let press = state.press.take()?; + let context = context?; + match press { + Press::Add { insertion } if world.is_some() => { + if let CanvasTool::Add(tool, control) = tool { + state.cancel(); + return publish(CanvasEvent::Begin { + context, + tool, + control, + insertion, + }); + } + } + Press::Connect { + source, + first, + moved, + .. + } if moved || !first => { + if let Some(target) = world + .and_then(|p| self.node_at(p)) + .filter(|target| self.connectable(source, *target)) + { + state.cancel(); + return publish(CanvasEvent::Connect { + context, + source, + target, + }); + } + } + Press::Reconnect { + link, + end, + moved: true, + .. + } => { + if let Some(target) = world.and_then(|p| self.node_at(p)) { + state.cancel(); + return publish(CanvasEvent::Reconnect { + context, + link, + end, + target, + }); + } + } + Press::Reorder { + occurrence, + moved: true, + .. + } => { + if let Some(before) = world.and_then(|p| self.node_at(p)) { + state.cancel(); + return publish(CanvasEvent::Reorder { + context, + occurrence, + before, + }); + } + } + _ => {} + } + Some(Action::request_redraw().and_capture()) + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { + Some(Action::request_redraw()) + } + _ => None, + } + } + pub(super) fn overlay( + &self, + state: &Input, + f: &mut Frame, + cursor: mouse::Cursor, + bounds: Rectangle, + ) { + let Some((context, tool, editable)) = self.interaction else { + return; + }; + if !editable { + return; + } + let point = state + .pointer + .map(|p| p + self.scroll_offset) + .or_else(|| cursor.position()) + .filter(|p| bounds.contains(*p)) + .map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale)); + let live = + state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds); + if live && let Some(source) = state.source { + for n in &self.nodes { + if n.id == source || self.connectable(source, n.id) { + f.stroke( + &Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()), + Stroke::default().with_color(style::TEAL).with_width(2.5), + ); + } + } + if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) { + draw_arrow( + f, + &[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to], + style::TEAL, + 2., + ); + } + } + if live + && let Some(Press::Reconnect { link, end, .. }) = state.press + && let Some(edge) = self.edges.iter().find(|e| e.id == link) + { + for node in &self.nodes { + let (source, target) = match end { + End::Source => (node.id, edge.target), + End::Target => (edge.source, node.id), + }; + if self.connectable(source, target) { + f.stroke( + &Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()), + Stroke::default().with_color(style::TEAL).with_width(2.5), + ); + } + } + if let Some(point) = point { + let (from, to) = match end { + End::Source => (point, *edge.points.last().unwrap()), + End::Target => (edge.points[0], point), + }; + draw_arrow(f, &[from, to], style::TEAL, 2.5); + } + } + if live + && let Some(Press::Reorder { occurrence, .. }) = state.press + && let Some(point) = point + && let Some(target) = self.node_at(point) + { + let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap(); + let target = self.nodes.iter().find(|n| n.id == target).unwrap(); + if source.rect.x == target.rect.x { + f.stroke( + &Path::line( + Point::new(target.rect.x, target.rect.y - 7.), + Point::new(target.rect.x + target.rect.width, target.rect.y - 7.), + ), + Stroke::default().with_color(style::TEAL).with_width(3.), + ); + draw_text( + f, + "Order before · paths unchanged".into(), + Point::new(target.rect.x, target.rect.y - 24.), + 12., + style::TEAL, + ); + } + } + if tool == CanvasTool::Select { + if let Some(r) = self.handle(self.selected) { + f.fill_rectangle(r.position(), r.size(), Color::WHITE); + draw_text(f, "↕".into(), r.position(), 17., style::TEAL); + } + if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) { + for p in [e.points[0], *e.points.last().unwrap()] { + f.fill(&Path::circle(p, 6. / self.scale), style::TEAL); + } + } + } + if let CanvasTool::Add(..) = tool + && let Some(point) = point + { + let at = self + .edge_at(point) + .filter(|_| self.node_at(point).is_none()); + let caption = if let Some(id) = at { + if let Some(e) = self.edges.iter().find(|e| e.id == id) { + draw_arrow(f, &e.points, style::TEAL, 3.); + } + "Insert on this path · preserve its guard" + } else if self.node_at(point).is_some() { + "Add before this contribution · unconnected" + } else { + "Append contribution · unconnected" + }; + draw_text( + f, + caption.into(), + Point::new(point.x + 12., point.y + 16.), + 12. / self.scale, + style::TEAL, + ); + } + } +} + +impl State { + pub(crate) fn selection_offset( + &self, + p: &Project, + owner: Id, + id: Id, + ) -> Option { + let graph = Graph::expanded(p, owner, id, &self.expanded); + let scale = self.scale(graph.size, 500.); + let rect = graph + .nodes + .iter() + .find(|n| n.id == id) + .map(|n| n.rect) + .or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?; + Some(scrollable::AbsoluteOffset { + x: (rect.x * scale - 12.).max(0.), + y: (rect.y * scale - 12.).max(0.), + }) + } +} diff --git a/crates/oa-desktop/src/behavior/topology/direct_tests.rs b/crates/oa-desktop/src/behavior/topology/direct_tests.rs new file mode 100644 index 00000000..8d2129c1 --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/direct_tests.rs @@ -0,0 +1,850 @@ +use super::*; +use crate::document::Document; +use oa_core::source_project::{ProjectLimits, SourceBundle}; + +pub(crate) struct Fixture { + pub p: Project, + pub owner: Id, + pub work: [Id; 3], + pub nodes: [Id; 3], + pub exchange: Id, + pub link: Id, +} +pub(crate) fn fixture(layer: Layer) -> Fixture { + let mut p = Project::empty(); + let owner = p + .make_element( + if layer == Layer::Oa { + Kind::Process + } else { + Kind::FunctionalChain + }, + layer, + "Respond safely", + None, + ) + .unwrap(); + let owner_id = owner.id; + p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap(); + let mut work = Vec::new(); + let mut nodes = Vec::new(); + for name in ["Assess", "Respond", "Recover"] { + let e = p + .make_element( + if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }, + layer, + name, + None, + ) + .unwrap(); + let id = e.id; + p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap(); + work.push(id); + let e = occurrence( + &p, + owner_id, + name, + OccurrenceBody::Action { + action: Some(id), + lifeline: None, + }, + ); + let id = e.id; + p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap(); + nodes.push(id); + } + let kind = if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }; + p = execute_batch( + &p, + &[Command::Connect( + kind, + "Assessment".into(), + work[0], + work[1], + )], + ) + .unwrap(); + let exchange = p.relations.values().find(|r| r.kind() == kind).unwrap().id; + let Command::CreateRelation(mut r) = link(&p, nodes[0], nodes[1], "approved") else { + unreachable!() + }; + r.alias = "assessed_path".into(); + r.description = "Reviewed safety rationale".into(); + let link_id = r.id; + p = execute_batch(&p, &[Command::CreateRelation(r)]).unwrap(); + Fixture { + p, + owner: owner_id, + work: work.try_into().unwrap(), + nodes: nodes.try_into().unwrap(), + exchange, + link: link_id, + } +} +pub(crate) fn context(owner: Id) -> Context { + Context { + document: Id::new_v4(), + owner, + revision: 0, + epoch: 0, + } +} +fn command(f: &Fixture, tool: Tool, target: Option, insertion: Insertion) -> Command { + let mut state = State::default(); + state + .begin(&f.p, context(f.owner), tool, ControlKind::Choice, insertion) + .unwrap(); + if let Some(id) = target { + state + .update( + &f.p, + Event::Target(Choice { + id, + label: "Source".into(), + }), + ) + .unwrap(); + } + state.prepare_pending(&f.p).unwrap() +} +fn created(p: &Project, after: &Project, owner: Id) -> Id { + after + .children(owner) + .find(|e| !p.contains(e.id)) + .unwrap() + .id +} +#[test] +fn direct_process_oa_sa_repeated_occurrences_share_sources_and_split_one_guarded_link() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer); + let original = f.p.clone(); + let add = command(&f, Tool::Work, Some(f.work[0]), Insertion::default()); + let result = execute_batch(&f.p, &[add]).unwrap(); + let repeated = created(&f.p, &result, f.owner); + assert_ne!(repeated, f.nodes[0]); + assert_eq!(result.elements[&f.work[0]], f.p.elements[&f.work[0]]); + assert!( + matches!(result.elements[&repeated].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Action{action:Some(id),..},..})if *id==f.work[0]) + ); + f.p = result; + let plan = command( + &f, + Tool::Exchange, + Some(f.exchange), + Insertion { + link: Some(f.link), + before: None, + }, + ); + let serialized = serde_json::to_string(&plan).unwrap(); + let api: Command = serde_json::from_str(&serialized).unwrap(); + assert_eq!(api, plan); + let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap(); + let before = doc.bundle().clone(); + doc.execute(plan).unwrap(); + let inserted = created(&f.p, doc.project(), f.owner); + let r = &doc.project().relations[&f.link]; + let expected = &f.p.relations[&f.link]; + assert_eq!(r.id, expected.id); + assert_eq!(r.alias, expected.alias); + assert_eq!(r.description, expected.description); + assert_eq!(r.source(), expected.source()); + assert_eq!(r.target(), inserted); + assert!(matches!(&r.body,RelationBody::SequenceLink{guard,..}if guard=="approved")); + assert!( + sequence_links(doc.project(), f.owner) + .iter() + .any(|r| r.source() == inserted && r.target() == f.nodes[1]) + ); + let steps = doc.project().elements[&f.owner].steps().unwrap(); + assert_eq!(&steps[..3], &[f.nodes[0], inserted, f.nodes[1]]); + assert_eq!( + doc.project().relations[&f.exchange], + original.relations[&f.exchange] + ); + assert_eq!(doc.project(), &execute_batch(&f.p, &[api]).unwrap()); + let after = doc.bundle().clone(); + assert!(doc.undo()); + assert_eq!(doc.bundle(), &before); + assert!(doc.redo()); + assert_eq!(doc.bundle(), &after); + let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap(); + assert_eq!(reopened.project(), doc.project()); + } +} +#[test] +fn direct_process_specific_parallel_link_and_incompatible_adjacency() { + let mut f = fixture(Layer::Oa); + let Command::CreateRelation(mut second) = link(&f.p, f.nodes[0], f.nodes[1], "urgent") else { + unreachable!() + }; + second.alias = "urgent_path".into(); + let second_id = second.id; + f.p = execute_batch(&f.p, &[Command::CreateRelation(second)]).unwrap(); + let graph = Graph::new(&f.p, f.owner, f.owner); + for edge in &graph.edges { + let clicked = send( + &graph, + &mut Input::default(), + mouse::Event::ButtonPressed(mouse::Button::Left), + edge.label.center(), + Rectangle::new(Point::ORIGIN, graph.size), + ); + assert!( + matches!(clicked,Some(Message::Select(id))if id==edge.id), + "Each guarded parallel path must be individually selectable" + ); + } + + let plan = command( + &f, + Tool::Control, + None, + Insertion { + link: Some(f.link), + before: None, + }, + ); + let after = execute_batch(&f.p, &[plan]).unwrap(); + assert_eq!(after.relations[&second_id], f.p.relations[&second_id]); + let mut state = State::default(); + state + .begin( + &f.p, + context(f.owner), + Tool::Exchange, + ControlKind::Choice, + Insertion { + link: Some(f.link), + before: None, + }, + ) + .unwrap(); + let wrong = execute_batch( + &f.p, + &[Command::Connect( + RelationKind::Interaction, + "Reverse".into(), + f.work[2], + f.work[0], + )], + ) + .unwrap(); + let wrong_id = wrong + .relations + .values() + .find(|r| r.name == "Reverse") + .unwrap() + .id; + state + .update( + &wrong, + Event::Target(Choice { + id: wrong_id, + label: "Wrong".into(), + }), + ) + .unwrap(); + assert!(state.prepare_pending(&wrong).is_err()); + assert!(state.draft.is_some()); + assert!(direct::reconnect(&f.p, f.owner, f.link, direct::End::Target, f.nodes[0]).is_err()); +} +#[test] +fn direct_process_controls_allow_incomplete_work_and_preserve_branch_semantics() { + let f = fixture(Layer::Oa); + for kind in [ + ControlKind::Choice, + ControlKind::Merge, + ControlKind::Fork, + ControlKind::Join, + ControlKind::Loop, + ] { + let mut state = State::default(); + state + .begin( + &f.p, + context(f.owner), + Tool::Control, + kind, + Insertion::default(), + ) + .unwrap(); + let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap(); + let id = created(&f.p, &result, f.owner); + assert!( + matches!(result.elements[&id].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Control{kind:k},..})if *k==kind) + ); + assert!( + validation::validate(&result) + .iter() + .any(|d| d.element == Some(id) + && d.code == "control_cardinality" + && d.severity == validation::Severity::Warning) + ); + } + for kind in [ControlKind::Choice, ControlKind::Fork] { + let mut state = State::default(); + state + .update(&f.p, Event::Start(f.owner, Tool::Branch, Some(f.nodes[0]))) + .unwrap(); + for event in [ + Event::Target(Choice { + id: f.work[0], + label: "A".into(), + }), + Event::Alternate(Choice { + id: f.work[2], + label: "B".into(), + }), + Event::Control(ControlChoice(kind)), + Event::Guard("ready".into()), + ] { + state.update(&f.p, event).unwrap(); + } + let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap(); + assert!( + matches!(&result.relations[&f.link].body,RelationBody::SequenceLink{guard,..}if guard=="approved") + ); + let branch_guards: Vec<_> = sequence_links(&result, f.owner) + .into_iter() + .filter(|r| !f.p.relations.contains_key(&r.id)) + .filter_map(|r| { + if let RelationBody::SequenceLink { guard, .. } = &r.body { + Some(guard.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!( + branch_guards.contains(&"ready"), + kind == ControlKind::Choice + ); + assert_eq!(branch_guards.contains(&"else"), kind == ControlKind::Choice); + } +} +#[test] +fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources() { + let f = fixture(Layer::Oa); + let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap(); + let original = doc.bundle().clone(); + doc.execute( + direct::reconnect( + doc.project(), + f.owner, + f.link, + direct::End::Target, + f.nodes[2], + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(doc.project().relations[&f.link].target(), f.nodes[2]); + assert_eq!(doc.project().relations[&f.link].alias, "assessed_path"); + let reconnected = doc.bundle().clone(); + doc.execute(Command::Delete(f.nodes[0])).unwrap(); + assert!(doc.project().contains(f.work[0])); + assert!(doc.project().contains(f.exchange)); + assert!(doc.project().contains(f.nodes[1])); + assert!(!doc.project().contains(f.link)); + doc.undo(); + assert_eq!(doc.bundle(), &reconnected); + doc.undo(); + assert_eq!(doc.bundle(), &original); + let reordered = execute_batch( + &f.p, + &[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()], + ) + .unwrap(); + assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]); + assert_eq!(reordered.relations, f.p.relations); + assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err()); +} +#[test] +fn direct_process_reuse_preview_is_local_expandable_and_recursive_sources_are_rejected() { + let mut f = fixture(Layer::Oa); + let source = + f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None) + .unwrap(); + let source_id = source.id; + f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap(); + for i in 0..8 { + let e = occurrence( + &f.p, + source_id, + &format!("Shared step {i}"), + OccurrenceBody::Action { + action: Some(f.work[0]), + lifeline: None, + }, + ); + f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap(); + } + let shared = f.p.clone(); + let mut references = Vec::new(); + for _ in 0..2 { + let plan = command(&f, Tool::Reuse, Some(source_id), Insertion::default()); + let result = execute_batch(&f.p, &[plan]).unwrap(); + references.push(created(&f.p, &result, f.owner)); + f.p = result; + } + let graph = Graph::new(&f.p, f.owner, references[0]); + let node = graph.nodes.iter().find(|n| n.id == references[0]).unwrap(); + assert_eq!(node.preview.len(), 4); + assert_eq!(node.preview_total, 8); + let mut input = Input::default(); + let point = Point::new(node.rect.x + 20., node.rect.y + 140.); + let clicked = send( + &graph, + &mut input, + mouse::Event::ButtonPressed(mouse::Button::Left), + point, + Rectangle::new(Point::ORIGIN, graph.size), + ); + assert!( + matches!(clicked,Some(Message::Select(id))if id==references[0]), + "preview click must select the local reference" + ); + let expanded = Graph::expanded( + &f.p, + f.owner, + references[0], + &BTreeSet::from([references[0]]), + ); + assert_eq!( + expanded + .nodes + .iter() + .find(|n| n.id == references[0]) + .unwrap() + .preview + .len(), + 8 + ); + assert_eq!( + expanded + .nodes + .iter() + .find(|n| n.id == references[1]) + .unwrap() + .preview + .len(), + 4 + ); + for e in shared.children(source_id) { + assert_eq!(f.p.elements[&e.id], *e); + } + assert!( + !targets(&f.p, source_id, Tool::Reuse) + .iter() + .any(|c| c.id == f.owner) + ); + let inside = f.p.elements[&source_id].steps().unwrap()[0]; + assert!(!validation::can_connect( + &f.p, + RelationKind::SequenceLink, + references[0], + inside + )); +} +#[test] +fn direct_process_nested_source_reopen_and_api_command_use_one_transaction() { + let bundle=SourceBundle{entry:"project.arcadia".into(),files:[ + ("project.arcadia".into(),"arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; include \"oa/process.arcadia\"; }\n".into()), + ("oa/work.arcadia".into(),"// Shared work\noa_activity assess \"Assess\" {}\n".into()), + ("oa/process.arcadia".into(),"// Contextual process\noa_process respond \"Respond\" {}\n".into()), + ].into()}; + let mut doc = Document::from_bundle(bundle, ProjectLimits::default()).unwrap(); + let original = doc.bundle().clone(); + let owner = doc + .project() + .elements + .values() + .find(|e| e.alias == "respond") + .unwrap() + .id; + let work = doc + .project() + .elements + .values() + .find(|e| e.alias == "assess") + .unwrap() + .id; + let mut state = State::default(); + state + .begin( + doc.project(), + context(owner), + Tool::Work, + ControlKind::Choice, + Insertion::default(), + ) + .unwrap(); + state + .update( + doc.project(), + Event::Target(Choice { + id: work, + label: "Assess".into(), + }), + ) + .unwrap(); + let plan = state.prepare_pending(doc.project()).unwrap(); + let expected = execute_batch( + doc.project(), + &[serde_json::from_value(serde_json::to_value(&plan).unwrap()).unwrap()], + ) + .unwrap(); + doc.execute(plan).unwrap(); + let id = doc.project().elements[&owner].steps().unwrap()[0]; + assert_eq!( + doc.compiled().index.declarations[&id].path, + "oa/process.arcadia" + ); + assert_eq!( + doc.bundle().files["oa/work.arcadia"], + original.files["oa/work.arcadia"] + ); + assert_eq!(doc.project(), &expected); + let after = doc.bundle().clone(); + doc.undo(); + assert_eq!(doc.bundle(), &original); + doc.redo(); + assert_eq!(doc.bundle(), &after); + let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap(); + assert_eq!(reopened.project(), &expected); +} +fn graph(f: &Fixture, tool: CanvasTool) -> Graph<'_> { + let mut graph = Graph::new(&f.p, f.owner, f.owner); + graph.interaction = Some((context(f.owner), tool, true)); + graph +} +fn send( + graph: &Graph<'_>, + input: &mut Input, + event: mouse::Event, + point: Point, + bounds: Rectangle, +) -> Option { + canvas::Program::update( + graph, + input, + &canvas::Event::Mouse(event), + bounds, + mouse::Cursor::Available(point + graph.scroll_offset), + ) + .and_then(|a| a.into_inner().0) +} +fn position(graph: &Graph<'_>, id: Id) -> Point { + graph + .nodes + .iter() + .find(|n| n.id == id) + .unwrap() + .rect + .center() +} +#[test] +fn direct_process_production_click_click_drag_insert_and_scroll_coordinates() { + let f = fixture(Layer::Oa); + for drag in [false, true] { + let mut graph = graph(&f, CanvasTool::Connect); + graph.scroll_offset = iced::Vector::new(35., 90.); + let bounds = Rectangle::new(Point::new(10., 20.), graph.size); + let screen = + |id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) - graph.scroll_offset; + let a = screen(f.nodes[0]); + let b = screen(f.nodes[2]); + let mut input = Input::default(); + send( + &graph, + &mut input, + mouse::Event::CursorMoved { position: a }, + a, + bounds, + ); + send( + &graph, + &mut input, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + if !drag { + assert!( + send( + &graph, + &mut input, + mouse::Event::ButtonReleased(mouse::Button::Left), + a, + bounds + ) + .is_none() + ); + } + send( + &graph, + &mut input, + mouse::Event::CursorMoved { position: b }, + b, + bounds, + ); + if !drag { + send( + &graph, + &mut input, + mouse::Event::ButtonPressed(mouse::Button::Left), + b, + bounds, + ); + } + assert!( + matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Connect{source,target,..}))if source==f.nodes[0]&&target==f.nodes[2]) + ); + assert!( + send( + &graph, + &mut input, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + bounds + ) + .is_none() + ); + } + let graph = graph(&f, CanvasTool::Add(Tool::Exchange, ControlKind::Choice)); + let bounds = Rectangle::new(Point::ORIGIN, graph.size); + let e = &graph.edges[0]; + let point = Point::new((e.points[0].x + e.points[1].x) / 2., e.points[0].y); + let mut input = Input::default(); + send( + &graph, + &mut input, + mouse::Event::ButtonPressed(mouse::Button::Left), + point, + bounds, + ); + assert!( + matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),point,bounds),Some(Message::TopologyCanvas(CanvasEvent::Begin{insertion:Insertion{link:Some(id),..},..}))if id==f.link) + ); +} +#[test] +fn direct_process_production_stale_focus_scroll_bounds_and_pending_cancel() { + let f = fixture(Layer::Oa); + for change in 0..8 { + let mut graph = graph(&f, CanvasTool::Connect); + let bounds = Rectangle::new(Point::ORIGIN, graph.size); + let a = position(&graph, f.nodes[0]); + let b = position(&graph, f.nodes[2]); + let mut input = Input::default(); + send( + &graph, + &mut input, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + send( + &graph, + &mut input, + mouse::Event::CursorMoved { position: b }, + b, + bounds, + ); + let mut end_bounds = bounds; + match change { + 0 => graph.interaction.as_mut().unwrap().0.epoch += 1, + 1 => graph.interaction.as_mut().unwrap().2 = false, + 2 => end_bounds.y -= 40., + 3 => { + canvas::Program::update( + &graph, + &mut input, + &canvas::Event::Window(iced::window::Event::Unfocused), + bounds, + mouse::Cursor::Available(b), + ); + } + 4 => { + send( + &graph, + &mut input, + mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: 1. }, + }, + b, + bounds, + ); + } + 5 => graph.interaction.as_mut().unwrap().0.document = Id::new_v4(), + 6 => graph.interaction.as_mut().unwrap().1 = CanvasTool::Select, + _ => { + canvas::Program::update( + &graph, + &mut input, + &canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + modified_key: iced::keyboard::Key::Named( + iced::keyboard::key::Named::Escape, + ), + physical_key: iced::keyboard::key::Physical::Code( + iced::keyboard::key::Code::Escape, + ), + location: iced::keyboard::Location::Standard, + modifiers: iced::keyboard::Modifiers::empty(), + text: None, + repeat: false, + }), + bounds, + mouse::Cursor::Available(b), + ); + } + } + assert!( + send( + &graph, + &mut input, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + end_bounds + ) + .is_none(), + "change {change}" + ); + } +} +#[test] +fn direct_process_readable_default_fit_expansion_and_bounded_choices() { + let f = fixture(Layer::Oa); + let mut state = State::default(); + state.enter(Id::new_v4(), f.owner); + state.viewport(Size::new(500., 400.)); + assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.); + state.zoom(None); + assert!(state.scale(Size::new(3262., 220.), 500.) < 0.2); + state.zoom(Some(0.1)); + assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.5); + state.enter(Id::new_v4(), f.owner); + assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.); + let mut all = Vec::new(); + for page in 0..500usize.div_ceil(24) { + let range = view::choice_page(500, page); + assert!(range.len() <= 24); + all.extend(range); + } + assert_eq!(all, (0..500).collect::>()); + assert_eq!(view::choice_page(0, 90), 0..0); +} + +#[test] +fn direct_process_production_endpoint_and_peer_reorder_handles_publish_exact_identity() { + let f = fixture(Layer::Oa); + let mut graph = graph(&f, CanvasTool::Select); + let bounds = Rectangle::new(Point::ORIGIN, graph.size); + graph.selected = f.link; + let mut input = Input::default(); + let a = *graph + .edges + .iter() + .find(|e| e.id == f.link) + .unwrap() + .points + .last() + .unwrap(); + let b = position(&graph, f.nodes[2]); + send( + &graph, + &mut input, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + send( + &graph, + &mut input, + mouse::Event::CursorMoved { position: b }, + b, + bounds, + ); + assert!( + matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reconnect{link,end:direct::End::Target,target,..}))if link==f.link&&target==f.nodes[2]) + ); + graph.selected = f.nodes[2]; + let n = graph.nodes.iter().find(|n| n.id == f.nodes[2]).unwrap(); + let a = Point::new(n.rect.x + n.rect.width - 20., n.rect.y + 18.); + let b = position(&graph, f.nodes[0]); + let mut input = Input::default(); + send( + &graph, + &mut input, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + send( + &graph, + &mut input, + mouse::Event::CursorMoved { position: b }, + b, + bounds, + ); + assert!( + matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reorder{occurrence,before,..}))if occurrence==f.nodes[2]&&before==f.nodes[0]) + ); +} +#[test] +fn direct_process_real_picker_stays_bounded_and_search_keeps_selection() { + let f = fixture(Layer::Oa); + let mut state = State::default(); + state + .begin( + &f.p, + context(f.owner), + Tool::Work, + ControlKind::Choice, + Insertion::default(), + ) + .unwrap(); + state + .update( + &f.p, + Event::Target(Choice { + id: f.work[0], + label: "Keep".into(), + }), + ) + .unwrap(); + let mut choices: Vec<_> = (0..500) + .map(|i| Choice { + id: Id::new_v4(), + label: format!("Activity {i}"), + }) + .collect(); + choices.push(Choice { + id: f.work[0], + label: "Selected source".into(), + }); + fn nodes(tree: &iced::advanced::widget::Tree) -> usize { + 1 + tree.children.iter().map(nodes).sum::() + } + for page in 0..21 { + state.update(&f.p, Event::Page(0, page)).unwrap(); + let d = state.draft.as_ref().unwrap(); + let view = view::picker(choices.clone(), d, 0, d.target, "Choose", Event::Target); + assert!(nodes(&iced::advanced::widget::Tree::new(view.as_widget())) < 40); + } + state + .update(&f.p, Event::Search(0, "Activity 499".into())) + .unwrap(); + assert_eq!(state.draft.as_ref().unwrap().target, Some(f.work[0])); + assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0); +} diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs new file mode 100644 index 00000000..0df64063 --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -0,0 +1,464 @@ +use super::*; +use iced::widget::column; +pub(super) fn picker<'a>( + mut choices: Vec, + d: &'a Draft, + index: usize, + value: Option, + placeholder: &'a str, + change: fn(Choice) -> Event, +) -> Element<'a, Message> { + let selected = value + .and_then(|id| choices.iter().find(|c| c.id == id)) + .cloned(); + let browser = &d.browsers[index]; + let query = browser.search.trim().to_lowercase(); + choices.retain(|c| c.label.to_lowercase().contains(&query)); + let range = choice_page(choices.len(), browser.page); + let page = range.start / 24; + let mut out = column![ + text_input("Search compatible choices…", &browser.search) + .on_input(move |v| event(Event::Search(index, v))) + .padding(7) + .size(12) + ] + .spacing(5); + if let Some(selected) = selected + .as_ref() + .filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id)) + { + out = out.push(label(format!("Selected: {}", selected.label))); + } + out = out.push( + row![ + button("Previous") + .on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))), + text(format!( + "{} choices · {}/{}", + choices.len(), + page + 1, + choices.len().div_ceil(24).max(1) + )) + .size(11), + button("Next").on_press_maybe( + (range.end < choices.len()).then(|| event(Event::Page(index, page + 1))) + ) + ] + .spacing(6), + ); + out.push( + pick_list(choices[range].to_vec(), selected, move |v| event(change(v))) + .placeholder(placeholder) + .padding(8) + .text_size(12) + .width(Fill), + ) + .into() +} +pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range { + let start = requested.min(len.saturating_sub(1) / 24) * 24; + start..(start + 24).min(len) +} +pub(super) fn view<'a>( + app: &'a App, + owner: Id, + mut out: Column<'a, Message>, +) -> Element<'a, Message> { + let p = app.project(); + let context = app.topology_context().expect("visible process context"); + let state = &app.behavior.topology; + let pending = state.draft.is_some(); + let explicit = + uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty); + let selected = is_contribution(p, owner, app.selected).then_some(app.selected); + let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool)); + if !pending && explicit { + out = out + .push( + row![ + button("Select / reorder") + .style(if state.tool == CanvasTool::Select { + button::primary + } else { + style::nav + }) + .on_press(choose(CanvasTool::Select)), + button("Work") + .style( + if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) { + button::primary + } else { + style::nav + } + ) + .on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))), + button("Exchange") + .style( + if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) { + button::primary + } else { + style::nav + } + ) + .on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))), + button("Reuse") + .style( + if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) { + button::primary + } else { + style::nav + } + ) + .on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice))) + ] + .spacing(6), + ) + .push( + row![ + pick_list( + vec![ + ControlKind::Choice, + ControlKind::Merge, + ControlKind::Fork, + ControlKind::Join, + ControlKind::Loop + ] + .into_iter() + .map(ControlChoice) + .collect::>(), + if let CanvasTool::Add(Tool::Control, kind) = state.tool { + Some(ControlChoice(kind)) + } else { + None + }, + move |c| Message::TopologyCanvas(CanvasEvent::Choose( + context, + CanvasTool::Add(Tool::Control, c.0) + )) + ) + .placeholder("Draw control…") + .text_size(12) + .padding(8), + button("Sequence link") + .style(if state.tool == CanvasTool::Connect { + button::primary + } else { + style::nav + }) + .on_press(choose(CanvasTool::Connect)), + button("Two paths…").on_press(event(Event::Start( + owner, + Tool::Branch, + selected + ))) + ] + .spacing(6), + ); + } + out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else{match state.tool{ + CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column", + CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels", + CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}})); + if explicit { + let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded); + graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.)); + graph.interaction = Some(( + context, + state.tool, + !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(), + )); + graph.scroll_offset = state.scroll_offset(); + let scale = graph.scale; + out = out.push( + row![ + button("Fit overview") + .on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))), + button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom( + context, + Some(scale / 1.25) + ))), + label(format!("{:.0}%", scale * 100.)), + button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom( + context, + Some(scale * 1.25) + ))), + button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom( + context, + Some(1.) + ))) + ] + .spacing(6), + ); + if graph.nodes.is_empty() { + out = out.push(label( + "Choose Work, Exchange, Reuse or a control, then click the canvas to start.", + )); + } + let size = Size::new(graph.size.width * scale, graph.size.height * scale); + let canvas = scrollable( + canvas::Canvas::new(graph) + .width(size.width) + .height(size.height), + ) + .id("process-canvas") + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::default(), + horizontal: scrollable::Scrollbar::default(), + }) + .height(if pending { + (app.window_size.height - 510.).clamp(160., 240.) + } else { + (app.window_size.height - 420.).clamp(190., 420.) + }); + out = out.push( + iced::widget::sensor( + container(super::super::scenario_scroll::observe_topology( + context, + scenario::ScrollArea::Canvas, + canvas, + )) + .padding(8) + .width(Fill) + .style(style::card), + ) + .on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))) + .on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))), + ); + } else { + out = out.push(label( + "Ordered exchange path · convert explicitly before drawing topology.", + )); + let commands = preserved_order(p, owner, None); + if !commands.is_empty() { + out = out.push( + button("Convert ordered path to explicit links") + .on_press(Message::Command(unique_batch(p, commands))), + ); + } else { + out=out.push(label("A single exchange has no path link to convert. Involve work, a control or a reusable process to begin explicit topology.")) + .push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))), + button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))), + button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6)); + } + let scene = Scene::sequence(p, owner, app.selected); + let size = scene.size; + out = out.push( + scrollable( + canvas::Canvas::new(scene) + .width(size.width) + .height(size.height), + ) + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::default(), + horizontal: scrollable::Scrollbar::default(), + }) + .height(240.), + ); + } + if let Some(id) = selected { + let mut actions = + row![button("Contribution properties").on_press(Message::EditProperties(id))] + .spacing(6); + if let Some(target) = direct::referenced(p, owner, id) { + actions = actions.push( + button(text(format!("Open source: {}", short(p.named(target), 28))).size(12)) + .on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))), + ); + if matches!( + p.elements[&id].shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { .. }, + .. + }) + ) { + actions = actions.push( + button(if state.expanded.contains(&id) { + "Collapse" + } else { + "Expand" + }) + .on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))), + ); + } + } + out = out.push(actions); + } else if sequence_links(p, owner) + .iter() + .any(|r| r.id == app.selected) + { + out = out.push( + button("Path properties / guard").on_press(Message::EditProperties(app.selected)), + ); + } + if !state.return_to.is_empty() { + out = out.push( + button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))), + ); + } + if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) { + let mut form = column![text(d.tool.title()).size(16)].spacing(8); + if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) { + let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body { + guard.as_str() + } else { + "" + }; + form = form.push(label(format!( + "Insert on {} → {} · preserve path identity and condition [{}]", + p.named(link.source()), + p.named(link.target()), + guard + ))); + } else if d.fixed && d.tool != Tool::Connect { + form = form.push(label( + d.insertion + .before + .map_or("Append an unconnected contribution".into(), |id| { + format!("Unconnected contribution before {}", p.named(id)) + }), + )); + } + if !legacy_order(p, owner).is_empty() { + form = form.push(label( + "This edit preserves the ordered path as explicit links in the same undo step.", + )); + } + if matches!( + d.tool, + Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch + ) { + let choices = targets(p, owner, d.tool) + .into_iter() + .filter(|c| compatible_source(p, d, c.id)) + .collect(); + form = form.push(picker( + choices, + d, + 0, + d.target, + "Choose existing source…", + Event::Target, + )); + } + if d.tool != Tool::Connect { + form = form.push( + text_input("Contribution name (defaults to source)", &d.name) + .on_input(|v| event(Event::Name(v))) + .padding(8) + .size(12), + ); + } + if matches!(d.tool, Tool::Control | Tool::Branch) { + let kinds = if d.tool == Tool::Branch { + vec![ControlKind::Choice, ControlKind::Fork] + } else { + vec![ + ControlKind::Choice, + ControlKind::Merge, + ControlKind::Fork, + ControlKind::Join, + ControlKind::Loop, + ] + }; + form = form.push( + pick_list( + kinds.into_iter().map(ControlChoice).collect::>(), + Some(ControlChoice(d.control)), + |v| event(Event::Control(v)), + ) + .padding(8) + .text_size(12), + ); + } + if d.tool == Tool::Branch { + form = form.push(picker( + targets(p, owner, Tool::Work), + d, + 1, + d.alternate, + "Second path work…", + Event::Alternate, + )); + if d.control != ControlKind::Fork { + form = form + .push( + text_input("First path guard", &d.guard) + .on_input(|v| event(Event::Guard(v))) + .padding(8), + ) + .push( + text_input("Second path guard (e.g. else)", &d.alternate_guard) + .on_input(|v| event(Event::AlternateGuard(v))) + .padding(8), + ); + } + } + if d.fixed { + if let (Some(a), Some(b)) = (d.before, d.after) { + form = form.push(label(format!("{} → {}", p.named(a), p.named(b)))); + } + } else { + form = form + .push(picker( + contributions(p, owner), + d, + 2, + d.before, + "After contribution…", + Event::Before, + )) + .push(button("Clear preceding").on_press(event(Event::ClearBefore))) + .push(picker( + contributions(p, owner), + d, + 3, + d.after, + "Before contribution…", + Event::After, + )) + .push(button("Clear following").on_press(event(Event::ClearAfter))); + } + if d.tool != Tool::Branch + && d.insertion.link.is_none() + && (d.before.is_some() || d.tool == Tool::Connect) + { + form = form.push( + text_input( + "Path condition (empty remains a Choice / Loop warning)", + &d.guard, + ) + .on_input(|v| event(Event::Guard(v))) + .padding(8) + .size(12), + ); + } + let valid = match d.command(p) { + Ok(_) => true, + Err(problem) => { + form = form.push(label(problem)); + false + } + }; + let actions = row![ + button("Create").on_press_maybe(valid.then(|| event(Event::Submit))), + button("Cancel").on_press(event(Event::Cancel)) + ] + .spacing(7); + out = out.push( + container( + column![ + actions, + scrollable(form).height((app.window_size.height - 520.).clamp(150., 260.)) + ] + .spacing(8), + ) + .padding(12) + .style(style::card), + ); + } + super::super::scenario_scroll::observe_topology( + context, + scenario::ScrollArea::Page, + scrollable(out.padding(18)), + ) +} From dfc14bcd1f0eb5063e5bc6ce4770b0ff257ec599 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 05:35:30 +0300 Subject: [PATCH 3/5] Record direct process editing verification and native gates --- harness/STATE.md | 21 ++++--- harness/release-process-editing.md | 91 +++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/harness/STATE.md b/harness/STATE.md index b8023289..096db75c 100644 --- a/harness/STATE.md +++ b/harness/STATE.md @@ -2,14 +2,18 @@ ## Objective and phase -Direct process/chain editing is now being implemented on `codex/process-direct` -at base 1bf8ebc in the scenario checkout and its dedicated target. The previous -scenario branch is retained. `release-process-editing.md` records the adopted -direct occurrence/link, guard-preserving insertion, bounded picker, derived -nesting and lifetime contract. This is desktop-only work; core/source/API types -stay unchanged. Automated implementation evidence and root-owned native trials -are pending. Cross-reference inner-chain linking remains an explicit semantic -extension rather than a claim of full Capella chain equivalence. +Direct process/chain editing is frozen in `codex/process-direct` at production +7a1e12b, based on 1bf8ebc, in `D:/CodexBuilds/Capella2-scenario-direct`. The prior +scenario branch is retained. Full Windows desktop tests pass 303/303, strict +all-target desktop Clippy and optimized build pass; independent existing core +topology/diagnostics/nested-source suites pass 19/19. The frozen binary, manifest +and logs are at `D:/CodexBuilds/Capella2-scenario-target/freeze-process-7a1e12b`. +`release-process-editing.md` records the identity-preserving direct gestures, +derived nesting, bounded forms, observed regression fixes and exact artifact hash. +No native operation was performed by this worker. Root independent review and +Windows/Linux native trials are pending. The accepted model/source/API contract +is unchanged; cross-reference inner-chain linking and disconnected exchange-only +topology remain explicit model boundaries, not implied completed semantics. Direct scenario work is now in isolated `codex/scenario-direct`, checkout `D:/CodexBuilds/Capella2-scenario-direct`, base ab661f2, dedicated target `D:/CodexBuilds/Capella2-scenario-target`. Scope and acceptance are recorded in @@ -189,4 +193,3 @@ Call/Reply history were verified; flat property paths prompted the latest fix. The independent bounded-browser review reports no remaining medium/high finding. Linux native R6 workflows remain a root-owned acceptance gate. See release-data-verification.md. Main remains a8f451e; R6 is not yet accepted. - diff --git a/harness/release-process-editing.md b/harness/release-process-editing.md index 7d4afd58..3b3d7842 100644 --- a/harness/release-process-editing.md +++ b/harness/release-process-editing.md @@ -1,6 +1,6 @@ # Direct process / functional-chain editing -Status: implementation started on 2026-09-06 in `codex/process-direct`, based on +Status: implementation and automated Windows verification passed on 2026-09-06 in `codex/process-direct`, based on 1bf8ebc in `D:/CodexBuilds/Capella2-scenario-direct`; dedicated target remains `D:/CodexBuilds/Capella2-scenario-target`. The prior scenario branch is retained. No native acceptance is claimed; root owns Windows/Linux native operation. @@ -53,3 +53,92 @@ Run focused invariants, all desktop tests, strict all-target desktop Clippy and optimized desktop build with --locked --offline and the dedicated target. Record actual evidence and remaining native tasks before freezing. Root independently reviews and native-tests the freeze; full-release completeness remains separate. + +## Implementation freeze and actual evidence — 2026-09-06 + +Production is `7a1e12bc7c18c5693de688f054293bacd095f4d8` (`7a1e12b`). +The prior `codex/scenario-direct` branch remains at its original head. Production +changes are confined to desktop topology and its local direct/view/test modules, +App process routing, the existing passive-scroll observer and one updated passive +lifecycle regression. `scenario.rs` and scenario message-label construction are +unchanged, allowing the root's separate DATA native fixes to merge independently. + +The palette now draws work/exchange/reuse/control contributions and sequence links +with click-click or drag. Selected link endpoint handles reconnect by identity; +selected contribution handles reorder peers within one derived path column. +Source navigation is explicit. Expanded reference previews expose all directly +owned source contributions without copying them or making inner endpoints local. +Every picker has search and 24-item pages. Active tools have distinct styling. +Create/Cancel are outside the form's internal scroller. The canvas stays visible +while forms are pending. New occurrence selection is restored after the final +SetSteps command, and successful edits reveal it in the graph. + +The 100% default and manual 50% zoom floor keep authoring legible. Explicit Fit +may produce a small overview; choosing a drawing tool leaves that overview at +100%. No positions are persisted. A source-only exchange sequence retains its +legacy linear meaning: two or more entries have explicit Convert; one exchange +has no implied link to materialize and exposes Work/Control/Reuse form entry to +begin explicit topology. Disconnected exchange-only topology cannot be represented +by the accepted contract; the editor does not introduce an invisible mode flag. + +The new App resize regression initially failed because existing window handling +only invalidated scenario contexts; it now invalidates process contexts too. +Passive viewport and both scroll reports are accepted before busy/modal gates, +validated by document/owner/revision, and cannot apply properties or discard a +behavior draft. The shared observer distinguishes its scenario/process delivery. +A separate captured-link probe failed because two existing parallel guards had +the same hit target. Distinct derived routes now make each relation selectable; +source/target/UUID/guard remain unchanged. The failing probe is saved alongside +the passing final suite as `process-parallel-selection-before.log`. + +Actual commands used `--locked --offline` and the dedicated scenario target: + +- Full desktop tests: **303 passed**, zero failed/ignored. Seventeen new process + tests exercise production pointer sequences, exact guarded-link insertion, + duplicate-link selection, same-owner reorder, source failure/drafts, busy save + scrolling, dirty source/properties, real resize, reusable-source Open/Back, + bounded widget trees and nested multi-file source/serialized-command parity. +- Strict all-target desktop Clippy: **passed** (`-D warnings`). +- Independent existing core suites `release_topology`, `process_diagnostics` and + `source_nested_updates`: **19 passed**. These cover complete alternative, + parallel and loop topology; two-direction transition closure; deletion and + reidentification; legacy sequences and exact nested source ownership/history. +- Optimized desktop build: **passed**. Owned sources were formatted with Rustfmt + edition 2024; `git diff --check` passed before the production commit. + +The source/JSON commands and core invariants are unchanged. These tests do not +claim a new actual HTTP transport trial or native acceptance. + +Frozen Windows directory: +`D:/CodexBuilds/Capella2-scenario-target/freeze-process-7a1e12b`. +It contains the binary, manifest and five verification/probe logs. Binary size: +20409856 bytes. SHA-256: +`701ECE6CD329A1CBE50D4DAEBFCE33AD7A93E6906295E74C3290653706F8455A`. +The frozen copy was hash-compared with the release target binary. + +## Root-owned native acceptance still required + +At 1440x900 and 1100x700 on Windows and Linux, operate the actual application: + +1. Start an OA process and SA chain, involve matching existing work/exchanges, + repeat a shared source with a distinct contextual name, and draw links with + both click-click and drag. Inspect source identity and contextual properties. +2. Draw Choice/Merge alternatives and Fork/Join parallel paths; enter `ready` and + `else`, create a loop repeat/exit, and inspect incomplete warnings. Insert an + exchange/control on an existing guarded path, reconnect an endpoint and reject + an incompatible target; confirm the original path metadata and other branch. +3. Reuse a subprocess twice, select its body without navigation, expand one local + reference and inspect the count, Open the shared source, then Back to its caller. + Reorder peer contributions and verify links are unchanged; cross-column movement + is rejected with the existing topology explanation. +4. Test Fit/100%, inner and outer wheel/scrollbar changes, resize, Escape and + unfocus during an armed or held gesture. Verify no stale endpoint/slot commits. + Confirm Create/Cancel remains accessible while a long form is open, search/page + changes retain its values, navigation is guarded and save-time scrolling recovers. +5. Convert a legacy multi-exchange path and try the single-exchange entry actions. + Undo/Redo the conversion and direct edits, save/reopen a nested source project, + then inspect the same occurrence/link IDs, guards and shared source records. + +No native/UI input was performed in this task. Cross-reference inner-chain linking +remains an explicit unresolved semantic extension; this milestone does not claim +full Capella functional-chain equivalence or complete the first release. From 8bc564b4b2c3b6d12eb61fc09aec7f129f6e45b7 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 05:42:01 +0300 Subject: [PATCH 4/5] Fix exchange-only process continuation with explicit append path --- crates/oa-desktop/src/app/process_canvas.rs | 120 ++++++++++++++++++ .../src/behavior/topology/direct.rs | 11 ++ .../src/behavior/topology/direct_tests.rs | 120 ++++++++++++++++++ .../oa-desktop/src/behavior/topology/view.rs | 20 ++- 4 files changed, 268 insertions(+), 3 deletions(-) diff --git a/crates/oa-desktop/src/app/process_canvas.rs b/crates/oa-desktop/src/app/process_canvas.rs index 8db6ec68..39d87d53 100644 --- a/crates/oa-desktop/src/app/process_canvas.rs +++ b/crates/oa-desktop/src/app/process_canvas.rs @@ -143,6 +143,18 @@ impl App { 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); @@ -222,6 +234,114 @@ mod tests { } } #[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(); diff --git a/crates/oa-desktop/src/behavior/topology/direct.rs b/crates/oa-desktop/src/behavior/topology/direct.rs index b60ccd9c..2fd2d8ed 100644 --- a/crates/oa-desktop/src/behavior/topology/direct.rs +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -30,6 +30,7 @@ pub enum CanvasEvent { Scrolled(Context, scenario::ScrollArea, iced::Vector), Choose(Context, CanvasTool), Select(Context, Id), + AppendExchange(Context), Begin { context: Context, tool: Tool, @@ -67,6 +68,7 @@ impl CanvasEvent { | Self::Expand(c, ..) | Self::Open(c, ..) | Self::Back(c) + | Self::AppendExchange(c) | Self::Zoom(c, ..) | Self::Viewport(c, ..) => *c, Self::Begin { context, .. } @@ -204,6 +206,15 @@ impl State { } Ok(()) } + pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> { + let last = legacy_order(p, owner) + .last() + .copied() + .ok_or("Choose an ordered exchange path to append to.")?; + self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?; + self.draft.as_mut().expect("started form").fixed = true; + Ok(()) + } pub(crate) fn connect( &mut self, p: &Project, diff --git a/crates/oa-desktop/src/behavior/topology/direct_tests.rs b/crates/oa-desktop/src/behavior/topology/direct_tests.rs index 8d2129c1..cbf9a5c8 100644 --- a/crates/oa-desktop/src/behavior/topology/direct_tests.rs +++ b/crates/oa-desktop/src/behavior/topology/direct_tests.rs @@ -848,3 +848,123 @@ fn direct_process_real_picker_stays_bounded_and_search_keeps_selection() { assert_eq!(state.draft.as_ref().unwrap().target, Some(f.work[0])); assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0); } + +pub(crate) fn empty_exchange_fixture(layer: Layer) -> (Fixture, Id) { + let mut f = fixture(layer); + let mut commands = f.nodes.into_iter().map(Command::Delete).collect::>(); + commands.push(Command::Connect( + f.p.relations[&f.exchange].kind(), + "Response".into(), + f.work[1], + f.work[2], + )); + f.p = execute_batch(&f.p, &commands).unwrap(); + let next = + f.p.relations + .values() + .find(|r| r.name == "Response") + .unwrap() + .id; + assert!(f.p.elements[&f.owner].steps().unwrap().is_empty()); + (f, next) +} + +#[test] +fn direct_process_append_preserves_existing_order_and_rejects_incompatible_exchange() { + for layer in [Layer::Oa, Layer::Sa] { + let (mut f, next) = empty_exchange_fixture(layer); + let kind = f.p.relations[&f.exchange].kind(); + f.p = execute_batch( + &f.p, + &[Command::Connect( + kind, + "Recovery".into(), + f.work[2], + f.work[0], + )], + ) + .unwrap(); + let final_exchange = + f.p.relations + .values() + .find(|r| r.name == "Recovery") + .unwrap() + .id; + for exchange in [f.exchange, next] { + let e = occurrence( + &f.p, + f.owner, + "Ordered contribution", + OccurrenceBody::Exchange { + exchange: Some(exchange), + sender: None, + receiver: None, + message_kind: MessageKind::default(), + reply_to: None, + values: vec![], + }, + ); + f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap(); + } + let before = f.p.elements[&f.owner].steps().unwrap().clone(); + assert_eq!(legacy_order(&f.p, f.owner), before); + assert!(sequence_links(&f.p, f.owner).is_empty()); + let mut state = State::default(); + state.append_exchange(&f.p, f.owner).unwrap(); + let draft = state.draft.as_ref().unwrap(); + assert!(draft.fixed); + assert_eq!(draft.before, before.last().copied()); + assert_eq!(draft.after, None); + assert!(!compatible_source(&f.p, draft, f.exchange)); + assert!(compatible_source(&f.p, draft, final_exchange)); + state + .update( + &f.p, + Event::Target(Choice { + id: f.exchange, + label: "Incompatible".into(), + }), + ) + .unwrap(); + assert!(state.prepare_pending(&f.p).is_err()); + assert!(state.draft.is_some()); + state + .update( + &f.p, + Event::Target(Choice { + id: final_exchange, + label: "Recovery".into(), + }), + ) + .unwrap(); + let command = state.prepare_pending(&f.p).unwrap(); + let Command::Batch(commands) = &command else { + panic!("one ordinary batch") + }; + assert_eq!( + commands.len(), + 4, + "materialize old path + occurrence + append link + order" + ); + let serialized = serde_json::to_value(command).unwrap(); + let result = execute_batch(&f.p, &[serde_json::from_value(serialized).unwrap()]).unwrap(); + let after = result.elements[&f.owner].steps().unwrap(); + assert_eq!(&after[..2], &before); + assert_eq!(after.len(), 3); + let links = sequence_links(&result, f.owner); + assert_eq!(links.len(), 2); + for pair in after.windows(2) { + assert_eq!( + links + .iter() + .filter(|r| r.source() == pair[0] && r.target() == pair[1]) + .count(), + 1 + ); + } + assert!(uses_explicit_topology(&result, f.owner)); + for (id, relation) in &f.p.relations { + assert_eq!(result.relations[id], *relation); + } + } +} diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs index 0df64063..e5cad97d 100644 --- a/crates/oa-desktop/src/behavior/topology/view.rs +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -155,7 +155,7 @@ pub(super) fn view<'a>( .spacing(6), ); } - out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else{match state.tool{ + out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{ CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column", CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels", CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}})); @@ -226,8 +226,14 @@ pub(super) fn view<'a>( ); } else { out = out.push(label( - "Ordered exchange path · convert explicitly before drawing topology.", + "Ordered exchange path · append the next exchange or convert to explicit links.", )); + if !pending { + out = out.push( + button("Append exchange…") + .on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))), + ).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step.")); + } let commands = preserved_order(p, owner, None); if !commands.is_empty() { out = out.push( @@ -235,7 +241,7 @@ pub(super) fn view<'a>( .on_press(Message::Command(unique_batch(p, commands))), ); } else { - out=out.push(label("A single exchange has no path link to convert. Involve work, a control or a reusable process to begin explicit topology.")) + out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process.")) .push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))), button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))), button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6)); @@ -309,6 +315,14 @@ pub(super) fn view<'a>( p.named(link.target()), guard ))); + } else if d.fixed + && d.tool == Tool::Exchange + && let Some(before) = d.before + { + form = form.push(label(format!( + "Append after {} → new exchange · Create adds an explicit sequence link", + p.named(before) + ))); } else if d.fixed && d.tool != Tool::Connect { form = form.push(label( d.insertion From 1a3164bf1604ca755143326af8870ba8b8dc8021 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 05:43:48 +0300 Subject: [PATCH 5/5] Record exchange continuation review fix and updated freeze --- harness/STATE.md | 27 +++++++++------ harness/release-process-editing.md | 54 ++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/harness/STATE.md b/harness/STATE.md index 096db75c..138f30c7 100644 --- a/harness/STATE.md +++ b/harness/STATE.md @@ -3,17 +3,22 @@ ## Objective and phase Direct process/chain editing is frozen in `codex/process-direct` at production -7a1e12b, based on 1bf8ebc, in `D:/CodexBuilds/Capella2-scenario-direct`. The prior -scenario branch is retained. Full Windows desktop tests pass 303/303, strict -all-target desktop Clippy and optimized build pass; independent existing core -topology/diagnostics/nested-source suites pass 19/19. The frozen binary, manifest -and logs are at `D:/CodexBuilds/Capella2-scenario-target/freeze-process-7a1e12b`. -`release-process-editing.md` records the identity-preserving direct gestures, -derived nesting, bounded forms, observed regression fixes and exact artifact hash. -No native operation was performed by this worker. Root independent review and -Windows/Linux native trials are pending. The accepted model/source/API contract -is unchanged; cross-reference inner-chain linking and disconnected exchange-only -topology remain explicit model boundaries, not implied completed semantics. +8bc564b, based on 1bf8ebc, in `D:/CodexBuilds/Capella2-scenario-direct`. The prior +scenario branch is retained. Independent review found and this revision fixes +the empty-owner → first exchange continuation dead end: **Append exchange…** +captures the last ordered contribution and creates the next occurrence plus its +explicit sequence link in one validated model/source Batch. No mode flag or +arbitrary topology is added. Both OA/SA regressions cover exact source history, +reopen, preservation of longer ordered paths and incompatible-source rejection. +Full Windows desktop tests pass 305/305; strict all-target desktop Clippy and +optimized build pass. Original independent core topology/diagnostics/nested-source +verification remains 19/19 at 7a1e12b. The current frozen binary, manifest and logs +are at `D:/CodexBuilds/Capella2-scenario-target/freeze-process-8bc564b`. +`release-process-editing.md` records behavior, verification and exact artifact hash. +No native operation was performed by this worker. Root review/native follow-up +must include empty → first exchange → Append exchange on Windows/Linux. The +accepted model/source/API contract is unchanged; cross-reference inner-chain +linking and disconnected exchange-only topology remain explicit model boundaries. Direct scenario work is now in isolated `codex/scenario-direct`, checkout `D:/CodexBuilds/Capella2-scenario-direct`, base ab661f2, dedicated target `D:/CodexBuilds/Capella2-scenario-target`. Scope and acceptance are recorded in diff --git a/harness/release-process-editing.md b/harness/release-process-editing.md index 3b3d7842..df9c3ec3 100644 --- a/harness/release-process-editing.md +++ b/harness/release-process-editing.md @@ -54,7 +54,7 @@ optimized desktop build with --locked --offline and the dedicated target. Record actual evidence and remaining native tasks before freezing. Root independently reviews and native-tests the freeze; full-release completeness remains separate. -## Implementation freeze and actual evidence — 2026-09-06 +## Original implementation freeze and actual evidence — 2026-09-06 Production is `7a1e12bc7c18c5693de688f054293bacd095f4d8` (`7a1e12b`). The prior `codex/scenario-direct` branch remains at its original head. Production @@ -76,10 +76,11 @@ SetSteps command, and successful edits reveal it in the graph. The 100% default and manual 50% zoom floor keep authoring legible. Explicit Fit may produce a small overview; choosing a drawing tool leaves that overview at 100%. No positions are persisted. A source-only exchange sequence retains its -legacy linear meaning: two or more entries have explicit Convert; one exchange -has no implied link to materialize and exposes Work/Control/Reuse form entry to -begin explicit topology. Disconnected exchange-only topology cannot be represented -by the accepted contract; the editor does not introduce an invisible mode flag. +legacy linear meaning: two or more entries have explicit Convert. The original +one-exchange fallback offered only Work/Control/Reuse; independent review found +that this blocked exchange-only continuation. The correction is recorded below. +Disconnected exchange-only topology cannot be represented by the accepted +contract; the editor does not introduce an invisible mode flag. The new App resize regression initially failed because existing window handling only invalidated scenario contexts; it now invalidates process contexts too. @@ -116,6 +117,46 @@ It contains the binary, manifest and five verification/probe logs. Binary size: `701ECE6CD329A1CBE50D4DAEBFCE33AD7A93E6906295E74C3290653706F8455A`. The frozen copy was hash-compared with the release target binary. +## Independent-review correction — exchange-only continuation + +Production follow-up is `8bc564b4b2c3b6d12eb61fc09aec7f129f6e45b7` (`8bc564b`). +Independent review found a real dead end: after the first Exchange was created in +an empty process/chain, the accepted model classified it as an ordered exchange +path. There was no path to convert and the fallback omitted another Exchange. + +Ordered paths now offer **Append exchange…**, including a single contribution. +The form names the captured final contribution and explains that Create adds an +explicit sequence link. Its contextual picker accepts exchanges whose source +matches the preceding exchange's target. Create emits one existing validated +Batch: new exchange occurrence, preceding-to-new SequenceLink and SetSteps; +existing implied links in longer ordered paths are materialized in that same +transaction. The second contribution therefore restores the direct topology +palette without inventing disconnected topology, global exchanges or a mode flag. +Source reuse, same-owner endpoints and the cross-reference semantic boundary remain. + +Two new regressions both exercise OA and SA. The App regression starts with an +empty owner, creates its first exchange, invokes the actual Append event and +creates its second exchange. It verifies exactly one link, two distinct occurrence +IDs, unchanged shared records, one history entry per Create, exact model/source +Undo/Redo and source reopen. The independent proposal regression preserves a +multi-entry ordered path, rejects an incompatible exchange without losing its +form, and validates the serialized ordinary Batch through core execution. + +Focused regressions: **2 passed**. Full desktop tests: **305 passed**, zero +failed/ignored. Strict all-target desktop Clippy: **passed** (`-D warnings`). +Commands used `--locked --offline` and the same dedicated target. The final full +suite took 10.75 seconds; Clippy took 4.96 seconds. Production changes are confined +to `topology/direct.rs`, `topology/view.rs`, local tests and `app/process_canvas.rs`. +No native input or new actual HTTP transport trial was performed. + +Updated optimized build: **passed** in 38.80 seconds. Current frozen Windows +artifact, manifest and three verification logs are in +`D:/CodexBuilds/Capella2-scenario-target/freeze-process-8bc564b`. +Binary size: 20430336 bytes; SHA-256: +`8BD5C84DF0F4E333C1955E6E9299F431081912E1A504ED13355E0BFA70F8B8BD`. +The frozen copy was hash-compared with the release target binary. The prior +`freeze-process-7a1e12b` remains available as historical evidence. + ## Root-owned native acceptance still required At 1440x900 and 1100x700 on Windows and Linux, operate the actual application: @@ -135,7 +176,8 @@ At 1440x900 and 1100x700 on Windows and Linux, operate the actual application: unfocus during an armed or held gesture. Verify no stale endpoint/slot commits. Confirm Create/Cancel remains accessible while a long form is open, search/page changes retain its values, navigation is guarded and save-time scrolling recovers. -5. Convert a legacy multi-exchange path and try the single-exchange entry actions. +5. Start an empty owner, add its first exchange, use Append exchange for its second, + and verify the visible link/direct palette. Also append/convert a legacy multi-exchange path. Undo/Redo the conversion and direct edits, save/reopen a nested source project, then inspect the same occurrence/link IDs, guards and shared source records.