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.
686 lines
21 KiB
Rust
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);
|
|
}
|
|
}
|