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}