fix: keep saved diagram viewport stable across group selection
This commit is contained in:
@@ -154,9 +154,6 @@ async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Append to app/diagram_focus_tests.rs after the frozen desktop gate.
|
||||
// Reuses its imports and FocusField, plus Mounted/key from canvas_workspace tests.
|
||||
|
||||
async fn modifier_focus_fixture() -> (App, Mounted, Id, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
@@ -498,3 +495,99 @@ async fn mounted_window_unfocus_resets_held_shift_before_next_canvas_press() {
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_selection_strip_keeps_canvas_and_fitted_camera_fixed_for_empty_single_group_and_mixed()
|
||||
{
|
||||
use crate::diagram_usage::Target;
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "First activity", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
&format!(
|
||||
"Second activity {}",
|
||||
"with a very long primary name ".repeat(20)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let edge = project.relate(RelationKind::Interaction, "Activity exchange", a, b);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let first = project.place(diagram, a, Position { x: 80., y: 80. });
|
||||
let second = project.place(diagram, b, Position { x: 500., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(1442., 932.);
|
||||
let _ = app.update(Message::Select(diagram));
|
||||
app.fit_to_view = true;
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let baseline = app.document.snapshot();
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size);
|
||||
assert!(camera.2, "exercise the native resize-triggered refit path");
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert!(bounds.width > 500. && bounds.height > 300.);
|
||||
for (target, expected) in [
|
||||
(
|
||||
Some(Target::Placement(first)),
|
||||
vec![Target::Placement(first)],
|
||||
),
|
||||
(
|
||||
Some(Target::Placement(second)),
|
||||
vec![Target::Placement(first), Target::Placement(second)],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(edge)),
|
||||
vec![
|
||||
Target::Placement(first),
|
||||
Target::Placement(second),
|
||||
Target::Edge(edge),
|
||||
],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(edge)),
|
||||
vec![Target::Placement(first), Target::Placement(second)],
|
||||
),
|
||||
(
|
||||
Some(Target::Placement(second)),
|
||||
vec![Target::Placement(first)],
|
||||
),
|
||||
(None, vec![]),
|
||||
] {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let event = match target {
|
||||
Some(target) => {
|
||||
multiselect::Event::Choose(context, target, multiselect::Choice::Toggle)
|
||||
}
|
||||
None => multiselect::Event::Clear(context),
|
||||
};
|
||||
let task = app.update(Message::DiagramSelection(event));
|
||||
mounted.perform(&mut app, task).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(app.multiselect_snapshot().unwrap().targets(), expected);
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID),
|
||||
bounds,
|
||||
"selection feedback must not change the measured canvas rectangle"
|
||||
);
|
||||
assert_eq!(
|
||||
(app.zoom, app.pan, app.fit_to_view, app.canvas_size),
|
||||
camera
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,32 +429,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
chooser = chooser.push(action("View properties", Message::Select(id)));
|
||||
controls = controls.push(action("Auto-layout", Message::Command(Command::Layout(id))));
|
||||
}
|
||||
let mut toolbar = column![chooser, controls].spacing(6);
|
||||
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1) {
|
||||
let mut group_controls = row![label(format!("{} selected", snapshot.targets().len()))]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center);
|
||||
if crate::app::multiselect::placements(&snapshot).is_some() {
|
||||
group_controls = group_controls.push(action(
|
||||
"Hide selected",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Hide(snapshot.clone())),
|
||||
));
|
||||
}
|
||||
group_controls = group_controls.push(action(
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Clear(snapshot.context())),
|
||||
));
|
||||
toolbar = toolbar.push(
|
||||
column![
|
||||
group_controls,
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
short(app.project().named(app.selected), 36)
|
||||
))
|
||||
]
|
||||
.spacing(3),
|
||||
);
|
||||
}
|
||||
let toolbar = column![chooser, controls].spacing(6);
|
||||
let projection = app
|
||||
.active_diagram
|
||||
.and_then(|id| app.project().diagrams.get(&id))
|
||||
@@ -634,6 +609,37 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
)
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
|
||||
});
|
||||
let selection = app.multiselect_snapshot();
|
||||
let count = selection.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut selection_controls =
|
||||
row![label(format!("{count} selected")).wrapping(widget::text::Wrapping::None)]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = selection.as_ref().filter(|s| s.targets().len() > 1) {
|
||||
if crate::app::multiselect::placements(snapshot).is_some() {
|
||||
selection_controls = selection_controls.push(action(
|
||||
"Hide selected",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Hide(
|
||||
snapshot.clone(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
selection_controls = selection_controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
|
||||
snapshot.context(),
|
||||
)),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
short(app.project().named(app.selected), 36)
|
||||
))
|
||||
.wrapping(widget::text::Wrapping::None),
|
||||
);
|
||||
}
|
||||
let hint = if let Some(feedback) = &app.allocation_drag.feedback {
|
||||
feedback.text.as_str()
|
||||
} else if let Some(session) = &app.connection {
|
||||
@@ -646,16 +652,37 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
} else {
|
||||
session.tool.hint()
|
||||
}
|
||||
} else if count > 1 {
|
||||
if selection
|
||||
.as_ref()
|
||||
.is_some_and(|s| crate::app::multiselect::placements(s).is_some())
|
||||
&& projection != Some(ProjectionKind::Allocation)
|
||||
{
|
||||
"Shift-click to add/remove · drag selected to move · right-click for actions · scroll to zoom"
|
||||
} else {
|
||||
"Shift-click to add/remove · right-click for selection actions · Esc clears selection"
|
||||
}
|
||||
} else if promoted {
|
||||
"Expand endpoint containers to edit this connection route"
|
||||
} else if edge.is_some() {
|
||||
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
|
||||
} else {
|
||||
"Right-click for actions · drag to arrange · scroll to zoom"
|
||||
"Shift-click to add/remove · right-click for actions · drag to arrange · scroll to zoom"
|
||||
};
|
||||
// Status is outside the measured canvas. Clipping prevents long text
|
||||
// from painting over work headers or the dedicated destination slot.
|
||||
tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true));
|
||||
// Selection changes must not resize the measured canvas or refit its
|
||||
// camera. Reserve both rows even when no group actions are available.
|
||||
tools = tools.push(
|
||||
container(
|
||||
column![
|
||||
selection_controls,
|
||||
label(hint).wrapping(widget::text::Wrapping::None)
|
||||
]
|
||||
.spacing(3),
|
||||
)
|
||||
.height(56)
|
||||
.width(Fill)
|
||||
.clip(true),
|
||||
);
|
||||
}
|
||||
column![container(tools).padding(14), canvas].into()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# Independent native evaluator review — 2026-09-23
|
||||
|
||||
Read-only review of `crates/oa-core/examples/multiselect_native.rs` at
|
||||
`12a7352ddef5f69a144ead8219a50916233d63a9`, the local native preparation,
|
||||
snapshot and capture helpers, and the owning session's proposed UI sequence.
|
||||
Only this note was edited by the reviewer. No builds, tests, UI actions or
|
||||
Gitea operations were run. The parent reports core, full desktop and lint gates
|
||||
passed; no native run was available for acceptance at this review checkpoint.
|
||||
|
||||
## Finding and correction
|
||||
|
||||
**P2, corrected in the helper during review:** the original
|
||||
`.local/multiselect-native/prepare-native.py:38–41` recorded the GUI exit code
|
||||
and binary/fixture immutability booleans, then returned normal helper success
|
||||
even if the GUI crashed or a recorded input changed. This could mislead a
|
||||
consumer using process exit status as the gate. The parent added assertions at
|
||||
lines 42–43 requiring GUI exit zero and all three immutability conditions after
|
||||
writing the durable receipt. Re-reading the helper confirmed the correction.
|
||||
No production source or build input changed for this correction, and it was
|
||||
not runtime-tested by this reviewer.
|
||||
|
||||
No additional actionable checker correctness defect was established. A native
|
||||
acceptance result still requires the actual UI sequence and evidence below;
|
||||
successful launcher completion alone is insufficient.
|
||||
|
||||
## Independence and falsification coverage
|
||||
|
||||
- The expected Project starts from the loaded baseline and changes explicitly
|
||||
enumerated fixture records. Movement expects A, B and the child to move once,
|
||||
translates only the internal route's pins, and preserves C, one-ended route
|
||||
pins, attachments, labels, all semantic records, traces and other views.
|
||||
Hide removes only A/B placements. It preserves the unselected child, stored
|
||||
routes and model objects. Neither expectation invokes the group planner,
|
||||
descendant traversal or routing implementation.
|
||||
- `equal` compares the complete Project, not a selected geometry subset.
|
||||
Validation, JSON roundtrip and source compilation/roundtrip supplement that
|
||||
comparison. Completeness warnings in this deliberately partial fixture are
|
||||
allowed; source discovery errors and compilation failures are rejected.
|
||||
- The ten checker tests include positive generation/source/command examples
|
||||
and multiple valid-model corruption falsifiers. The negative cases cover
|
||||
an omitted or double-moved child, stranded internal pins, moved one-ended
|
||||
pins, attachment/label drift, semantic and trace drift, other-view and fixed
|
||||
node changes, invalid movement evidence, destructive Hide and unchanged-mode
|
||||
semantic drift. Corruptions are validated before comparison and most tests
|
||||
require the specific whole-Project mismatch, avoiding a fake pass caused
|
||||
only by malformed input rejection. Planner use is confined to generating
|
||||
positive/corrupted test candidates, not expected evaluator output.
|
||||
- Fixture UUIDs are deterministic, generation refuses an existing output
|
||||
directory, and evidence writes are exclusive. Preparation validates binary
|
||||
hashes against successful unchanged-input build receipts and requires the
|
||||
GUI and checker input-manifest hashes to match. The inspected checker
|
||||
receipt names the reviewed commit; the GUI's then-current started receipt
|
||||
records the same input hash. Final build/run results remain the parent's gate.
|
||||
- The snapshot helper copies source into a unique directory, compares the
|
||||
source file set and hashes before/after copying and after checking, pins the
|
||||
checker and baseline, and propagates evaluator failure. Source snapshot
|
||||
evaluation is separate from UI capture, as it should be.
|
||||
|
||||
## Acceptance limits for the planned sequence
|
||||
|
||||
The parent plans native context Add for A/B, actual drag, saved-model checks,
|
||||
one-step Undo/Redo with exact source-byte comparison, undo back to baseline,
|
||||
group Hide with Undo/Redo, and reopen. That is appropriate evidence for the
|
||||
ordinary saved-view path with these explicit limits:
|
||||
|
||||
1. **The movement checker is an invariant evaluator.** It infers the common
|
||||
delta from saved A and checks bounds, nontrivial length and anchor grid
|
||||
alignment. A wrong drag direction or magnitude can pass if all required
|
||||
records share the same admissible delta. The code and manifest already
|
||||
state this limit. Record native press/release and viewport observations if
|
||||
claiming intended displacement accuracy; otherwise claim only the verified
|
||||
common translation and preservation invariants. Existing pointer/gesture
|
||||
tests provide separate evidence and are not native coordinate evidence.
|
||||
2. **Model equality does not prove an action happened.** Capture one actual
|
||||
Undo or Redo and completed Save between the appropriate snapshots. An
|
||||
unchanged check alone cannot distinguish successful Undo from never making
|
||||
an edit. Reopen must actually load the saved working source in the GUI and
|
||||
demonstrate its state; checker roundtrip alone does not verify GUI reopening.
|
||||
3. **Exact source-byte restoration is a separate comparison.** The snapshot
|
||||
helper records each source inventory but does not compare different stages.
|
||||
Explicitly compare all relative paths and hashes: Undo movement to the
|
||||
original fixture source, Redo movement to the first moved snapshot, and
|
||||
corresponding baseline/hidden inventories for Hide history. A Project PASS
|
||||
does not establish identical source formatting or file layout.
|
||||
4. **Join provenance rather than assuming it.** Tie the captured native window
|
||||
to the launched PID/executable in `started.json`, identify the opened working
|
||||
entry, retain the build/input receipts, and require the final fixture and
|
||||
binary immutability checks. The checker verifies the expected fixture project
|
||||
ID, not a baked-in baseline hash; baseline authenticity comes from the
|
||||
generated fixture inventory and unchanged final receipt. Capture JSON and
|
||||
screenshots alone do not establish which binary produced saved source.
|
||||
5. **This fixture is OA Activities on an ordinary Overview.** Native context
|
||||
Add verifies that explicit alternative, not held-Shift injection (unavailable
|
||||
through the chosen native tool). Mounted focus tests separately exercise
|
||||
Shift before canvas focus. The native run does not establish an SA Function
|
||||
workflow, selected ancestor-plus-child deduplication, mixed-port/edge refusal,
|
||||
busy suspension behavior or every cancellation path. Existing targeted tests
|
||||
remain separate evidence for those cases; no additional broad native claims
|
||||
should be inferred.
|
||||
6. **Scope remains incomplete beyond ordinary saved views.** State, Allocation,
|
||||
Process/Scenario and aggregate model deletion are not accepted by this
|
||||
checker or planned native sequence. Their absence is not a new finding in
|
||||
this bounded review.
|
||||
|
||||
These limits qualify what the evidence proves. They do not require replacing
|
||||
the independently enumerated Project oracle or broadening this native fixture
|
||||
into the unfinished adapter families.
|
||||
|
||||
## Native layout follow-up: selection changes move the canvas
|
||||
|
||||
**P2 — group feedback changes the diagram viewport while merely selecting.**
|
||||
The parent subsequently supplied native run-1 captures. I independently viewed
|
||||
`.local/multiselect-native/run-1/06-select-a-0.jpg` and
|
||||
`08-group-selected-0.jpg`; both capture the same native window at 1442 by 932.
|
||||
The canvas begins near y=314 in the singleton capture and y=370 after forming
|
||||
the group. A's header moves from approximately y=484 to y=511. The group count,
|
||||
Hide/Clear actions and primary-properties explanation are readable, but adding
|
||||
them has moved the diagram under the user's pointer. This is a presentation
|
||||
finding; it does not assert that selecting changed saved geometry.
|
||||
|
||||
The source cause is explicit: `views.rs:434–461` conditionally appends a two-row
|
||||
group block above the View content control and canvas when the target count
|
||||
exceeds one. The additional block is about 55 pixels high. The observed node
|
||||
shift is smaller than the viewport-top shift, consistent with the canvas resize
|
||||
and current fitted camera reacting to the reduced height. Removing the final
|
||||
group member or clearing selection reverses the same conditional layout.
|
||||
|
||||
The ordinary idle hint (`views.rs:654`) also mentions right-click, drag and zoom
|
||||
but gives no visible Shift-click discovery cue; neither inspected screenshot
|
||||
shows such a cue. Context Add is usable, but the user's requested modifier
|
||||
gesture should be discoverable directly in the ordinary selection UI.
|
||||
|
||||
### Smallest space-conscious correction
|
||||
|
||||
Move the group feedback into an **always-mounted, fixed-height selection/status
|
||||
strip at the existing fixed 30-pixel hint location** near `views.rs:658`. Remove
|
||||
the conditional group block from the toolbar. A roughly 56-pixel strip can
|
||||
contain:
|
||||
|
||||
- A fixed approximately 32-pixel first row with selection count, the existing
|
||||
group Hide/Clear controls when applicable, and the short explicit disclosure
|
||||
`Properties edit <primary> only`. Use a bounded/nonwrapping primary label so
|
||||
long names or a mixed selection cannot increase strip height. Empty and
|
||||
singleton states keep the same row allocation even with fewer controls.
|
||||
- A short second line: `Shift-click to add/remove · drag selected to move ·
|
||||
right-click for actions`. Existing active connection, allocation and route
|
||||
instructions retain precedence when those modes own input; ordinary idle
|
||||
selection should expose the modifier hint.
|
||||
|
||||
This reuses the existing status location and consumes about 26 more vertical
|
||||
pixels than today's singleton layout, while recovering about 29 pixels from
|
||||
today's group layout. The shown central workspace is approximately 860 pixels
|
||||
wide, sufficient for these compact controls and a bounded primary label without
|
||||
adding a third row. A fixed outer height, fixed first-row height and nonwrapping
|
||||
text keep the canvas rectangle identical for zero, one and multiple targets.
|
||||
Keep this strip outside the canvas; an overlay would obscure diagram content
|
||||
and complicate hit testing. Simply reserving the current extra 55-pixel toolbar
|
||||
block would also stop the jump, but spends more permanent vertical space than
|
||||
reusing the existing status strip.
|
||||
|
||||
The narrow verification target is mounted canvas-bounds equality across
|
||||
0→1→2→1→0 selection transitions, including mixed selection and a long primary
|
||||
name, with unchanged model/history for those selection changes. A native
|
||||
before/after capture should confirm stationary node screen positions and readable
|
||||
controls at the same 1442-pixel window width. No source changes, build or native
|
||||
interaction were performed by this reviewer for this follow-up.
|
||||
|
||||
### Delegated layout correction
|
||||
|
||||
The parent subsequently authorized edits to `views.rs`,
|
||||
`app/diagram_focus_tests.rs` and this note. The conditional toolbar group block
|
||||
is removed. Every active ordinary diagram now reserves a 56-pixel strip in the
|
||||
former hint location with a fixed 32-pixel controls row. Count is always shown;
|
||||
the existing group Hide/Clear intents and primary-only property explanation are
|
||||
shown for groups. The primary name remains shortened and all strip text is
|
||||
nonwrapping. Hide still requires whole-set placement admission and all App
|
||||
event guards are unchanged.
|
||||
|
||||
Idle selection hints expose Shift-click add/remove. Supported ordinary placement
|
||||
groups advertise dragging selected elements together; mixed groups and Allocation
|
||||
groups receive selection guidance without claiming a movement adapter they do
|
||||
not have. Active allocation feedback and connection instructions retain priority;
|
||||
singleton route/promoted-edge hints are preserved. The strip remains outside the
|
||||
canvas with clipping, and no overlay or global layout policy was introduced.
|
||||
|
||||
One mounted regression was added in source:
|
||||
`mounted_selection_strip_keeps_canvas_and_fitted_camera_fixed_for_empty_single_group_and_mixed`.
|
||||
It mounts the real App at 1442 by 932 with fitting enabled, a very long primary
|
||||
name and a visible edge, then checks 0→1→2→3 mixed→2→1→0 selection transitions.
|
||||
Each step must retain the exact measured canvas rectangle, zoom, pan, fit flag,
|
||||
observed canvas size, complete Project, source bundle and empty history.
|
||||
The previous implementation changes the canvas rectangle on the 1→2 transition,
|
||||
so this verifies the observed regression through production layout and sensors.
|
||||
|
||||
Only a static inspection and `git diff --check` were performed for these edits.
|
||||
Formatting, compilation, regression execution and a fresh native before/after
|
||||
capture remain owned by the parent. Native run-1 continues against its unchanged
|
||||
pinned binary and cannot serve as verification of this subsequent source edit.
|
||||
Reference in New Issue
Block a user