Files
ARCADIA-Studio/crates/oa-desktop/src/diagram/group_gesture.rs
T
sorokin.oleg.edu 2a3b9ba822 Add exact State multiselection, atomic movement and placement Hide
Preserve the desktop-2 captured source (1286 passed, 6 existing ignored). Three menu payload blobs are staged from that verified capture without replacing working files; the strict-lint size correction follows in a separate commit. Native acceptance remains pending.
2026-09-23 03:37:01 -07:00

686 lines
21 KiB
Rust

//! Saved-view pointer geometry. Selection, capability and model admission remain
//! with the caller; this module never applies a selection or a model command.
use crate::diagram_selection::Snapshot;
use crate::diagram_usage::{Surface, Target};
use iced::{Point, Rectangle, Vector};
use oa_core::{Id, Position};
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Viewport {
pub zoom: f32,
pub pan: Vector,
pub bounds: Rectangle,
}
/// Node displacement policy; route-bend snapping remains outside this helper.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DisplacementPolicy {
/// Preserve the ordinary saved-view ten-unit anchor grid.
Grid10,
/// Preserve State views' fractional authored placement displacement.
Unsnapped,
}
#[derive(Debug)]
pub(crate) struct Gesture {
snapshot: Snapshot,
anchor: Id,
original: Position,
press: Point,
viewport: Viewport,
displacement: DisplacementPolicy,
crossed_threshold: bool,
delta: Position,
retired: bool,
}
#[derive(Debug, PartialEq)]
pub(crate) enum Outcome {
Click(Id),
Move(Snapshot, Position),
}
impl Gesture {
pub fn new(
snapshot: Snapshot,
anchor: Id,
original: Position,
press: Point,
viewport: Viewport,
) -> Option<Self> {
Self::new_with_policy(
snapshot,
anchor,
original,
press,
viewport,
DisplacementPolicy::Grid10,
)
}
/// `original` is the authored placement position, not its projected symbol
/// position (State entry/exit symbols can be displayed on another boundary).
/// Pointer displacement comes from frozen screen geometry and zoom.
pub fn new_with_policy(
snapshot: Snapshot,
anchor: Id,
original: Position,
press: Point,
viewport: Viewport,
displacement: DisplacementPolicy,
) -> Option<Self> {
if !matches!(snapshot.context().scope.surface, Surface::Saved(_))
|| !snapshot.targets().contains(&Target::Placement(anchor))
|| !snapshot
.targets()
.iter()
.all(|target| matches!(target, Target::Placement(_)))
|| !viewport.valid()
|| !finite_position(original)
|| !finite_point(press)
|| !viewport.bounds.contains(press)
{
return None;
}
Some(Self {
snapshot,
anchor,
original,
press,
viewport,
displacement,
crossed_threshold: false,
delta: Position { x: 0., y: 0. },
retired: false,
})
}
pub fn snapshot(&self) -> &Snapshot {
&self.snapshot
}
#[cfg(test)]
pub fn anchor(&self) -> Id {
self.anchor
}
/// Compare before processing each event. On mismatch, the caller must drop
/// this gesture, just as on Escape, focus loss or changed model authority.
pub fn viewport_matches(&self, viewport: Viewport) -> bool {
!self.retired && self.viewport == viewport
}
pub fn crossed_threshold(&self) -> bool {
self.crossed_threshold
}
/// Common policy-adjusted world delta for preview and release; zero before a drag.
pub fn delta(&self) -> Position {
self.delta
}
/// Process an absolute screen position. False permanently retires the
/// gesture; the caller must remove its preview and produce no operation.
pub fn move_to(&mut self, pointer: Point) -> bool {
if self.retired {
return false;
}
if !finite_point(pointer) || !self.viewport.bounds.contains(pointer) {
self.retire();
return false;
}
let screen = pointer - self.press;
self.crossed_threshold |= screen.x.hypot(screen.y) > 3.;
if !self.crossed_threshold {
return true;
}
let unsnapped = Position {
x: screen.x / self.viewport.zoom,
y: screen.y / self.viewport.zoom,
};
let desired = Position {
x: self.original.x + unsnapped.x,
y: self.original.y + unsnapped.y,
};
if !finite_position(desired) {
self.retire();
return false;
}
// Returning to the original position must not align an off-grid anchor
// as an accidental edit. It is still a drag, so selection stays grouped.
let delta = match self.displacement {
// Use the pointer delta directly, avoiding precision loss from
// subtracting a large authored original from its desired position.
DisplacementPolicy::Unsnapped => unsnapped,
DisplacementPolicy::Grid10 if desired == self.original => Position { x: 0., y: 0. },
DisplacementPolicy::Grid10 => Position {
x: (desired.x / 10.).round() * 10. - self.original.x,
y: (desired.y / 10.).round() * 10. - self.original.y,
},
};
if !finite_position(delta) {
self.retire();
return false;
}
self.delta = delta;
true
}
/// Consume the final pointer too: a release can be the first displaced
/// event. The caller must validate snapshot and viewport before acting on
/// either outcome. Cancelling is dropping the gesture without calling this.
pub fn release(mut self, pointer: Point) -> Option<Outcome> {
if !self.move_to(pointer) {
return None;
}
Some(if self.crossed_threshold {
Outcome::Move(self.snapshot, self.delta)
} else {
Outcome::Click(self.anchor)
})
}
fn retire(&mut self) {
self.retired = true;
self.delta = Position { x: 0., y: 0. };
}
}
impl Viewport {
fn valid(self) -> bool {
self.zoom.is_finite()
&& self.zoom > 0.
&& self.pan.x.is_finite()
&& self.pan.y.is_finite()
&& self.bounds.x.is_finite()
&& self.bounds.y.is_finite()
&& self.bounds.width.is_finite()
&& self.bounds.height.is_finite()
&& self.bounds.width > 0.
&& self.bounds.height > 0.
&& (self.bounds.x + self.bounds.width).is_finite()
&& (self.bounds.y + self.bounds.height).is_finite()
}
}
fn finite_point(point: Point) -> bool {
point.x.is_finite() && point.y.is_finite()
}
fn finite_position(position: Position) -> bool {
position.x.is_finite() && position.y.is_finite()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagram_selection::{Context, Scope, Selection};
fn id(value: u128) -> Id {
Id::from_u128(value)
}
fn context() -> Context {
Context {
scope: Scope {
document: id(1),
surface: Surface::Saved(id(2)),
},
revision: 3,
epoch: 4,
}
}
fn snapshot(targets: impl IntoIterator<Item = Target>) -> Snapshot {
let mut selection = Selection::new(context().scope);
for target in targets {
selection.add(target, |_| true);
}
selection.snapshot(context()).unwrap()
}
fn viewport() -> Viewport {
Viewport {
zoom: 1.,
pan: Vector::new(17., -8.),
bounds: Rectangle {
x: 100.,
y: 50.,
width: 400.,
height: 300.,
},
}
}
fn press() -> Point {
Point::new(150., 120.)
}
fn gesture(original: Position) -> Gesture {
Gesture::new(
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
id(10),
original,
press(),
viewport(),
)
.unwrap()
}
#[test]
fn stationary_or_exact_threshold_release_is_click_without_snapping() {
for pointer in [press(), Point::new(153., 120.), Point::new(152., 122.)] {
let mut gesture = gesture(Position { x: 13., y: -7. });
assert!(gesture.move_to(pointer));
assert!(!gesture.crossed_threshold());
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
assert_eq!(gesture.release(pointer), Some(Outcome::Click(id(10))));
}
}
#[test]
fn threshold_latches_and_return_to_offgrid_origin_is_zero_move() {
let mut gesture = gesture(Position { x: 13., y: -7. });
let captured = gesture.snapshot().clone();
assert!(gesture.move_to(Point::new(170., 140.)));
assert!(gesture.crossed_threshold());
assert!(gesture.move_to(press()));
assert!(gesture.crossed_threshold());
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
assert_eq!(
gesture.release(press()),
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
);
}
#[test]
fn release_only_displacement_that_snaps_to_zero_still_preserves_group() {
let gesture = gesture(Position { x: 20., y: 40. });
let captured = gesture.snapshot().clone();
assert_eq!(
gesture.release(Point::new(154., 120.)),
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
);
}
#[test]
fn offgrid_anchor_yields_one_shared_delta_and_release_matches_preview() {
let mut gesture = gesture(Position { x: 13., y: -7. });
let captured = gesture.snapshot().clone();
assert_eq!(gesture.anchor(), id(10));
let pointer = Point::new(167., 133.);
assert!(gesture.move_to(pointer));
// Desired anchor (30,6) snaps to (30,10), hence shared delta (17,17).
assert_eq!(gesture.delta(), Position { x: 17., y: 17. });
let sibling = Position { x: -6., y: 24. };
let translated = Position {
x: sibling.x + gesture.delta().x,
y: sibling.y + gesture.delta().y,
};
assert_eq!(translated, Position { x: 11., y: 41. });
assert_eq!(
gesture.release(pointer),
Some(Outcome::Move(captured, Position { x: 17., y: 17. }))
);
}
#[test]
fn zoom_scales_world_delta_but_threshold_remains_in_screen_pixels() {
let mut gesture = Gesture::new(
snapshot([Target::Placement(id(10))]),
id(10),
Position { x: 20., y: 40. },
press(),
Viewport {
zoom: 2.,
..viewport()
},
)
.unwrap();
assert!(gesture.move_to(Point::new(153., 120.)));
assert!(!gesture.crossed_threshold());
assert!(gesture.move_to(Point::new(168., 112.)));
// Screen (18,-8) means world (9,-4), snapped anchor (30,40).
assert_eq!(gesture.delta(), Position { x: 10., y: 0. });
assert!(gesture.crossed_threshold());
}
#[test]
fn unsnapped_fractional_pointer_delta_preserves_authored_offset() {
let authored = Position {
x: 1024.125,
y: -321.5,
};
let mut gesture = Gesture::new_with_policy(
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
id(10),
authored,
press(),
Viewport {
zoom: 2.,
..viewport()
},
DisplacementPolicy::Unsnapped,
)
.unwrap();
let captured = gesture.snapshot().clone();
let pointer = Point::new(164.5, 114.5);
assert!(gesture.move_to(pointer));
assert_eq!(gesture.delta(), Position { x: 7.25, y: -2.75 });
let translated_authored = Position {
x: authored.x + gesture.delta().x,
y: authored.y + gesture.delta().y,
};
assert_eq!(
translated_authored,
Position {
x: 1031.375,
y: -324.25
}
);
assert_eq!(
gesture.release(pointer),
Some(Outcome::Move(captured, Position { x: 7.25, y: -2.75 })),
);
}
#[test]
fn unsnapped_threshold_and_return_to_origin_preserve_group_lifecycle() {
let make = || {
Gesture::new_with_policy(
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
id(10),
Position { x: 13.25, y: -7.5 },
press(),
viewport(),
DisplacementPolicy::Unsnapped,
)
.unwrap()
};
let mut click = make();
assert!(click.move_to(Point::new(153., 120.)));
assert!(!click.crossed_threshold());
assert_eq!(click.delta(), Position { x: 0., y: 0. });
assert_eq!(
click.release(Point::new(153., 120.)),
Some(Outcome::Click(id(10)))
);
let mut drag = make();
let captured = drag.snapshot().clone();
assert!(drag.move_to(Point::new(157.25, 117.25)));
assert_eq!(drag.delta(), Position { x: 7.25, y: -2.75 });
assert!(drag.move_to(press()));
assert!(drag.crossed_threshold());
assert_eq!(drag.delta(), Position { x: 0., y: 0. });
assert_eq!(
drag.release(press()),
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
);
}
#[test]
fn explicit_grid_and_default_constructor_keep_existing_quantization() {
let original = Position { x: 13., y: -7. };
let mut default = gesture(original);
let mut explicit = Gesture::new_with_policy(
default.snapshot().clone(),
id(10),
original,
press(),
viewport(),
DisplacementPolicy::Grid10,
)
.unwrap();
let pointer = Point::new(167., 133.);
assert!(default.move_to(pointer));
assert!(explicit.move_to(pointer));
// Existing ordinary behavior: desired (30,6), grid (30,10).
assert_eq!(default.delta(), Position { x: 17., y: 17. });
assert_eq!(explicit.delta(), Position { x: 17., y: 17. });
assert_eq!(default.release(pointer), explicit.release(pointer));
}
#[test]
fn unsnapped_release_only_movement_and_invalid_pointer_retirement() {
let make = || {
Gesture::new_with_policy(
snapshot([Target::Placement(id(10))]),
id(10),
Position {
x: 20.125,
y: 40.25,
},
press(),
viewport(),
DisplacementPolicy::Unsnapped,
)
.unwrap()
};
let release_only = make();
let captured = release_only.snapshot().clone();
assert_eq!(
release_only.release(Point::new(154.25, 120.)),
Some(Outcome::Move(captured, Position { x: 4.25, y: 0. })),
);
for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] {
let mut gesture = make();
assert!(gesture.move_to(Point::new(154.25, 120.)));
assert!(!gesture.move_to(invalid));
assert!(!gesture.viewport_matches(viewport()));
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
assert_eq!(gesture.release(press()), None);
}
}
#[test]
fn full_typed_selection_and_saved_surface_are_required() {
for targets in [
vec![],
vec![Target::Placement(id(11))],
vec![Target::Placement(id(10)), Target::Edge(id(20))],
vec![
Target::Placement(id(10)),
Target::Port {
port: id(20),
placement: id(10),
},
],
] {
assert!(
Gesture::new(
snapshot(targets),
id(10),
Position { x: 0., y: 0. },
press(),
viewport()
)
.is_none()
);
}
let other = Context {
scope: Scope {
surface: Surface::Process(id(2)),
..context().scope
},
..context()
};
let mut selection = Selection::new(other.scope);
selection.add(Target::Placement(id(10)), |_| true);
assert!(
Gesture::new(
selection.snapshot(other).unwrap(),
id(10),
Position { x: 0., y: 0. },
press(),
viewport()
)
.is_none()
);
}
#[test]
fn any_frozen_viewport_change_is_detected() {
let gesture = gesture(Position { x: 0., y: 0. });
assert!(gesture.viewport_matches(viewport()));
for changed in [
Viewport {
zoom: 2.,
..viewport()
},
Viewport {
pan: Vector::new(18., -8.),
..viewport()
},
Viewport {
pan: Vector::new(17., -7.),
..viewport()
},
Viewport {
bounds: Rectangle {
x: 101.,
..viewport().bounds
},
..viewport()
},
Viewport {
bounds: Rectangle {
y: 51.,
..viewport().bounds
},
..viewport()
},
Viewport {
bounds: Rectangle {
width: 401.,
..viewport().bounds
},
..viewport()
},
Viewport {
bounds: Rectangle {
height: 301.,
..viewport().bounds
},
..viewport()
},
] {
assert!(!gesture.viewport_matches(changed));
}
}
#[test]
fn invalid_initial_geometry_cannot_start() {
let saved = snapshot([Target::Placement(id(10))]);
for invalid in [
Viewport {
zoom: 0.,
..viewport()
},
Viewport {
zoom: -1.,
..viewport()
},
Viewport {
zoom: f32::NAN,
..viewport()
},
Viewport {
zoom: f32::INFINITY,
..viewport()
},
Viewport {
pan: Vector::new(f32::NAN, 0.),
..viewport()
},
Viewport {
bounds: Rectangle {
width: 0.,
..viewport().bounds
},
..viewport()
},
Viewport {
bounds: Rectangle {
height: -1.,
..viewport().bounds
},
..viewport()
},
Viewport {
bounds: Rectangle {
x: f32::INFINITY,
..viewport().bounds
},
..viewport()
},
] {
assert!(
Gesture::new(
saved.clone(),
id(10),
Position { x: 0., y: 0. },
press(),
invalid
)
.is_none()
);
}
assert!(
Gesture::new(
saved.clone(),
id(10),
Position { x: f32::NAN, y: 0. },
press(),
viewport()
)
.is_none()
);
for invalid in [Point::new(f32::NAN, 120.), Point::new(99., 120.)] {
assert!(
Gesture::new(
saved.clone(),
id(10),
Position { x: 0., y: 0. },
invalid,
viewport()
)
.is_none()
);
}
}
#[test]
fn invalid_pointer_permanently_retires_even_if_pointer_returns() {
for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] {
let mut gesture = gesture(Position { x: 20., y: 40. });
assert!(gesture.move_to(Point::new(170., 140.)));
assert!(!gesture.move_to(invalid));
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
assert!(!gesture.move_to(press()));
assert!(!gesture.viewport_matches(viewport()));
assert_eq!(gesture.release(press()), None);
}
assert_eq!(
gesture(Position { x: 20., y: 40. }).release(Point::new(501., 120.)),
None
);
}
#[test]
fn finite_inputs_that_overflow_world_geometry_retire_without_an_operation() {
let mut gesture = Gesture::new(
snapshot([Target::Placement(id(10))]),
id(10),
Position { x: 0., y: 0. },
press(),
Viewport {
zoom: f32::MIN_POSITIVE,
..viewport()
},
)
.unwrap();
assert!(!gesture.move_to(Point::new(170., 140.)));
assert_eq!(gesture.release(press()), None);
}
}