Preserve process camera and viewport while opening authoring tools
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user