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///
208/// `#[non_exhaustive]`: the crate hands this *to* consumer code — it is what a
209/// predicate inspects — the same reason [`MagnetVisualState`] carries it. Build
210/// one with [`new`](Self::new).
211#[derive(Clone)]
212#[non_exhaustive]
213pub struct MagnetRef {
214    /// The magnet's id.
215    pub id: MagnetId,
216    /// The item the magnet is attached to.
217    pub item: ItemId,
218    /// The magnet's advisory direction.
219    pub role: MagnetRole,
220    /// The magnet's payload, if any (an `Rc` clone of the stored one).
221    pub payload: Option<Rc<dyn Any>>,
222    /// The magnet's current position in scene coordinates.
223    pub scene_pos: Point,
224}
225
226impl std::fmt::Debug for MagnetRef {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        f.debug_struct("MagnetRef")
229            .field("id", &self.id)
230            .field("item", &self.item)
231            .field("role", &self.role)
232            .field("has_payload", &self.payload.is_some())
233            .field("scene_pos", &self.scene_pos)
234            .finish()
235    }
236}
237
238impl MagnetRef {
239    /// A snapshot stated field by field — the constructor
240    /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
241    /// The scene builds its own; this is for a consumer testing its predicate.
242    pub fn new(
243        id: MagnetId,
244        item: ItemId,
245        role: MagnetRole,
246        payload: Option<Rc<dyn Any>>,
247        scene_pos: Point,
248    ) -> Self {
249        Self {
250            id,
251            item,
252            role,
253            payload,
254            scene_pos,
255        }
256    }
257
258    /// Borrow the payload downcast to `P`, or `None` if absent or a
259    /// different type. The ergonomic way to read a typed payload inside
260    /// a predicate.
261    pub fn payload_as<P: 'static>(&self) -> Option<&P> {
262        self.payload.as_ref().and_then(|p| p.downcast_ref::<P>())
263    }
264}
265
266/// The result of running the accept/reject predicate on a candidate
267/// magnet pair. "Both payloads in, reject or accept-with-payload out."
268pub enum MagnetVerdict {
269    /// The pair may not connect; the scene skips it.
270    Reject,
271    /// The pair may connect. The optional payload is attached to the
272    /// resulting [`MagnetConnection`] (e.g. a derived edge descriptor).
273    Accept(Option<Rc<dyn Any>>),
274}
275
276impl MagnetVerdict {
277    /// Accept with no extra connection payload.
278    pub fn accept() -> Self {
279        MagnetVerdict::Accept(None)
280    }
281
282    /// Accept and attach a typed connection payload.
283    pub fn accept_with<P: 'static>(payload: P) -> Self {
284        MagnetVerdict::Accept(Some(Rc::new(payload)))
285    }
286
287    /// Whether this verdict accepts the pair.
288    pub fn is_accept(&self) -> bool {
289        matches!(self, MagnetVerdict::Accept(_))
290    }
291}
292
293/// A formed connection between two magnets, delivered to the consumer's
294/// `on_connect` handler on release (mouse) or confirm (keyboard).
295///
296/// `from` is the magnet that initiated the connection (the dragged
297/// item's magnet, the grabbed port, or the keyboard-activated source);
298/// `to` is the magnet it connected onto. `payload` is whatever the
299/// predicate's [`MagnetVerdict::Accept`] carried.
300///
301/// `#[non_exhaustive]`: the crate hands this *to* consumer code — it is the
302/// argument of `on_connect`. Build one with [`new`](Self::new).
303#[derive(Clone)]
304#[non_exhaustive]
305pub struct MagnetConnection {
306    /// The initiating magnet.
307    pub from: MagnetRef,
308    /// The receiving magnet.
309    pub to: MagnetRef,
310    /// The connection payload from the accepting verdict, if any.
311    pub payload: Option<Rc<dyn Any>>,
312}
313
314impl std::fmt::Debug for MagnetConnection {
315    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316        f.debug_struct("MagnetConnection")
317            .field("from", &self.from)
318            .field("to", &self.to)
319            .field("has_payload", &self.payload.is_some())
320            .finish()
321    }
322}
323
324impl MagnetConnection {
325    /// A connection stated field by field — the constructor
326    /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
327    /// The scene builds its own; this is for a consumer testing `on_connect`.
328    pub fn new(from: MagnetRef, to: MagnetRef, payload: Option<Rc<dyn Any>>) -> Self {
329        Self { from, to, payload }
330    }
331
332    /// Borrow the connection payload downcast to `P`.
333    pub fn payload_as<P: 'static>(&self) -> Option<&P> {
334        self.payload.as_ref().and_then(|p| p.downcast_ref::<P>())
335    }
336}
337
338/// The chosen snap when a dragged item's magnet aligns onto another
339/// item's magnet. Returned by
340/// [`Scene::compute_item_snap`](crate::Scene::compute_item_snap).
341///
342/// A heavyweight consumer that drives its own drag uses `snap_vector` to
343/// place the item so `from` lands on `to`, and resolves `from` / `to`
344/// via [`Scene::magnet`](crate::Scene::magnet) to build the connection
345/// for its own `on_connect`.
346///
347/// `#[non_exhaustive]`: the crate hands this *to* consumer code — a
348/// heavyweight consumer driving its own drag reads it. Build one with
349/// [`new`](Self::new).
350#[derive(Clone)]
351#[non_exhaustive]
352pub struct MagnetSnap {
353    /// The dragged item's magnet that is snapping.
354    pub from: MagnetId,
355    /// The stationary magnet it snaps onto.
356    pub to: MagnetId,
357    /// Add this to the drag delta (or the item's position) so `from`'s
358    /// scene position coincides with `to`'s.
359    pub snap_vector: Vec2,
360    /// The accepting verdict's payload, if any.
361    pub payload: Option<Rc<dyn Any>>,
362    /// Scene-space distance between the pair before snapping (the
363    /// tie-break used to pick the closest accepting pair).
364    pub distance: f32,
365}
366
367impl MagnetSnap {
368    /// A snap stated field by field — the constructor
369    /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
370    /// The scene computes its own; this is for a consumer's own tests.
371    pub fn new(
372        from: MagnetId,
373        to: MagnetId,
374        snap_vector: Vec2,
375        payload: Option<Rc<dyn Any>>,
376        distance: f32,
377    ) -> Self {
378        Self {
379            from,
380            to,
381            snap_vector,
382            payload,
383            distance,
384        }
385    }
386}
387
388impl std::fmt::Debug for MagnetSnap {
389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390        f.debug_struct("MagnetSnap")
391            .field("from", &self.from)
392            .field("to", &self.to)
393            .field("snap_vector", &self.snap_vector)
394            .field("has_payload", &self.payload.is_some())
395            .field("distance", &self.distance)
396            .finish()
397    }
398}
399
400/// When the [`SceneView`](crate::SceneView) paints magnet markers.
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum MarkerVisibility {
403    /// Always draw a marker for every enabled magnet (busy, but the
404    /// clearest discoverability — good for a dedicated editor).
405    Always,
406    /// Draw markers only while an interaction is in progress (an item
407    /// drag, a port drag, or keyboard connect mode). The default — keeps
408    /// an idle scene clean.
409    DuringInteraction,
410    /// Never draw markers (the consumer paints its own via the feedback
411    /// hook, or wants no visual at all).
412    Never,
413}
414
415/// The visual state of a magnet as the feedback renderer sees it.
416///
417/// `#[non_exhaustive]`: the crate hands this *to* consumer code — a custom
418/// [`MagnetFeedback`] must match it — and the set grows with every interaction
419/// state magnetism learns. A renderer's wildcard arm then keeps meaning "draw
420/// it plainly" instead of failing to compile.
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422#[non_exhaustive]
423pub enum MagnetVisualState {
424    /// A normal, idle magnet.
425    Idle,
426    /// A magnet the current interaction could connect to (it passes the
427    /// predicate against the active source).
428    Candidate,
429    /// The magnet the active interaction is currently snapped onto.
430    Snapped,
431    /// The keyboard-focused magnet (connect mode).
432    Focused,
433    /// The keyboard-activated source magnet awaiting a target.
434    PendingSource,
435}
436
437/// One magnet's render data, handed to the feedback renderer.
438///
439/// `#[non_exhaustive]`: the crate hands this *to* consumer code — it is what a
440/// custom [`MagnetismConfig::feedback`] draws, and it is the struct
441/// [`MagnetVisualState`] lives inside, which already says so for itself. Build
442/// one with [`new`](Self::new).
443#[derive(Debug, Clone, Copy)]
444#[non_exhaustive]
445pub struct MagnetMarker {
446    /// The magnet's id.
447    pub id: MagnetId,
448    /// Its current scene position.
449    pub scene_pos: Point,
450    /// Its advisory role.
451    pub role: MagnetRole,
452    /// Its visual state for this frame.
453    pub state: MagnetVisualState,
454}
455
456impl MagnetMarker {
457    /// A marker stated field by field — the constructor
458    /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
459    pub fn new(id: MagnetId, scene_pos: Point, role: MagnetRole, state: MagnetVisualState) -> Self {
460        Self {
461            id,
462            scene_pos,
463            role,
464            state,
465        }
466    }
467}
468
469/// Everything the magnetism feedback renderer needs for one frame, in
470/// scene coordinates (the canvas is already in the view-transform
471/// scope). The built-in renderer draws markers plus a connector; a
472/// custom [`MagnetismConfig::feedback`] closure receives the same data.
473///
474/// `#[non_exhaustive]`: the crate hands this *to* consumer code — it is the
475/// argument of a custom feedback renderer, and the set grows with every cue
476/// magnetism learns to draw. Build one with [`new`](Self::new).
477#[derive(Debug, Clone)]
478#[non_exhaustive]
479pub struct MagnetFeedback {
480    /// The view's current geometric zoom, so the renderer can size
481    /// constant-pixel chrome as `pixels / zoom` in scene units.
482    pub zoom: f32,
483    /// Eligible magnets to mark, with their per-frame state.
484    pub markers: Vec<MagnetMarker>,
485    /// A connector to draw between two scene points, if an interaction
486    /// is forming one: the active item-drag snap pair, the port-drag
487    /// wire (source to snapped target or cursor), or the keyboard
488    /// preview (pending source to focused candidate). `true` in the
489    /// second field marks an *accepted* connector (drawn solid /
490    /// highlighted) versus a tentative one (the free port-drag wire).
491    pub connector: Option<(Point, Point, bool)>,
492}
493
494impl MagnetFeedback {
495    /// A frame of feedback stated field by field — the constructor
496    /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
497    pub fn new(
498        zoom: f32,
499        markers: Vec<MagnetMarker>,
500        connector: Option<(Point, Point, bool)>,
501    ) -> Self {
502        Self {
503            zoom,
504            markers,
505            connector,
506        }
507    }
508}
509
510/// Per-view magnetism configuration, installed via
511/// [`SceneView::magnetism`](crate::SceneView::magnetism).
512///
513/// Holds the consumer's *policy* — the accept/reject predicate and the
514/// `on_connect` handler — plus presentation knobs. The scene supplies
515/// the mechanism (snap math, broad-phase, feedback rendering, the
516/// connection event); this config is where the consumer plugs its
517/// policy in.
518#[derive(Clone)]
519pub struct MagnetismConfig {
520    pub(crate) predicate: Rc<dyn Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict>,
521    pub(crate) on_connect: Rc<dyn Fn(&MagnetConnection, &mut EventContext)>,
522    pub(crate) capture_px: f32,
523    pub(crate) markers: MarkerVisibility,
524    pub(crate) feedback: Option<Rc<dyn Fn(&mut Canvas, &PaintContext, &MagnetFeedback)>>,
525    pub(crate) connect_key: Key,
526    pub(crate) enabled: Signal<bool>,
527}
528
529impl std::fmt::Debug for MagnetismConfig {
530    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531        f.debug_struct("MagnetismConfig")
532            .field("capture_px", &self.capture_px)
533            .field("markers", &self.markers)
534            .field("has_custom_feedback", &self.feedback.is_some())
535            .field("connect_key", &self.connect_key)
536            .field("enabled", &self.enabled.get())
537            .finish()
538    }
539}
540
541impl MagnetismConfig {
542    /// A config with the given accept/reject `predicate` and defaults:
543    /// 14 px capture radius, markers during interaction, the built-in
544    /// feedback renderer, `m` to toggle keyboard connect mode, enabled.
545    /// Install an `on_connect` handler to actually do something on
546    /// connect.
547    pub fn new(predicate: impl Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict + 'static) -> Self {
548        Self {
549            predicate: Rc::new(predicate),
550            on_connect: Rc::new(|_, _| {}),
551            capture_px: 14.0,
552            markers: MarkerVisibility::DuringInteraction,
553            feedback: None,
554            connect_key: Key::Character('m'),
555            enabled: Signal::new(true),
556        }
557    }
558
559    /// The handler invoked when a connection is formed (mouse release or
560    /// keyboard confirm). Runs with a live `EventContext` and no scene
561    /// borrow held, so it may mutate the model (add an edge item,
562    /// reparent), call `scene.add_a11y_relation`, or fire an intent.
563    pub fn on_connect(
564        mut self,
565        f: impl Fn(&MagnetConnection, &mut EventContext) + 'static,
566    ) -> Self {
567        self.on_connect = Rc::new(f);
568        self
569    }
570
571    /// Capture and grab radius in **screen pixels** (converted to scene
572    /// units by dividing by the live zoom, so snapping feels consistent
573    /// at any zoom). Default 14.
574    pub fn capture_px(mut self, px: f32) -> Self {
575        self.capture_px = px.max(0.0);
576        self
577    }
578
579    /// When magnet markers are painted. Default
580    /// [`MarkerVisibility::DuringInteraction`].
581    pub fn markers(mut self, markers: MarkerVisibility) -> Self {
582        self.markers = markers;
583        self
584    }
585
586    /// Replace the built-in feedback renderer with a custom one. The
587    /// closure paints in scene coordinates (the canvas already has the
588    /// view transform pushed).
589    pub fn feedback(
590        mut self,
591        f: impl Fn(&mut Canvas, &PaintContext, &MagnetFeedback) + 'static,
592    ) -> Self {
593        self.feedback = Some(Rc::new(f));
594        self
595    }
596
597    /// The key that toggles keyboard connect mode while the SceneView is
598    /// focused. Default `m`.
599    pub fn connect_key(mut self, key: Key) -> Self {
600        self.connect_key = key;
601        self
602    }
603
604    /// Set the enabled state, statically or reactively (an app-owned
605    /// signal drives enabled/disabled from e.g. a toolbar toggle).
606    pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self {
607        self.enabled = on.into().as_signal();
608        self
609    }
610
611    /// The reactive enabled signal, for a toolbar to read or bind.
612    pub fn enabled_signal(&self) -> Signal<bool> {
613        self.enabled.clone()
614    }
615
616    /// Whether magnetism is currently enabled.
617    pub fn is_enabled(&self) -> bool {
618        self.enabled.get()
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use crate::scene::Scene;
626    use teksilo_canvas::{Point, Transform2D};
627
628    /// Helper: a predicate that accepts any Source -> Target pair on
629    /// different items, rejecting same-role and same-item pairs.
630    fn source_to_target(a: &MagnetRef, b: &MagnetRef) -> MagnetVerdict {
631        if a.item == b.item {
632            return MagnetVerdict::Reject;
633        }
634        match (a.role, b.role) {
635            (MagnetRole::Source, MagnetRole::Target) | (MagnetRole::Target, MagnetRole::Source) => {
636                MagnetVerdict::accept()
637            }
638            _ => MagnetVerdict::Reject,
639        }
640    }
641
642    #[test]
643    fn magnet_ids_are_unique_and_monotonic() {
644        let a = MagnetId::next();
645        let b = MagnetId::next();
646        assert_ne!(a, b);
647        assert!(b.as_u64() > a.as_u64());
648    }
649
650    #[test]
651    fn magnet_builder_defaults_and_setters() {
652        let m = Magnet::new(Point::new(3.0, 4.0));
653        assert_eq!(m.local_pos, Point::new(3.0, 4.0));
654        assert_eq!(m.role, MagnetRole::Bidirectional);
655        assert!(m.enabled);
656        assert!(m.payload.is_none());
657
658        let m = Magnet::new(Point::ZERO)
659            .role(MagnetRole::Source)
660            .payload(42_u32)
661            .enabled(false);
662        assert_eq!(m.role, MagnetRole::Source);
663        assert!(!m.enabled);
664        assert_eq!(
665            m.payload.as_ref().unwrap().downcast_ref::<u32>(),
666            Some(&42_u32)
667        );
668    }
669
670    #[test]
671    fn verdict_helpers() {
672        assert!(MagnetVerdict::accept().is_accept());
673        assert!(MagnetVerdict::accept_with(7_i32).is_accept());
674        assert!(!MagnetVerdict::Reject.is_accept());
675    }
676
677    // --- compute_item_snap ------------------------------------------
678
679    #[test]
680    fn item_snap_snaps_to_nearest_accepting_magnet() {
681        let mut scene = Scene::new();
682        // Dragged item at origin with a Source magnet at its local (0,0).
683        let dragged = scene.add_item(
684            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
685            Point::new(0.0, 0.0),
686        );
687        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
688
689        // Target item at (100, 0) with a Target magnet at its local (0,0),
690        // i.e. scene (100, 0).
691        let target = scene.add_item(
692            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
693            Point::new(100.0, 0.0),
694        );
695        let tmag = scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Target));
696
697        // Drag so the source magnet sits at scene (95, 0): 5 px shy of the
698        // target. With a 20 px capture radius it should snap.
699        let snap = scene
700            .compute_item_snap(dragged, Vec2::new(95.0, 0.0), 20.0, &source_to_target)
701            .expect("expected a snap");
702        assert_eq!(snap.to, tmag);
703        // snap_vector should carry the source from (95,0) to (100,0): +5 x.
704        assert!((snap.snap_vector.x - 5.0).abs() < 1e-3);
705        assert!(snap.snap_vector.y.abs() < 1e-3);
706    }
707
708    #[test]
709    fn item_snap_ignores_pairs_beyond_radius() {
710        let mut scene = Scene::new();
711        let dragged = scene.add_item(
712            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
713            Point::ZERO,
714        );
715        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
716        let target = scene.add_item(
717            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
718            Point::new(100.0, 0.0),
719        );
720        scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Target));
721
722        // Source dragged to (50,0): 50 px from the target, capture radius 20.
723        let snap = scene.compute_item_snap(dragged, Vec2::new(50.0, 0.0), 20.0, &source_to_target);
724        assert!(snap.is_none());
725    }
726
727    #[test]
728    fn item_snap_respects_rejecting_predicate() {
729        let mut scene = Scene::new();
730        let dragged = scene.add_item(
731            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
732            Point::ZERO,
733        );
734        // Both Source — source_to_target rejects same-role pairs.
735        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
736        let target = scene.add_item(
737            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
738            Point::new(100.0, 0.0),
739        );
740        scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Source));
741
742        let snap = scene.compute_item_snap(dragged, Vec2::new(98.0, 0.0), 20.0, &source_to_target);
743        assert!(snap.is_none());
744    }
745
746    #[test]
747    fn item_snap_excludes_dragged_items_own_magnets() {
748        let mut scene = Scene::new();
749        let dragged = scene.add_item(
750            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
751            Point::ZERO,
752        );
753        // Two magnets on the SAME item that would otherwise satisfy the
754        // predicate (Source + Target) and be near each other.
755        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
756        scene.add_magnet(
757            dragged,
758            Magnet::new(Point::new(2.0, 0.0)).role(MagnetRole::Target),
759        );
760
761        let snap = scene.compute_item_snap(dragged, Vec2::ZERO, 20.0, &source_to_target);
762        assert!(snap.is_none(), "a dragged item must not snap to itself");
763    }
764
765    #[test]
766    fn item_snap_picks_global_minimum_distance() {
767        let mut scene = Scene::new();
768        let dragged = scene.add_item(
769            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
770            Point::ZERO,
771        );
772        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
773
774        // Two candidate targets; the nearer one wins.
775        let near = scene.add_item(
776            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
777            Point::new(10.0, 0.0),
778        );
779        let near_mag = scene.add_magnet(near, Magnet::new(Point::ZERO).role(MagnetRole::Target));
780        let far = scene.add_item(
781            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
782            Point::new(18.0, 0.0),
783        );
784        scene.add_magnet(far, Magnet::new(Point::ZERO).role(MagnetRole::Target));
785
786        // Source dragged to (8,0): 2 px from `near`, 10 px from `far`.
787        let snap = scene
788            .compute_item_snap(dragged, Vec2::new(8.0, 0.0), 20.0, &source_to_target)
789            .expect("snap");
790        assert_eq!(snap.to, near_mag);
791    }
792
793    #[test]
794    fn item_snap_carries_verdict_payload() {
795        let mut scene = Scene::new();
796        let dragged = scene.add_item(
797            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
798            Point::ZERO,
799        );
800        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
801        let target = scene.add_item(
802            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
803            Point::new(100.0, 0.0),
804        );
805        scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Target));
806
807        let pred = |a: &MagnetRef, b: &MagnetRef| {
808            if a.item != b.item {
809                MagnetVerdict::accept_with(String::from("edge"))
810            } else {
811                let _ = b;
812                MagnetVerdict::Reject
813            }
814        };
815        let snap = scene
816            .compute_item_snap(dragged, Vec2::new(98.0, 0.0), 20.0, &pred)
817            .expect("snap");
818        assert_eq!(
819            snap.payload.as_ref().unwrap().downcast_ref::<String>(),
820            Some(&String::from("edge"))
821        );
822    }
823
824    #[test]
825    fn item_snap_honors_item_transform() {
826        let mut scene = Scene::new();
827        let dragged = scene.add_item(
828            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
829            Point::ZERO,
830        );
831        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
832
833        // Target item scaled 2x with a magnet at local (10,0) -> scene (20,0).
834        let target = scene.add_item(
835            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
836            Point::ZERO,
837        );
838        scene.set_transform(target, Transform2D::scale(2.0, 2.0));
839        let tmag = scene.add_magnet(
840            target,
841            Magnet::new(Point::new(10.0, 0.0)).role(MagnetRole::Target),
842        );
843        // Magnet scene pos should be (20, 0).
844        assert_eq!(scene.magnet_scene_pos(tmag), Some(Point::new(20.0, 0.0)));
845
846        let snap = scene
847            .compute_item_snap(dragged, Vec2::new(19.0, 0.0), 20.0, &source_to_target)
848            .expect("snap");
849        assert_eq!(snap.to, tmag);
850        assert!((snap.snap_vector.x - 1.0).abs() < 1e-3);
851    }
852
853    // --- compute_port_snap ------------------------------------------
854
855    #[test]
856    fn port_snap_returns_nearest_accepting_target() {
857        let mut scene = Scene::new();
858        let src_item = scene.add_item(
859            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
860            Point::ZERO,
861        );
862        let source = scene.add_magnet(src_item, Magnet::new(Point::ZERO).role(MagnetRole::Source));
863        let target = scene.add_item(
864            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
865            Point::new(50.0, 0.0),
866        );
867        let tmag = scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Target));
868
869        // Cursor near the target magnet.
870        let res = scene
871            .compute_port_snap(source, Point::new(52.0, 1.0), 20.0, &source_to_target)
872            .expect("port snap");
873        assert_eq!(res.0.id, tmag);
874    }
875
876    #[test]
877    fn port_snap_excludes_source_and_rejects_far() {
878        let mut scene = Scene::new();
879        let src_item = scene.add_item(
880            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
881            Point::ZERO,
882        );
883        let source = scene.add_magnet(src_item, Magnet::new(Point::ZERO).role(MagnetRole::Source));
884
885        // No accepting target near the cursor -> None.
886        let res = scene.compute_port_snap(source, Point::new(0.0, 0.0), 20.0, &source_to_target);
887        assert!(res.is_none());
888    }
889
890    // --- storage & cleanup -----------------------------------------
891
892    #[test]
893    fn magnet_storage_add_remove_clear() {
894        let mut scene = Scene::new();
895        let item = scene.add_item(
896            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
897            Point::ZERO,
898        );
899        let a = scene.add_magnet(item, Magnet::new(Point::ZERO));
900        let b = scene.add_magnet(item, Magnet::new(Point::new(5.0, 5.0)));
901        assert_eq!(scene.magnet_ids_of(item).len(), 2);
902
903        scene.remove_magnet(a);
904        assert_eq!(scene.magnet_ids_of(item), vec![b]);
905        assert!(scene.magnet(a).is_none());
906
907        scene.clear_magnets(item);
908        assert!(scene.magnet_ids_of(item).is_empty());
909    }
910
911    #[test]
912    fn removing_item_drops_its_magnets() {
913        let mut scene = Scene::new();
914        let item = scene.add_item(
915            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
916            Point::ZERO,
917        );
918        let m = scene.add_magnet(item, Magnet::new(Point::ZERO));
919        assert!(scene.magnet(m).is_some());
920        scene.remove(item);
921        assert!(scene.magnet(m).is_none());
922        assert!(scene.magnet_ids_of(item).is_empty());
923    }
924
925    #[test]
926    fn disabled_magnets_are_not_snap_candidates() {
927        let mut scene = Scene::new();
928        let dragged = scene.add_item(
929            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
930            Point::ZERO,
931        );
932        scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source));
933        let target = scene.add_item(
934            crate::RectItem::new(teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0)),
935            Point::new(100.0, 0.0),
936        );
937        // Disabled target magnet — should be ignored.
938        scene.add_magnet(
939            target,
940            Magnet::new(Point::ZERO)
941                .role(MagnetRole::Target)
942                .enabled(false),
943        );
944        let snap = scene.compute_item_snap(dragged, Vec2::new(98.0, 0.0), 20.0, &source_to_target);
945        assert!(snap.is_none());
946    }
947}