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,