Preserve process camera and viewport while opening authoring tools

This commit is contained in:
2026-09-06 06:41:57 +03:00
parent 767634aea5
commit cf337851ff
4 changed files with 298 additions and 10 deletions
@@ -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<f32>) {
self.fit = value.is_none();
@@ -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<Vec<usize>> {
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::<Rc<()>>(),
&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}"
);
}
}
}
}
@@ -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,
+56 -2
View File
@@ -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.