From cf337851ff47add5e9d29a262b31dd6bb485d6b9 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 06:41:57 +0300 Subject: [PATCH] Preserve process camera and viewport while opening authoring tools --- .../src/behavior/topology/direct.rs | 5 +- .../src/behavior/topology/direct_tests.rs | 222 ++++++++++++++++++ .../oa-desktop/src/behavior/topology/view.rs | 23 +- harness/release-process-editing.md | 58 ++++- 4 files changed, 298 insertions(+), 10 deletions(-) diff --git a/crates/oa-desktop/src/behavior/topology/direct.rs b/crates/oa-desktop/src/behavior/topology/direct.rs index 2fd2d8ed..8cb730f6 100644 --- a/crates/oa-desktop/src/behavior/topology/direct.rs +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -103,11 +103,8 @@ impl State { changed } pub(crate) fn choose(&mut self, tool: CanvasTool) { + // Choosing how to edit must retain the user's current view. 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(); diff --git a/crates/oa-desktop/src/behavior/topology/direct_tests.rs b/crates/oa-desktop/src/behavior/topology/direct_tests.rs index cbf9a5c8..1c687522 100644 --- a/crates/oa-desktop/src/behavior/topology/direct_tests.rs +++ b/crates/oa-desktop/src/behavior/topology/direct_tests.rs @@ -968,3 +968,225 @@ fn direct_process_append_preserves_existing_order_and_rejects_incompatible_excha } } } + +#[test] +fn direct_process_palette_preserves_fit_and_manual_camera() { + for layer in [Layer::Oa, Layer::Sa] { + let f = fixture(layer); + let mut state = State::default(); + let document = Id::new_v4(); + state.enter(document, f.owner); + state.viewport(Size::new(500., 300.)); + state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(35., 90.)); + state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 120.)); + for camera in [None, Some(0.75), Some(1.5)] { + state.zoom(camera); + let size = Size::new(690., 250.); + let before = state.scale(size, 500.); + for tool in [ + CanvasTool::Add(Tool::Work, ControlKind::Choice), + CanvasTool::Add(Tool::Exchange, ControlKind::Choice), + CanvasTool::Add(Tool::Reuse, ControlKind::Choice), + CanvasTool::Add(Tool::Control, ControlKind::Choice), + CanvasTool::Add(Tool::Control, ControlKind::Fork), + CanvasTool::Connect, + CanvasTool::Select, + ] { + state.choose(tool); + assert_eq!( + state.scale(size, 500.), + before, + "{layer:?} {camera:?} {tool:?}" + ); + assert_eq!(state.fit, camera.is_none()); + assert_eq!(state.scroll_offset(), iced::Vector::new(35., 210.)); + state.enter(document, f.owner); + assert_eq!(state.scale(size, 500.), before); + } + } + state.enter(Id::new_v4(), f.owner); + assert_eq!(state.scale(Size::new(690., 250.), 500.), 1.); + assert_eq!(state.scroll_offset(), iced::Vector::ZERO); + } +} + +#[test] +fn direct_process_pending_form_diff_retains_the_canvas_scroll_state() { + use iced::advanced::widget::{Tree, tree}; + use std::rc::Rc; + fn path_to(tree: &Tree, tag: tree::Tag) -> Option> { + if tree.tag == tag { + return Some(Vec::new()); + } + tree.children.iter().enumerate().find_map(|(index, child)| { + let mut path = path_to(child, tag)?; + path.insert(0, index); + Some(path) + }) + } + fn at<'a>(mut tree: &'a mut Tree, path: &[usize]) -> &'a mut Tree { + for &index in path { + tree = &mut tree.children[index]; + } + tree + } + for layer in [Layer::Oa, Layer::Sa] { + for size in [Size::new(1440., 900.), Size::new(1100., 700.)] { + for tool in [Tool::Control, Tool::Branch, Tool::Connect] { + let f = fixture(layer); + let mut app = App::with_project(f.p.clone()); + let _ = app.update(Message::Select(f.nodes[1])); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = size; + let original = app.document.snapshot(); + let graph: Element<'_, Message> = + canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into(); + let canvas_tag = Tree::new(graph.as_widget()).tag; + drop(graph); + let scroll: Element<'_, Message> = scrollable(text("")).into(); + let scroll_tag = Tree::new(scroll.as_widget()).tag; + let mut tree = Tree::new(view::view(&app, f.owner, Column::new()).as_widget()); + let mut scroll_path = path_to(&tree, canvas_tag).unwrap(); + scroll_path.pop(); + assert_eq!(at(&mut tree, &scroll_path).tag, scroll_tag); + // Scrollable::diff treats its local state as opaque. A retained marker + // proves the real Iced diff preserves the state that owns the offsets. + // No layout or input operation runs on this test-only marker state. + let marker = Rc::new(()); + at(&mut tree, &scroll_path).state = tree::State::new(Rc::clone(&marker)); + let _ = app.update(Message::Behavior(super::super::Event::Topology( + Event::Start(f.owner, tool, Some(f.nodes[1])), + ))); + assert!(app.behavior.has_pending()); + tree.diff(view::view(&app, f.owner, Column::new()).as_widget()); + assert_eq!( + Rc::strong_count(&marker), + 2, + "form open: {layer:?} {size:?} {tool:?}" + ); + let mut current_path = path_to(&tree, canvas_tag).unwrap(); + current_path.pop(); + assert_eq!(current_path, scroll_path); + assert!(Rc::ptr_eq( + at(&mut tree, &scroll_path).state.downcast_ref::>(), + &marker, + )); + let _ = app.update(Message::Behavior(super::super::Event::Topology( + Event::Cancel, + ))); + tree.diff(view::view(&app, f.owner, Column::new()).as_widget()); + assert_eq!( + Rc::strong_count(&marker), + 2, + "form cancel: {layer:?} {size:?} {tool:?}" + ); + assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &original)); + } + } + } +} + +#[test] +fn direct_process_scaled_control_and_link_gestures_use_visible_positions() { + for layer in [Layer::Oa, Layer::Sa] { + let f = fixture(layer); + for scale in [0.15, 0.6898551, 1.5] { + let mut graph = graph(&f, CanvasTool::Add(Tool::Control, ControlKind::Choice)); + graph.scale = scale; + graph.scroll_offset = iced::Vector::new(11., 19.); + let bounds = Rectangle::new( + Point::new(40., 70.), + Size::new(graph.size.width * scale, graph.size.height * scale), + ); + let screen = |world: Point| { + Point::new( + world.x * scale + bounds.x - 11., + world.y * scale + bounds.y - 19., + ) + }; + let edge = &graph.edges[0]; + let point = screen(Point::new( + (edge.points[0].x + edge.points[1].x) / 2., + edge.points[0].y, + )); + let mut input = Input::default(); + send( + &graph, + &mut input, + mouse::Event::CursorMoved { position: point }, + point, + bounds, + ); + 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 { + tool: Tool::Control, control: ControlKind::Choice, + insertion: Insertion { link: Some(id), .. }, .. + })) if id == f.link + ), + "{layer:?} control at {scale}" + ); + graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect; + for drag in [false, true] { + let mut input = Input::default(); + let a = screen(position(&graph, f.nodes[0])); + let b = screen(position(&graph, f.nodes[2])); + 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 { + send( + &graph, + &mut input, + mouse::Event::ButtonReleased(mouse::Button::Left), + a, + bounds, + ); + } + 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] + ), + "{layer:?} link at {scale}, drag={drag}" + ); + } + } + } +} diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs index e5cad97d..d2d7c917 100644 --- a/crates/oa-desktop/src/behavior/topology/view.rs +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -72,8 +72,11 @@ pub(super) fn view<'a>( 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)); + // Keep the canvas at the same sibling position when its palette becomes a + // pending-form notice, so Iced retains the scrollable's local offsets. + let mut controls = Column::new().spacing(14); if !pending && explicit { - out = out + controls = controls .push( row![ button("Select / reorder") @@ -155,10 +158,11 @@ pub(super) fn view<'a>( .spacing(6), ); } - 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{ + controls=controls.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"}})); + out = out.push(controls); 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.)); @@ -345,12 +349,19 @@ pub(super) fn view<'a>( .into_iter() .filter(|c| compatible_source(p, d, c.id)) .collect(); + if d.tool == Tool::Branch { + form = form.push(label("First path work")); + } form = form.push(picker( choices, d, 0, d.target, - "Choose existing source…", + if d.tool == Tool::Branch { + "Choose first path work…" + } else { + "Choose existing source…" + }, Event::Target, )); } @@ -385,7 +396,7 @@ pub(super) fn view<'a>( ); } if d.tool == Tool::Branch { - form = form.push(picker( + form = form.push(label("Second path work")).push(picker( targets(p, owner, Tool::Work), d, 1, @@ -395,11 +406,13 @@ pub(super) fn view<'a>( )); if d.control != ControlKind::Fork { form = form + .push(label("First path guard")) .push( text_input("First path guard", &d.guard) .on_input(|v| event(Event::Guard(v))) .padding(8), ) + .push(label("Second path guard")) .push( text_input("Second path guard (e.g. else)", &d.alternate_guard) .on_input(|v| event(Event::AlternateGuard(v))) @@ -413,6 +426,7 @@ pub(super) fn view<'a>( } } else { form = form + .push(label("Preceding contribution")) .push(picker( contributions(p, owner), d, @@ -422,6 +436,7 @@ pub(super) fn view<'a>( Event::Before, )) .push(button("Clear preceding").on_press(event(Event::ClearBefore))) + .push(label("Following contribution")) .push(picker( contributions(p, owner), d, diff --git a/harness/release-process-editing.md b/harness/release-process-editing.md index df9c3ec3..e912c498 100644 --- a/harness/release-process-editing.md +++ b/harness/release-process-editing.md @@ -74,8 +74,10 @@ 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 +may produce a small overview. Palette changes preserve the chosen Fit/manual +camera; use the explicit 100% control to enlarge it. This supersedes the original +forced-100% policy after native testing showed that it hid intended endpoints. +No positions are persisted. A source-only exchange sequence retains its 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. @@ -184,3 +186,55 @@ At 1440x900 and 1100x700 on Windows and Linux, operate the actual application: 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. + +## Root Linux native direct-chain trial — 2026-09-06 + +Frozen60d162c Wayland1440x900. Created empty SA Functional chain; Work palette and +canvas clicks involved existing Request navigation and Provide navigation. Drew +Sequence link by dragging their cards, entered ready, created it. Exchange palette +and click on that exact guarded path chose existing Navigation request. Create +inserted its occurrence between functions:69objects/7links/3expected model checks. +Fit69% reveals all three cards and guard. Undo68/6 and Redo69/7 passed; Save All +created included sa/behavior/process_new_functional_chain.arcadia. + +Chain768ad41c-3bf7-4a89-8163-41963157f1b1; function occurrences a77f6697 and aa6526a7; +exchange occurrence9562fa0f-c9ff-42d1-aff5-da17ec3c827f. Original link +ead846bd-3245-44b7-b6aa-1223865f533b retains ready and now targets inserted exchange; +new link6f32045b-a598-47ae-bfe7-3f9b7f52cdf3 connects exchange to Provide navigation. +Shared function/exchange records are unchanged. Saved file was inspected; native +chain reopen is still pending. Wayland61870 closed0 normally without serious output. +OA, branch/fork/loop, reuse, reconnect/rejection, contextual names, Exchange-first +continuation, compact/Windows trials and gesture cancellation remain required. + + +## Preserve camera and viewport during process authoring + +Root native testing exposed two independent UI defects in the combined build. +After Fit showed a three-card chain at69%, choosing a control reset scale to100% +and hid remote cards. The implementation intentionally enlarged drawing tools for +legibility, but the real scaled input path already supports editing at Fit. Tool +selection now changes only the tool; explicit camera actions and document/owner +navigation retain their existing behavior and gesture epoch invalidation. + +Opening Two paths after revealing a remote OR control also reset the canvas to +the beginning without changing source/model. Its two palette rows were conditional +outer Column children. Removing them moved the canvas subtree to a different +sibling slot; Iced discarded its local scroll state. A permanent controls Column +now contains the palette and pending notice, preserving the canvas position in the +widget tree through form open/cancel. This follows the accepted scenario approach. + +The compact Two paths form now has persistent First/Second path work and guard +labels, plus Preceding/Following contribution labels. Typed values no longer erase +those meanings. Existing pinned Create/Cancel controls remain in place. + +Three regression tests exercise OA and SA: Fit and manual-camera preservation +across all palette tools; production Control insertion and click/drag sequence +links at15%, approximately69% and150% with nonzero canvas/scroll origins; and +actual Iced widget-tree diff through Control, Two paths and Connect form open/cancel +at1440x900 and1100x700. The latter marks the scrollable's opaque local state during +diff only, proving that the state owning offsets survives, while checking the +unchanged document snapshot. Both defect tests failed against original production +code (0.6898551→1.0 and dropped local state), then passed after correction. + +The focused process suite passed15 tests. Full desktop/platform verification and +frozen runtime artifacts are still in progress. This task has used no native input.