Skip to main content

teksilo_scene/
magnet.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Magnetism: typed snap-and-connect between anchor points on scene items.
5//!
6//! A **magnet** is a local point on an item (relative to the item's
7//! anchor, like a child point), carrying a type-erased payload
8//! (`'static`, downcastable) and a directional [`MagnetRole`]. An item
9//! can carry several. During an interaction the scene broad-phases
10//! nearby magnets, runs an accept/reject [predicate](MagnetVerdict) per
11//! candidate pair, snaps so the closest accepting pair aligns, and on
12//! release a connection event carries the payloads to the consumer.
13//!
14//! # Mechanism in scene, policy in the consumer
15//!
16//! This module and [`Scene`](crate::Scene) own the *mechanism*: magnet
17//! geometry, broad-phase, snap math, and the connection result. They do
18//! **not** own *policy* — which magnet types are compatible, what a
19//! connection means, or whether a connection persists. Compatibility is
20//! decided entirely by the predicate the consumer supplies to
21//! [`Scene::compute_item_snap`](crate::Scene::compute_item_snap) /
22//! [`Scene::compute_port_snap`](crate::Scene::compute_port_snap); the
23//! meaning of a formed connection is decided by the consumer's
24//! `on_connect` handler. No widget-tree or designer concept (slot,
25//! category, insertion index) leaks into this API; those live in the
26//! payloads and the predicate.
27//!
28//! [`MagnetRole`] is generic node-graph / diagram vocabulary used by the
29//! scene only for default feedback (which end is the source) and for
30//! ordering the keyboard connect flow. It is advisory: the predicate is
31//! always authoritative on whether two magnets may connect.
32//!
33//! ## Example — two items connected by a typed magnet pair
34//!
35//! ```rust
36//! use teksilo_scene::{Scene, RectItem, Magnet, MagnetRole, MagnetRef, MagnetVerdict};
37//! use teksilo_canvas::{Point, Rect, Vec2};
38//!
39//! // A predicate that accepts Source → Target pairs on different items.
40//! fn source_to_target(a: &MagnetRef, b: &MagnetRef) -> MagnetVerdict {
41//!     if a.item == b.item { return MagnetVerdict::Reject; }
42//!     match (a.role, b.role) {
43//!         (MagnetRole::Source, MagnetRole::Target)
44//!         | (MagnetRole::Target, MagnetRole::Source) => MagnetVerdict::accept(),
45//!         _ => MagnetVerdict::Reject,
46//!     }
47//! }
48//!
49//! let mut scene = Scene::new();
50//!
51//! // Dragged item with a Source magnet at its local origin.
52//! let dragged = scene.add_item(
53//!     RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
54//!     Point::ZERO,
55//! );
56//! scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
57//!
58//! // Target item 100 px to the right with a Target magnet at its local origin.
59//! let target = scene.add_item(
60//!     RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
61//!     Point::new(100.0, 0.0),
62//! );
63//! scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Target));
64//!
65//! // The dragged item is 5 px away from snapping; capture radius 20 px.
66//! if let Some(snap) = scene.compute_item_snap(dragged, Vec2::new(95.0, 0.0), 20.0, &source_to_target) {
67//!     // snap_vector carries the dragged item exactly onto the target magnet.
68//!     assert!((snap.snap_vector.x - 5.0).abs() < 1e-3);
69//! }
70//! ```
71
72use std::any::Any;
73use std::rc::Rc;
74use std::sync::atomic::{AtomicU64, Ordering};
75
76use teksilo_canvas::{Canvas, Point, Vec2};
77use teksilo_core::event::Key;
78use teksilo_core::signal::{Prop, Signal};
79use teksilo_core::widget::{EventContext, PaintContext};
80use teksilo_i18n::LocalizedString;
81
82use crate::item::ItemId;
83
84/// Opaque identifier for a [`Magnet`] inside a [`Scene`](crate::Scene).
85///
86/// Globally unique within a process, minted by `MagnetId::next`. Stable
87/// across the magnet's lifetime; removing a magnet (or its owning item)
88/// retires its id permanently — ids are never reused.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
90pub struct MagnetId(pub(crate) u64);
91
92impl MagnetId {
93    /// Mint a fresh globally-unique id. Used internally by Scene.
94    pub(crate) fn next() -> Self {
95        static COUNTER: AtomicU64 = AtomicU64::new(1);
96        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
97    }
98
99    /// Raw numeric value, used by AccessKit's synthetic-NodeId derivation.
100    pub fn as_u64(self) -> u64 {
101        self.0
102    }
103}
104
105/// The direction a magnet faces in a connection.
106///
107/// Advisory only — the scene uses it for default feedback (arrow
108/// direction, which end starts the keyboard flow), but the
109/// accept/reject predicate is always the authority on compatibility. A
110/// node-graph output port is a [`Source`](MagnetRole::Source), an input
111/// port is a [`Target`](MagnetRole::Target); a snap point that can be
112/// either end is [`Bidirectional`](MagnetRole::Bidirectional).
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub enum MagnetRole {
115    /// Originates a connection (e.g. a node-graph output port).
116    Source,
117    /// Receives a connection (e.g. a node-graph input port).
118    Target,
119    /// Can be either end of a connection.
120    Bidirectional,
121}
122
123/// A magnetism anchor attached to a scene item.
124///
125/// Built fluently and handed to
126/// [`SceneModel::add_magnet`](crate::SceneModel::add_magnet). Carries a
127/// local-frame position, a [`MagnetRole`], an optional type-erased
128/// payload, an enabled flag, and an optional accessibility label.
129#[derive(Clone)]
130pub struct Magnet {
131    pub(crate) local_pos: Point,
132    pub(crate) role: MagnetRole,
133    pub(crate) payload: Option<Rc<dyn Any>>,
134    pub(crate) enabled: bool,
135    pub(crate) label: Option<LocalizedString>,
136}
137
138impl std::fmt::Debug for Magnet {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("Magnet")
141            .field("local_pos", &self.local_pos)
142            .field("role", &self.role)
143            .field("has_payload", &self.payload.is_some())
144            .field("enabled", &self.enabled)
145            .field("has_label", &self.label.is_some())
146            .finish()
147    }
148}
149
150impl Magnet {
151    /// A magnet at `local_pos` in the owning item's local frame, role
152    /// [`Bidirectional`](MagnetRole::Bidirectional), no payload, enabled.
153    pub fn new(local_pos: Point) -> Self {
154        Self {
155            local_pos,
156            role: MagnetRole::Bidirectional,
157            payload: None,
158            enabled: true,
159            label: None,
160        }
161    }
162
163    /// Set the connection direction (advisory — see [`MagnetRole`]).
164    pub fn role(mut self, role: MagnetRole) -> Self {
165        self.role = role;
166        self
167    }
168
169    /// Attach a type-erased payload the predicate and the connection
170    /// event can downcast. Cheap to carry around (held in an `Rc`).
171    pub fn payload<P: 'static>(mut self, payload: P) -> Self {
172        self.payload = Some(Rc::new(payload));
173        self
174    }
175
176    /// Attach an already-`Rc`-wrapped payload (use when several magnets
177    /// share one payload object).
178    pub fn payload_rc(mut self, payload: Rc<dyn Any>) -> Self {
179        self.payload = Some(payload);
180        self
181    }
182
183    /// The accessibility name announced for this magnet's synthetic AT
184    /// node. Defaults to a generic role-based label when unset.
185    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
186        self.label = Some(label.into());
187        self
188    }
189
190    /// Disabled magnets are skipped by broad-phase, feedback, the
191    /// keyboard cycle, and AT emission. Enabled by default.
192    pub fn enabled(mut self, on: bool) -> Self {
193        self.enabled = on;
194        self
195    }
196}
197
198/// An owned, borrow-free snapshot of one magnet, handed to the
199/// accept/reject predicate and carried in a [`MagnetConnection`].
200///
201/// The payload is an `Rc` clone, so a snapshot can outlive the borrow
202/// taken to collect candidates. The predicate inspects these snapshots
203/// while a shared (read-only) scene borrow is held — it may read the
204/// model but must not mutate it. The `on_connect` handler, by contrast,
205/// runs after every borrow is dropped and may freely mutate the model
206/// (add an edge item, reparent, fire an intent).
207#[derive(Clone)]
208pub struct MagnetRef {
209    /// The magnet's id.
210    pub id: MagnetId,
211    /// The item the magnet is attached to.
212    pub item: ItemId,
213    /// The magnet's advisory direction.
214    pub role: MagnetRole,
215    /// The magnet's payload, if any (an `Rc` clone of the stored one).
216    pub payload: Option<Rc<dyn Any>>,
217    /// The magnet's current position in scene coordinates.
218    pub scene_pos: Point,
219}
220
221impl std::fmt::Debug for MagnetRef {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        f.debug_struct("MagnetRef")
224            .field("id", &self.id)
225            .field("item", &self.item)
226            .field("role", &self.role)
227            .field("has_payload", &self.payload.is_some())
228            .field("scene_pos", &self.scene_pos)
229            .finish()
230    }
231}
232
233impl MagnetRef {
234    /// Borrow the payload downcast to `P`, or `None` if absent or a
235    /// different type. The ergonomic way to read a typed payload inside
236    /// a predicate.
237    pub fn payload_as<P: 'static>(&self) -> Option<&P> {
238        self.payload.as_ref().and_then(|p| p.downcast_ref::<P>())
239    }
240}
241
242/// The result of running the accept/reject predicate on a candidate
243/// magnet pair. "Both payloads in, reject or accept-with-payload out."
244pub enum MagnetVerdict {
245    /// The pair may not connect; the scene skips it.
246    Reject,
247    /// The pair may connect. The optional payload is attached to the
248    /// resulting [`MagnetConnection`] (e.g. a derived edge descriptor).
249    Accept(Option<Rc<dyn Any>>),
250}
251
252impl MagnetVerdict {
253    /// Accept with no extra connection payload.
254    pub fn accept() -> Self {
255        MagnetVerdict::Accept(None)
256    }
257
258    /// Accept and attach a typed connection payload.
259    pub fn accept_with<P: 'static>(payload: P) -> Self {
260        MagnetVerdict::Accept(Some(Rc::new(payload)))
261    }
262
263    /// Whether this verdict accepts the pair.
264    pub fn is_accept(&self) -> bool {
265        matches!(self, MagnetVerdict::Accept(_))
266    }
267}
268
269/// A formed connection between two magnets, delivered to the consumer's
270/// `on_connect` handler on release (mouse) or confirm (keyboard).
271///
272/// `from` is the magnet that initiated the connection (the dragged
273/// item's magnet, the grabbed port, or the keyboard-activated source);
274/// `to` is the magnet it connected onto. `payload` is whatever the
275/// predicate's [`MagnetVerdict::Accept`] carried.
276#[derive(Clone)]
277pub struct MagnetConnection {
278    /// The initiating magnet.
279    pub from: MagnetRef,
280    /// The receiving magnet.
281    pub to: MagnetRef,
282    /// The connection payload from the accepting verdict, if any.
283    pub payload: Option<Rc<dyn Any>>,
284}
285
286impl std::fmt::Debug for MagnetConnection {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        f.debug_struct("MagnetConnection")
289            .field("from", &self.from)
290            .field("to", &self.to)
291            .field("has_payload", &self.payload.is_some())
292            .finish()
293    }
294}
295
296impl MagnetConnection {
297    /// Borrow the connection payload downcast to `P`.
298    pub fn payload_as<P: 'static>(&self) -> Option<&P> {
299        self.payload.as_ref().and_then(|p| p.downcast_ref::<P>())
300    }
301}
302
303/// The chosen snap when a dragged item's magnet aligns onto another
304/// item's magnet. Returned by
305/// [`Scene::compute_item_snap`](crate::Scene::compute_item_snap).
306///
307/// A heavyweight consumer that drives its own drag uses `snap_vector` to
308/// place the item so `from` lands on `to`, and resolves `from` / `to`
309/// via [`Scene::magnet`](crate::Scene::magnet) to build the connection
310/// for its own `on_connect`.
311#[derive(Clone)]
312pub struct MagnetSnap {
313    /// The dragged item's magnet that is snapping.
314    pub from: MagnetId,
315    /// The stationary magnet it snaps onto.
316    pub to: MagnetId,
317    /// Add this to the drag delta (or the item's position) so `from`'s
318    /// scene position coincides with `to`'s.
319    pub snap_vector: Vec2,
320    /// The accepting verdict's payload, if any.
321    pub payload: Option<Rc<dyn Any>>,
322    /// Scene-space distance between the pair before snapping (the
323    /// tie-break used to pick the closest accepting pair).
324    pub distance: f32,
325}
326
327impl std::fmt::Debug for MagnetSnap {
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        f.debug_struct("MagnetSnap")
330            .field("from", &self.from)
331            .field("to", &self.to)
332            .field("snap_vector", &self.snap_vector)
333            .field("has_payload", &self.payload.is_some())
334            .field("distance", &self.distance)
335            .finish()
336    }
337}
338
339/// When the [`SceneView`](crate::SceneView) paints magnet markers.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum MarkerVisibility {
342    /// Always draw a marker for every enabled magnet (busy, but the
343    /// clearest discoverability — good for a dedicated editor).
344    Always,
345    /// Draw markers only while an interaction is in progress (an item
346    /// drag, a port drag, or keyboard connect mode). The default — keeps
347    /// an idle scene clean.
348    DuringInteraction,
349    /// Never draw markers (the consumer paints its own via the feedback
350    /// hook, or wants no visual at all).
351    Never,
352}
353
354/// The visual state of a magnet as the feedback renderer sees it.
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum MagnetVisualState {
357    /// A normal, idle magnet.
358    Idle,
359    /// A magnet the current interaction could connect to (it passes the
360    /// predicate against the active source).
361    Candidate,
362    /// The magnet the active interaction is currently snapped onto.
363    Snapped,
364    /// The keyboard-focused magnet (connect mode).
365    Focused,
366    /// The keyboard-activated source magnet awaiting a target.
367    PendingSource,
368}
369
370/// One magnet's render data, handed to the feedback renderer.
371#[derive(Debug, Clone, Copy)]
372pub struct MagnetMarker {
373    /// The magnet's id.
374    pub id: MagnetId,
375    /// Its current scene position.
376    pub scene_pos: Point,
377    /// Its advisory role.
378    pub role: MagnetRole,
379    /// Its visual state for this frame.
380    pub state: MagnetVisualState,
381}
382
383/// Everything the magnetism feedback renderer needs for one frame, in
384/// scene coordinates (the canvas is already in the view-transform
385/// scope). The built-in renderer draws markers plus a connector; a
386/// custom [`MagnetismConfig::feedback`] closure receives the same data.
387#[derive(Debug, Clone)]
388pub struct MagnetFeedback {
389    /// The view's current geometric zoom, so the renderer can size
390    /// constant-pixel chrome as `pixels / zoom` in scene units.
391    pub zoom: f32,
392    /// Eligible magnets to mark, with their per-frame state.
393    pub markers: Vec<MagnetMarker>,
394    /// A connector to draw between two scene points, if an interaction
395    /// is forming one: the active item-drag snap pair, the port-drag
396    /// wire (source to snapped target or cursor), or the keyboard
397    /// preview (pending source to focused candidate). `true` in the
398    /// second field marks an *accepted* connector (drawn solid /
399    /// highlighted) versus a tentative one (the free port-drag wire).
400    pub connector: Option<(Point, Point, bool)>,
401}
402
403/// Per-view magnetism configuration, installed via
404/// [`SceneView::magnetism`](crate::SceneView::magnetism).
405///
406/// Holds the consumer's *policy* — the accept/reject predicate and the
407/// `on_connect` handler — plus presentation knobs. The scene supplies
408/// the mechanism (snap math, broad-phase, feedback rendering, the
409/// connection event); this config is where the consumer plugs its
410/// policy in.
411#[derive(Clone)]
412pub struct MagnetismConfig {
413    pub(crate) predicate: Rc<dyn Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict>,
414    pub(crate) on_connect: Rc<dyn Fn(&MagnetConnection, &mut EventContext)>,
415    pub(crate) capture_px: f32,
416    pub(crate) markers: MarkerVisibility,
417    pub(crate) feedback: Option<Rc<dyn Fn(&mut Canvas, &PaintContext, &MagnetFeedback)>>,
418    pub(crate) connect_key: Key,
419    pub(crate) enabled: Signal<bool>,
420}
421
422impl std::fmt::Debug for MagnetismConfig {
423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424        f.debug_struct("MagnetismConfig")
425            .field("capture_px", &self.capture_px)
426            .field("markers", &self.markers)
427            .field("has_custom_feedback", &self.feedback.is_some())
428            .field("connect_key", &self.connect_key)
429            .field("enabled", &self.enabled.get())
430            .finish()
431    }
432}
433
434impl MagnetismConfig {
435    /// A config with the given accept/reject `predicate` and defaults:
436    /// 14 px capture radius, markers during interaction, the built-in
437    /// feedback renderer, `m` to toggle keyboard connect mode, enabled.
438    /// Install an `on_connect` handler to actually do something on
439    /// connect.
440    pub fn new(predicate: impl Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict + 'static) -> Self {
441        Self {
442            predicate: Rc::new(predicate),
443            on_connect: Rc::new(|_, _| {}),
444            capture_px: 14.0,
445            markers: MarkerVisibility::DuringInteraction,
446            feedback: None,
447            connect_key: Key::Character('m'),
448            enabled: Signal::new(true),
449        }
450    }
451
452    /// The handler invoked when a connection is formed (mouse release or
453    /// keyboard confirm). Runs with a live `EventContext` and no scene
454    /// borrow held, so it may mutate the model (add an edge item,
455    /// reparent), call `scene.add_a11y_relation`, or fire an intent.
456    pub fn on_connect(
457        mut self,
458        f: impl Fn(&MagnetConnection, &mut EventContext) + 'static,
459    ) -> Self {
460        self.on_connect = Rc::new(f);
461        self
462    }
463
464    /// Capture and grab radius in **screen pixels** (converted to scene
465    /// units by dividing by the live zoom, so snapping feels consistent
466    /// at any zoom). Default 14.
467    pub fn capture_px(mut self, px: f32) -> Self {
468        self.capture_px = px.max(0.0);
469        self
470    }
471
472    /// When magnet markers are painted. Default
473    /// [`MarkerVisibility::DuringInteraction`].
474    pub fn markers(mut self, markers: MarkerVisibility) -> Self {
475        self.markers = markers;
476        self
477    }
478
479    /// Replace the built-in feedback renderer with a custom one. The
480    /// closure paints in scene coordinates (the canvas already has the
481    /// view transform pushed).
482    pub fn feedback(
483        mut self,
484        f: impl Fn(&mut Canvas, &PaintContext, &MagnetFeedback) + 'static,
485    ) -> Self {
486        self.feedback = Some(Rc::new(f));
487        self
488    }
489
490    /// The key that toggles keyboard connect mode while the SceneView is
491    /// focused. Default `m`.
492    pub fn connect_key(mut self, key: Key) -> Self {
493        self.connect_key = key;
494        self
495    }
496
497    /// Set the enabled state, statically or reactively (an app-owned
498    /// signal drives enabled/disabled from e.g. a toolbar toggle).
499    pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self {
500        self.enabled = on.into().as_signal();
501        self
502    }
503
504    /// The reactive enabled signal, for a toolbar to read or bind.
505    pub fn enabled_signal(&self) -> Signal<bool> {
506        self.enabled.clone()
507    }
508
509    /// Whether magnetism is currently enabled.
510    pub fn is_enabled(&self) -> bool {
511        self.enabled.get()
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::scene::Scene;
519    use teksilo_canvas::{Point, Transform2D};
520
521    /// Helper: a predicate that accepts any Source -> Target pair on
522    /// different items, rejecting same-role and same-item pairs.
523    fn source_to_target(a: &MagnetRef, b: &MagnetRef) -> MagnetVerdict {
524        if a.item == b.item {
525            return MagnetVerdict::Reject;
526        }
527        match (a.role, b.role) {
528            (MagnetRole::Source, MagnetRole::Target) | (MagnetRole::Target, MagnetRole::Source) => {
529                MagnetVerdict::accept()
530            }
531            _ => MagnetVerdict::Reject,
532        }
533    }
534
535    #[test]
536    fn magnet_ids_are_unique_and_monotonic() {
537        let a = MagnetId::next();
538        let b = MagnetId::next();
539        assert_ne!(a, b);
540        assert!(b.as_u64() > a.as_u64());
541    }
542
543    #[test]
544    fn magnet_builder_defaults_and_setters() {
545        let m = Magnet::new(Point::new(3.0, 4.0));
546        assert_eq!(m.local_pos, Point::new(3.0, 4.0));
547        assert_eq!(m.role, MagnetRole::Bidirectional);
548        assert!(m.enabled);
549        assert!(m.payload.is_none());
550
551        let m = Magnet::new(Point::ZERO)
552            .role(MagnetRole::Source)
553            .payload(42_u32)
554            .enabled(false);
555        assert_eq!(m.role, MagnetRole::Source);
556        assert!(!m.enabled);
557        assert_eq!(
558            m.payload.as_ref().unwrap().downcast_ref::<u32>(),
559            Some(&42_u32)
560        );
561    }
562
563    #[test]
564    fn verdict_helpers() {
565        assert!(MagnetVerdict::accept().is_accept());
566        assert!(MagnetVerdict::accept_with(7_i32).is_accept());
567        assert!(!MagnetVerdict::Reject.is_accept());
568    }
569
570    // --- compute_item_snap ------------------------------------------
571
572    #[test]
573    fn item_snap_snaps_to_nearest_accepting_magnet() {
574        let mut scene = Scene::new();
575        // Dragged item at origin with a Source magnet at its local (0,0).
576        let dragged = scene.add_item(
577            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
578            Point::new(0.0, 0.0),
579        );
580        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
581
582        // Target item at (100, 0) with a Target magnet at its local (0,0),
583        // i.e. scene (100, 0).
584        let target = scene.add_item(
585            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
586            Point::new(100.0, 0.0),
587        );
588        let tmag = scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Target));
589
590        // Drag so the source magnet sits at scene (95, 0): 5 px shy of the
591        // target. With a 20 px capture radius it should snap.
592        let snap = scene
593            .compute_item_snap(dragged, Vec2::new(95.0, 0.0), 20.0, &source_to_target)
594            .expect("expected a snap");
595        assert_eq!(snap.to, tmag);
596        // snap_vector should carry the source from (95,0) to (100,0): +5 x.
597        assert!((snap.snap_vector.x - 5.0).abs() < 1e-3);
598        assert!(snap.snap_vector.y.abs() < 1e-3);
599    }
600
601    #[test]
602    fn item_snap_ignores_pairs_beyond_radius() {
603        let mut scene = Scene::new();
604        let dragged = scene.add_item(
605            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
606            Point::ZERO,
607        );
608        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
609        let target = scene.add_item(
610            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
611            Point::new(100.0, 0.0),
612        );
613        scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Target));
614
615        // Source dragged to (50,0): 50 px from the target, capture radius 20.
616        let snap = scene.compute_item_snap(dragged, Vec2::new(50.0, 0.0), 20.0, &source_to_target);
617        assert!(snap.is_none());
618    }
619
620    #[test]
621    fn item_snap_respects_rejecting_predicate() {
622        let mut scene = Scene::new();
623        let dragged = scene.add_item(
624            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
625            Point::ZERO,
626        );
627        // Both Source — source_to_target rejects same-role pairs.
628        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
629        let target = scene.add_item(
630            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
631            Point::new(100.0, 0.0),
632        );
633        scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Source));
634
635        let snap = scene.compute_item_snap(dragged, Vec2::new(98.0, 0.0), 20.0, &source_to_target);
636        assert!(snap.is_none());
637    }
638
639    #[test]
640    fn item_snap_excludes_dragged_items_own_magnets() {
641        let mut scene = Scene::new();
642        let dragged = scene.add_item(
643            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
644            Point::ZERO,
645        );
646        // Two magnets on the SAME item that would otherwise satisfy the
647        // predicate (Source + Target) and be near each other.
648        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
649        scene.add_magnet(
650            dragged,
651            Magnet::new(Point::new(2.0, 0.0)).role(MagnetRole::Target),
652        );
653
654        let snap = scene.compute_item_snap(dragged, Vec2::ZERO, 20.0, &source_to_target);
655        assert!(snap.is_none(), "a dragged item must not snap to itself");
656    }
657
658    #[test]
659    fn item_snap_picks_global_minimum_distance() {
660        let mut scene = Scene::new();
661        let dragged = scene.add_item(
662            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
663            Point::ZERO,
664        );
665        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
666
667        // Two candidate targets; the nearer one wins.
668        let near = scene.add_item(
669            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
670            Point::new(10.0, 0.0),
671        );
672        let near_mag = scene.add_magnet(near, Magnet::new(Point::ZERO).role(MagnetRole::Target));
673        let far = scene.add_item(
674            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
675            Point::new(18.0, 0.0),
676        );
677        scene.add_magnet(far, Magnet::new(Point::ZERO).role(MagnetRole::Target));
678
679        // Source dragged to (8,0): 2 px from `near`, 10 px from `far`.
680        let snap = scene
681            .compute_item_snap(dragged, Vec2::new(8.0, 0.0), 20.0, &source_to_target)
682            .expect("snap");
683        assert_eq!(snap.to, near_mag);
684    }
685
686    #[test]
687    fn item_snap_carries_verdict_payload() {
688        let mut scene = Scene::new();
689        let dragged = scene.add_item(
690            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
691            Point::ZERO,
692        );
693        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
694        let target = scene.add_item(
695            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
696            Point::new(100.0, 0.0),
697        );
698        scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Target));
699
700        let pred = |a: &MagnetRef, b: &MagnetRef| {
701            if a.item != b.item {
702                MagnetVerdict::accept_with(String::from("edge"))
703            } else {
704                let _ = b;
705                MagnetVerdict::Reject
706            }
707        };
708        let snap = scene
709            .compute_item_snap(dragged, Vec2::new(98.0, 0.0), 20.0, &pred)
710            .expect("snap");
711        assert_eq!(
712            snap.payload.as_ref().unwrap().downcast_ref::<String>(),
713            Some(&String::from("edge"))
714        );
715    }
716
717    #[test]
718    fn item_snap_honors_item_transform() {
719        let mut scene = Scene::new();
720        let dragged = scene.add_item(
721            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
722            Point::ZERO,
723        );
724        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
725
726        // Target item scaled 2x with a magnet at local (10,0) -> scene (20,0).
727        let target = scene.add_item(
728            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
729            Point::ZERO,
730        );
731        scene.set_transform(target, Transform2D::scale(2.0, 2.0));
732        let tmag = scene.add_magnet(
733            target,
734            Magnet::new(Point::new(10.0, 0.0)).role(MagnetRole::Target),
735        );
736        // Magnet scene pos should be (20, 0).
737        assert_eq!(scene.magnet_scene_pos(tmag), Some(Point::new(20.0, 0.0)));
738
739        let snap = scene
740            .compute_item_snap(dragged, Vec2::new(19.0, 0.0), 20.0, &source_to_target)
741            .expect("snap");
742        assert_eq!(snap.to, tmag);
743        assert!((snap.snap_vector.x - 1.0).abs() < 1e-3);
744    }
745
746    // --- compute_port_snap ------------------------------------------
747
748    #[test]
749    fn port_snap_returns_nearest_accepting_target() {
750        let mut scene = Scene::new();
751        let src_item = scene.add_item(
752            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
753            Point::ZERO,
754        );
755        let source = scene.add_magnet(src_item, Magnet::new(Point::ZERO).role(MagnetRole::Source));
756        let target = scene.add_item(
757            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
758            Point::new(50.0, 0.0),
759        );
760        let tmag = scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Target));
761
762        // Cursor near the target magnet.
763        let res = scene
764            .compute_port_snap(source, Point::new(52.0, 1.0), 20.0, &source_to_target)
765            .expect("port snap");
766        assert_eq!(res.0.id, tmag);
767    }
768
769    #[test]
770    fn port_snap_excludes_source_and_rejects_far() {
771        let mut scene = Scene::new();
772        let src_item = scene.add_item(
773            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
774            Point::ZERO,
775        );
776        let source = scene.add_magnet(src_item, Magnet::new(Point::ZERO).role(MagnetRole::Source));
777
778        // No accepting target near the cursor -> None.
779        let res = scene.compute_port_snap(source, Point::new(0.0, 0.0), 20.0, &source_to_target);
780        assert!(res.is_none());
781    }
782
783    // --- storage & cleanup -----------------------------------------
784
785    #[test]
786    fn magnet_storage_add_remove_clear() {
787        let mut scene = Scene::new();
788        let item = scene.add_item(
789            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
790            Point::ZERO,
791        );
792        let a = scene.add_magnet(item, Magnet::new(Point::ZERO));
793        let b = scene.add_magnet(item, Magnet::new(Point::new(5.0, 5.0)));
794        assert_eq!(scene.magnet_ids_of(item).len(), 2);
795
796        scene.remove_magnet(a);
797        assert_eq!(scene.magnet_ids_of(item), vec![b]);
798        assert!(scene.magnet(a).is_none());
799
800        scene.clear_magnets(item);
801        assert!(scene.magnet_ids_of(item).is_empty());
802    }
803
804    #[test]
805    fn removing_item_drops_its_magnets() {
806        let mut scene = Scene::new();
807        let item = scene.add_item(
808            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
809            Point::ZERO,
810        );
811        let m = scene.add_magnet(item, Magnet::new(Point::ZERO));
812        assert!(scene.magnet(m).is_some());
813        scene.remove(item);
814        assert!(scene.magnet(m).is_none());
815        assert!(scene.magnet_ids_of(item).is_empty());
816    }
817
818    #[test]
819    fn disabled_magnets_are_not_snap_candidates() {
820        let mut scene = Scene::new();
821        let dragged = scene.add_item(
822            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
823            Point::ZERO,
824        );
825        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
826        let target = scene.add_item(
827            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
828            Point::new(100.0, 0.0),
829        );
830        // Disabled target magnet — should be ignored.
831        scene.add_magnet(
832            target,
833            Magnet::new(Point::ZERO)
834                .role(MagnetRole::Target)
835                .enabled(false),
836        );
837        let snap = scene.compute_item_snap(dragged, Vec2::new(98.0, 0.0), 20.0, &source_to_target);
838        assert!(snap.is_none());
839    }
840}