Skip to main content

teksilo_scene/
scene.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Scene`] data model — the owner of all items in a pannable/zoomable
5//! scene.
6//!
7//! `Scene` holds a flat list of entries in a parent-relative scene-graph, plus
8//! a pluggable [`SpatialIndex`] for rectangular queries. Items are positioned
9//! by `local_pos` (in their parent's coordinate frame, or scene-root if they
10//! have none) and an optional `transform` (rotation/scale around the local
11//! origin); the Scene composes those up the parent chain to derive each item's
12//! `scene_transform` and axis-aligned bounding box for hit-test, paint, and
13//! culling. Two content tiers coexist in one `Scene`: heavyweight `Widget`s
14//! (full focus/animation/DnD/AT — placed at scene coordinates) and lightweight
15//! [`SceneItem`]s (paint-only, no arena overhead, thousands
16//! cheap). All mutations update the [`SpatialIndex`] in lockstep, so
17//! [`Scene::items_in_rect`] and [`Scene::item_at`] stay `O(visible)`.
18//!
19//! `Scene` is rarely used directly. The normal entry point is
20//! [`SceneModel`](crate::SceneModel), a cloneable `Rc<RefCell<Scene>>` handle
21//! with `&self` mutators (the `ListModel` pattern) that lets multiple handlers
22//! and multiple [`SceneView`](crate::SceneView)s share one model.
23//!
24//! ## When to use
25//!
26//! Use `Scene` (via `SceneModel`) when you need a pannable/zoomable canvas —
27//! story corkboards, node-graph editors, mind maps, timeline views, CAD
28//! canvases, or simple spatial maps. Prefer a plain `ListView` or `TreeView`
29//! when the content is linear or tree-shaped without spatial relationships.
30//!
31//! ## Example
32//!
33//! ```rust
34//! use teksilo_scene::{Scene, ItemChange, SceneLayer};
35//! use teksilo_scene::{RectItem, ItemId};
36//! use teksilo_canvas::{Point, Rect};
37//! use teksilo_tokens::Color;
38//!
39//! let mut scene = Scene::new();
40//!
41//! // Add a lightweight rectangle item at scene coordinates (50, 50).
42//! let id: ItemId = scene.add_item(
43//!     RectItem::new(Rect::new(0.0, 0.0, 80.0, 40.0)).fill(Color::BLUE),
44//!     Point::new(50.0, 50.0),
45//! );
46//!
47//! // Observe every mutation — fires after the change is already applied.
48//! let _guard = scene.item_change_signal().observe(|change| {
49//!     if let ItemChange::LocalPosChanged { id: _, old: _, new } = change {
50//!         let _ = new; // react to the new position
51//!     }
52//! });
53//!
54//! // Move the item; the observer fires and the spatial index updates.
55//! scene.set_local_pos(id, Point::new(100.0, 100.0));
56//! assert_eq!(scene.scene_pos(id), Some(Point::new(100.0, 100.0)));
57//! ```
58
59use std::cell::Cell;
60use std::collections::HashMap;
61use std::collections::HashSet;
62use std::rc::Rc;
63
64use crate::a11y::{A11yCategory, A11yGroup, A11yGroupBuilder, A11yGroupId, A11yNode, A11yRelation};
65use crate::flags::ItemFlags;
66use crate::index::{GridHashIndex, SpatialIndex};
67use crate::item::{ItemId, SceneItem};
68use crate::item_handlers::SceneItemHandlerSet;
69use crate::magnet::{Magnet, MagnetId, MagnetRef, MagnetSnap, MagnetVerdict};
70use crate::transform::local_to_parent;
71use teksilo_canvas::{Path, Point, Rect, StrokeStyle, Transform2D, Vec2};
72use teksilo_core::color_prop::ColorProp;
73use teksilo_core::signal::Signal;
74use teksilo_core::widget::Widget;
75
76/// A change to an item's state, fired through
77/// [`Scene::item_change_signal`] for every mutation. Apps observe
78/// to wire snap-to-grid, validation, side effects, etc. The model
79/// is "fire after the change has been applied" — by the time the
80/// observer sees the event, the Scene already reflects it.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub enum ItemChange {
83    /// `set_local_pos`: position in parent coords moved.
84    LocalPosChanged { id: ItemId, old: Point, new: Point },
85    /// `set_local_bounds`: AABB in local coords changed.
86    LocalBoundsChanged { id: ItemId, old: Rect, new: Rect },
87    /// `set_transform`: local→parent transform changed.
88    TransformChanged { id: ItemId },
89    /// `set_visible` flipped IS_VISIBLE.
90    VisibilityChanged { id: ItemId, visible: bool },
91    /// `set_flags` / `set_flag` changed the bitset.
92    FlagsChanged {
93        id: ItemId,
94        old: ItemFlags,
95        new: ItemFlags,
96    },
97    /// `set_opacity`: local opacity multiplier changed.
98    OpacityChanged { id: ItemId, old: f32, new: f32 },
99    /// `set_z`: paint z-order changed.
100    ZChanged { id: ItemId, old: f32, new: f32 },
101    /// `set_layer`: the Under/Over paint band changed.
102    LayerChanged {
103        id: ItemId,
104        old: SceneLayer,
105        new: SceneLayer,
106    },
107    /// `set_item_parent`: logical parent changed.
108    ParentChanged {
109        id: ItemId,
110        old: Option<ItemId>,
111        new: Option<ItemId>,
112    },
113    /// `remove`: item is gone.
114    Removed { id: ItemId },
115    /// `add_item` / `add_widget`: item was inserted.
116    Added { id: ItemId },
117    /// `set_payload`: the type-erased payload of a `Delegated` heavyweight
118    /// entry was replaced. A `SceneView` rebuilds that entry's widget
119    /// (re-invokes its delegate) on the next build. Routed through
120    /// `emit_item_change`, so `mutation_seq` advances and the AT-walk gate
121    /// notices.
122    PayloadChanged { id: ItemId },
123    /// `set_item_fill` / `set_item_stroke` / `clear_item_*`: a lightweight
124    /// item's paint-only appearance (fill / stroke colour or style) changed.
125    /// Never moves geometry, so the observing `SceneView` evicts the item's
126    /// cached frame and repaints **without** relayout or rebuild.
127    AppearanceChanged { id: ItemId },
128}
129
130/// Which paint band a lightweight [`SceneItem`] sits in, relative to
131/// the heavyweight widget tier.
132///
133/// A `SceneView` paints in three passes: lightweight `Under` items
134/// (its `paint`, a backdrop), then the heavyweight widget children
135/// (the arena child-walk), then lightweight `Over` items (its
136/// `post_paint`, a foreground). Within each band, `z` still orders
137/// items among themselves.
138///
139/// This is a binary band, not a continuous z across the tiers, because
140/// the render walker offers exactly two lightweight paint positions
141/// (before and after the child subtree). The heavyweight tier is one
142/// contiguous block in between — to interleave a lightweight item
143/// *between* two specific heavyweight nodes you must promote it to a
144/// heavyweight widget. `Under` is the default (background furniture:
145/// connectors, grids, decorations); `Over` is for foreground overlays
146/// that must sit above the cards (selection halos, highlighted edges).
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum SceneLayer {
149    /// Painted under the heavyweight widget children (the default).
150    #[default]
151    Under,
152    /// Painted over the heavyweight widget children.
153    Over,
154}
155
156/// Which axes a [`SceneView`](crate::SceneView) is allowed to pan
157/// along. Set on the [`Scene`] (not the View) because a given scene
158/// model often makes sense at one orientation only — a horizontal
159/// timeline, a vertical timeline, a fixed-extent diagram. All views
160/// of the same scene inherit the constraint.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
162pub enum PanAxes {
163    /// No user-driven pan in either axis. Programmatic
164    /// [`SceneView::set_pan`](crate::SceneView::set_pan) /
165    /// [`pan_to`](crate::SceneView::pan_to) become no-ops too.
166    None,
167    /// Pan only along X. Vertical scroll deltas pass through to
168    /// ancestor scrollables.
169    Horizontal,
170    /// Pan only along Y. Horizontal scroll deltas pass through to
171    /// ancestor scrollables.
172    Vertical,
173    /// Default: pan freely in both axes.
174    #[default]
175    Both,
176}
177
178/// Reactive interaction-policy bundle owned by [`Scene`]. Apps
179/// configure pan/zoom behaviour by writing to these signals; gesture
180/// closures in [`SceneView`](crate::SceneView) read them live, so
181/// runtime mode switches (e.g. a toolbar toggling pan locks) take
182/// effect on the next event without rebuilding the view.
183///
184/// All four signals are exposed individually via [`Scene`] accessors
185/// (`pan_axes_signal`, `pan_bounds_signal`, `zoom_range_signal`,
186/// `zoomable_signal`). Per-(sub-)scene independence falls out of the
187/// model: each nested `SceneView` carries its own `Scene` with its
188/// own `SceneConstraints`.
189///
190/// View-level *tightening* overrides (`pan_bounds_override`,
191/// `zoom_range_override`) layer on top per-`SceneView` — the
192/// effective constraint is the intersection. Two views over the
193/// same `Scene` can lock down independently; neither can loosen
194/// what the `Scene` declares.
195pub struct SceneConstraints {
196    pan_axes: Signal<PanAxes>,
197    /// Scene-coord rectangle that the visible viewport must stay
198    /// inside. `None` (default) = unconstrained. When `Some(r)`,
199    /// pan is clamped so the visible scene region overlaps the
200    /// rect; when the viewport is bigger than the rect, the rect
201    /// is centered.
202    pan_bounds: Signal<Option<Rect>>,
203    /// Inclusive `[min, max]` clamp on zoom factor. `None` =
204    /// unconstrained from the `Scene` side (the `SceneView` may
205    /// still impose its own range override).
206    zoom_range: Signal<Option<std::ops::RangeInclusive<f32>>>,
207    zoomable: Signal<bool>,
208}
209
210impl SceneConstraints {
211    fn new() -> Self {
212        Self {
213            pan_axes: Signal::new(PanAxes::Both),
214            pan_bounds: Signal::new(None),
215            zoom_range: Signal::new(None),
216            zoomable: Signal::new(true),
217        }
218    }
219
220    /// Reactive pan-axes signal. Gesture handlers read live.
221    pub fn pan_axes_signal(&self) -> Signal<PanAxes> {
222        self.pan_axes.clone()
223    }
224    /// Reactive pan-bounds signal. `None` = unconstrained.
225    pub fn pan_bounds_signal(&self) -> Signal<Option<Rect>> {
226        self.pan_bounds.clone()
227    }
228    /// Reactive zoom-range signal. `None` = unconstrained from
229    /// the Scene side.
230    pub fn zoom_range_signal(&self) -> Signal<Option<std::ops::RangeInclusive<f32>>> {
231        self.zoom_range.clone()
232    }
233    /// Reactive zoomable-on/off signal. Equivalent to a zero-width
234    /// zoom_range — kept as a separate boolean for clarity and
235    /// efficient short-circuit at gesture time.
236    pub fn zoomable_signal(&self) -> Signal<bool> {
237        self.zoomable.clone()
238    }
239}
240
241impl Default for SceneConstraints {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247impl std::fmt::Debug for SceneConstraints {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        f.debug_struct("SceneConstraints")
250            .field("pan_axes", &self.pan_axes.get())
251            .field("pan_bounds", &self.pan_bounds.get())
252            .field("zoom_range", &self.zoom_range.get())
253            .field("zoomable", &self.zoomable.get())
254            .finish()
255    }
256}
257
258/// A single entry in a [`Scene`]. The two variants mirror the two
259/// content tiers: heavyweight `Widget`s consumed into the arena at
260/// build time, and lightweight `SceneItem`s painted directly from the
261/// SceneView's paint walk.
262pub(crate) struct SceneEntry {
263    pub(crate) id: ItemId,
264    /// Origin of the item's local coordinate frame, in **parent**
265    /// coordinates (or scene coords if `parent == None`).
266    pub(crate) local_pos: Point,
267    /// Item's AABB in **local** coordinates. For lightweight items
268    /// this is read once at insert time from `SceneItem::local_bounds`
269    /// and tracked through `Scene::set_local_bounds`. For widgets it
270    /// records the size requested at `add_widget` time, anchored at
271    /// the origin: `Rect::new(0, 0, w, h)`.
272    pub(crate) local_bounds: Rect,
273    /// Optional rotation/scale applied around the local origin
274    /// before translating by `local_pos`. Default identity.
275    pub(crate) transform: Transform2D,
276    pub(crate) kind: SceneEntryKind,
277    /// Z-order for paint — higher values paint *later* (on top).
278    /// Equal-z entries fall back to insertion order. Applies to **both**
279    /// tiers: lightweight items sort within their band each paint, and
280    /// heavyweight widget entries restack the SceneView's arena children
281    /// by z on the next rebuild (see [`Scene::set_z`]).
282    pub(crate) z: f32,
283    /// Which lightweight paint band this item sits in relative to the
284    /// heavyweight tier — [`SceneLayer::Under`] (default, backdrop) or
285    /// [`SceneLayer::Over`] (foreground). Lightweight tier only; ignored
286    /// for heavyweight widget entries (they paint via the arena).
287    pub(crate) layer: SceneLayer,
288    /// Logical parent. `None` means the item is rooted directly in
289    /// the Scene. Composes coordinate frames: a child's `local_pos`
290    /// is in the parent's local frame, and the child's
291    /// `scene_transform` is the parent's `scene_transform` composed
292    /// with the child's `local_to_parent`.
293    pub(crate) parent: Option<ItemId>,
294    /// Per-item behavior flags. Read once from
295    /// [`SceneItem::initial_flags`] at insert time, mutable through
296    /// [`Scene::set_flags`] / [`Scene::set_flag`].
297    pub(crate) flags: ItemFlags,
298    /// Multiplicative opacity in `[0.0, 1.0]`. Composes through the
299    /// parent chain: an item's `effective_opacity` is the product
300    /// of every ancestor's opacity and its own.
301    pub(crate) opacity: f32,
302    /// Per-item event handlers, cursor and tooltip overrides.
303    /// `None` until the app calls `Scene::set_item_handlers` /
304    /// `Scene::handlers_mut`.
305    pub(crate) handlers: Option<Box<SceneItemHandlerSet>>,
306    /// Whether the item's `local_bounds` may change between
307    /// build/layout passes (a signal-driven AABB). Static items
308    /// (default) snapshot bounds at insert and only update through
309    /// explicit [`Scene::set_local_bounds`]. Dynamic items added via
310    /// [`Scene::add_item_dynamic`] have their `local_bounds` re-read
311    /// each rebuild via [`Scene::refresh_dynamic_bounds`], with the
312    /// spatial index re-bucketed when the value changes.
313    pub(crate) dynamic_bounds: bool,
314}
315
316/// How a heavyweight `Widget` entry makes its instance available to a
317/// `SceneView`. The two variants are the single-view and multi-view
318/// production paths.
319pub(crate) enum WidgetSource {
320    /// Single-view sugar ([`Scene::add_widget`]). The first `SceneView` to
321    /// build drains the `Option` via `take()`; subsequent views (sharing the
322    /// same [`SceneModel`](crate::SceneModel)) find `None` and produce no
323    /// arena child for this entry. Use [`Scene::add_widget_delegated`] +
324    /// a view delegate for multi-view content.
325    Once(Option<Box<dyn Widget>>),
326    /// Multi-view path ([`Scene::add_widget_delegated`], surfaced as
327    /// [`SceneModel::add_widget_item`](crate::SceneModel::add_widget_item)).
328    /// Each view calls its own delegate with this type-erased `payload`
329    /// to build a fresh `Widget` instance. The payload is `Rc` so a view
330    /// can clone it out of a model borrow before invoking the delegate.
331    Delegated { payload: Rc<dyn std::any::Any> },
332}
333
334pub(crate) enum SceneEntryKind {
335    /// A heavyweight `Widget` materialised into the arena, via either the
336    /// single-view `Once` slot or the multi-view `Delegated` payload.
337    Widget(WidgetSource),
338    /// A lightweight `SceneItem` that lives in the scene
339    /// permanently; painted by the SceneView's paint walk.
340    Item(Box<dyn SceneItem>),
341}
342
343/// The data model behind a `SceneView`: a flat list of entries in a
344/// parent-relative scene-graph plus a [`SpatialIndex`] for rectangular
345/// queries.
346///
347/// The Scene itself does no rendering — it's a passive container the view
348/// reads from at build / place / paint time. Mutations (`add_widget`,
349/// `add_item`, `set_local_pos`, `set_transform`, `set_local_bounds`, `remove`)
350/// update the spatial index in lockstep, so `items_in_rect`, `item_at`, and
351/// SceneView's viewport-cull path are all `O(visible)` instead of `O(N)`. When
352/// a parent's `local_pos` or `transform` changes, every descendant's
353/// scene-AABB shifts; the Scene re-buckets the entire subtree.
354///
355/// In practice most callers operate on a [`SceneModel`](crate::SceneModel)
356/// handle (`Rc<RefCell<Scene>>` with `&self` mutators) rather than a bare
357/// `Scene`. Prefer `SceneModel` for any widget or handler that needs to share
358/// the scene across multiple owners.
359pub struct Scene {
360    pub(crate) entries: Vec<SceneEntry>,
361    /// `ItemId` → index into `entries` for O(1) lookup.
362    entry_index: HashMap<ItemId, usize>,
363    index: Box<dyn SpatialIndex>,
364
365    /// User-declared scene extent. `None` means "auto-compute from
366    /// items each query". Set via [`Scene::set_scene_rect`]. Used
367    /// by [`SceneView::adopt_scene_size`](crate::SceneView::adopt_scene_size).
368    /// (Distinct from `constraints.pan_bounds` which clamps the
369    /// visible viewport.)
370    user_scene_rect: Option<Rect>,
371    /// Reactive interaction policy: pan axes, pan bounds, zoom
372    /// range, zoomable on/off. Apps mutate via the dedicated
373    /// `Scene::pan_axes` / `set_pan_bounds` / `set_zoom_range` /
374    /// `zoomable` methods (still classic mutator shape) or read
375    /// the underlying signals via the `*_signal` accessors for
376    /// live observation.
377    constraints: SceneConstraints,
378    /// Reactive change signal. Every mutation fires an
379    /// [`ItemChange`] through this signal so apps can observe
380    /// geometry / visibility / parent / z / opacity changes.
381    item_change_signal: Signal<ItemChange>,
382    /// Reactive change counter for the *logical AT structure* (groups,
383    /// parents, relations, live, landmarks, categories). These mutations are
384    /// not item geometry, so they do not flow through `item_change_signal`;
385    /// `SceneView` observes this separately to re-walk the AccessKit tree. The
386    /// AT tree is fully separate from the visual scene, so it needs its own
387    /// notification channel.
388    a11y_change_signal: Signal<u64>,
389    /// Monotonic counter of *every* model mutation — item geometry / visibility
390    /// / structure (each [`ItemChange`] fire) **and** logical-AT structure (each
391    /// `bump_a11y_change`). Read via [`Scene::mutation_version`]. `SceneView`
392    /// gates its (expensive) AccessKit re-walk on this advancing, so a `build()`
393    /// triggered purely by dynamic-bounds churn it already accounted for doesn't
394    /// re-walk the AT tree every frame. A plain `Cell` because the bump path
395    /// (`bump_mutation`) is `&self` (shared with `bump_a11y_change`).
396    mutation_seq: Cell<u64>,
397
398    // --- logical AT structure ----------------------------------------
399    pub(crate) a11y_groups: Vec<A11yGroup>,
400    pub(crate) a11y_group_index: HashMap<A11yGroupId, usize>,
401    pub(crate) a11y_parents: HashMap<A11yNode, A11yNode>,
402    pub(crate) a11y_relations: Vec<(A11yNode, A11yRelation, A11yNode)>,
403    pub(crate) a11y_live: HashMap<A11yNode, accesskit::Live>,
404    pub(crate) a11y_landmarks: HashMap<A11yNode, accesskit::Role>,
405    pub(crate) a11y_categories: HashMap<A11yNode, Vec<A11yCategory>>,
406
407    // --- magnetism ---------------------------------------------------
408    /// Magnets attached to each item, in insertion order. Kept in a
409    /// side map (not on `SceneEntry`) so the magnet subsystem is
410    /// modular — the same shape as the logical-AT maps above.
411    magnets: HashMap<ItemId, Vec<(MagnetId, Magnet)>>,
412    /// Reverse lookup `MagnetId -> owning ItemId` for O(1) resolution
413    /// of a magnet's owner (and cleanup on `remove_magnet`).
414    magnet_owner: HashMap<MagnetId, ItemId>,
415}
416
417impl Scene {
418    /// An empty scene with the default [`GridHashIndex`].
419    pub fn new() -> Self {
420        Self::with_index(Box::new(GridHashIndex::default()))
421    }
422
423    /// An empty scene with a custom [`SpatialIndex`].
424    pub fn with_index(index: Box<dyn SpatialIndex>) -> Self {
425        Self {
426            entries: Vec::new(),
427            entry_index: HashMap::new(),
428            index,
429            user_scene_rect: None,
430            constraints: SceneConstraints::new(),
431            item_change_signal: Signal::new(ItemChange::Added { id: ItemId(0) }),
432            a11y_change_signal: Signal::new(0),
433            mutation_seq: Cell::new(0),
434            a11y_groups: Vec::new(),
435            a11y_group_index: HashMap::new(),
436            a11y_parents: HashMap::new(),
437            a11y_relations: Vec::new(),
438            a11y_live: HashMap::new(),
439            a11y_landmarks: HashMap::new(),
440            a11y_categories: HashMap::new(),
441            magnets: HashMap::new(),
442            magnet_owner: HashMap::new(),
443        }
444    }
445
446    // -----------------------------------------------------------------
447    // Insertion
448    // -----------------------------------------------------------------
449
450    /// Place a heavyweight `Widget` at `local_rect`'s origin, sized
451    /// `local_rect.size`. The rect is interpreted as
452    /// `(local_pos = local_rect.origin, local_bounds = (0, 0, w, h))`.
453    /// Returns the [`ItemId`] for later mutation. The widget is
454    /// consumed at SceneView build time and added to the arena.
455    pub fn add_widget<W: Widget + 'static>(&mut self, widget: W, local_rect: Rect) -> ItemId {
456        let id = ItemId::next();
457        let local_pos = Point::new(local_rect.x, local_rect.y);
458        let local_bounds = Rect::new(0.0, 0.0, local_rect.width, local_rect.height);
459        let entry = SceneEntry {
460            id,
461            local_pos,
462            local_bounds,
463            transform: Transform2D::identity(),
464            kind: SceneEntryKind::Widget(WidgetSource::Once(Some(Box::new(widget)))),
465            z: 0.0,
466            layer: SceneLayer::Under,
467            parent: None,
468            flags: ItemFlags::default(),
469            opacity: 1.0,
470            handlers: None,
471            dynamic_bounds: false,
472        };
473        self.push_entry(entry)
474    }
475
476    /// Multi-view heavyweight insertion: store a type-erased `payload`; each
477    /// [`SceneView`](crate::SceneView) builds its own instance via its
478    /// delegate. Surfaced publicly as
479    /// [`SceneModel::add_widget_item`](crate::SceneModel::add_widget_item).
480    pub(crate) fn add_widget_delegated(
481        &mut self,
482        payload: Rc<dyn std::any::Any>,
483        local_rect: Rect,
484    ) -> ItemId {
485        let id = ItemId::next();
486        let local_pos = Point::new(local_rect.x, local_rect.y);
487        let local_bounds = Rect::new(0.0, 0.0, local_rect.width, local_rect.height);
488        let entry = SceneEntry {
489            id,
490            local_pos,
491            local_bounds,
492            transform: Transform2D::identity(),
493            kind: SceneEntryKind::Widget(WidgetSource::Delegated { payload }),
494            z: 0.0,
495            layer: SceneLayer::Under,
496            parent: None,
497            flags: ItemFlags::default(),
498            opacity: 1.0,
499            handlers: None,
500            dynamic_bounds: false,
501        };
502        self.push_entry(entry)
503    }
504
505    /// Replace the type-erased payload of a `Delegated` heavyweight entry and
506    /// fire [`ItemChange::PayloadChanged`].
507    ///
508    /// # Panics
509    ///
510    /// Panics if `id` is unknown, refers to a `Once` widget entry, or refers to
511    /// a lightweight item. These are all caller-side precondition violations:
512    /// the caller obtained `id` from `add_widget_item` and is responsible for
513    /// only passing it back to `set_payload` while the entry is alive.
514    pub(crate) fn set_payload(&mut self, id: ItemId, payload: Rc<dyn std::any::Any>) {
515        let Some(&pos) = self.entry_index.get(&id) else {
516            panic!("set_payload: unknown ItemId {id:?}");
517        };
518        match &mut self.entries[pos].kind {
519            SceneEntryKind::Widget(WidgetSource::Delegated { payload: slot }) => *slot = payload,
520            _ => panic!("set_payload: {id:?} is not a Delegated widget entry"),
521        }
522        // Entry borrow dropped above; `emit_item_change` is `&self`.
523        self.emit_item_change(ItemChange::PayloadChanged { id });
524    }
525
526    /// The current type-erased payload of a `Delegated` heavyweight entry.
527    /// `None` for unknown ids, `Once` widget entries, and lightweight items.
528    pub(crate) fn payload(&self, id: ItemId) -> Option<Rc<dyn std::any::Any>> {
529        let pos = *self.entry_index.get(&id)?;
530        match &self.entries[pos].kind {
531            SceneEntryKind::Widget(WidgetSource::Delegated { payload }) => Some(payload.clone()),
532            _ => None,
533        }
534    }
535
536    /// Drain every still-pending `Once` heavyweight widget, in entry order.
537    /// Each is `take()`n from its slot, so a second `SceneView` over the same
538    /// model returns nothing for it — `Once` widgets are single-view. Called
539    /// by `SceneView::build`.
540    pub(crate) fn drain_all_once(&mut self) -> Vec<(ItemId, Box<dyn Widget>)> {
541        let mut out = Vec::new();
542        for entry in self.entries.iter_mut() {
543            if let SceneEntryKind::Widget(WidgetSource::Once(pending)) = &mut entry.kind
544                && let Some(w) = pending.take()
545            {
546                out.push((entry.id, w));
547            }
548        }
549        out
550    }
551
552    /// `(id, payload)` for every `Delegated` heavyweight entry, in entry order.
553    /// The payload `Rc` is cloned so the caller can drop the model borrow before
554    /// invoking its delegate (the reentrancy contract). Called by `SceneView::build`.
555    pub(crate) fn delegated_payloads(&self) -> Vec<(ItemId, Rc<dyn std::any::Any>)> {
556        self.entries
557            .iter()
558            .filter_map(|e| match &e.kind {
559                SceneEntryKind::Widget(WidgetSource::Delegated { payload }) => {
560                    Some((e.id, payload.clone()))
561                }
562                _ => None,
563            })
564            .collect()
565    }
566
567    /// Ids of every heavyweight `Widget` entry (`Once` and `Delegated`), in
568    /// entry order. Used by `SceneView::build` for child ordering and the
569    /// orphan-reap live-set.
570    pub(crate) fn heavyweight_ids(&self) -> Vec<ItemId> {
571        self.entries
572            .iter()
573            .filter_map(|e| match &e.kind {
574                SceneEntryKind::Widget(_) => Some(e.id),
575                SceneEntryKind::Item(_) => None,
576            })
577            .collect()
578    }
579
580    /// Place a lightweight [`SceneItem`] at `local_pos`. The item's
581    /// `local_bounds` and `initial_flags` are read once at insert
582    /// time. The item is **not** added to the arena — it's painted
583    /// directly from `SceneView::paint`.
584    pub fn add_item<I: SceneItem + 'static>(&mut self, item: I, local_pos: Point) -> ItemId {
585        self.add_item_inner(item, local_pos, false)
586    }
587
588    /// Like [`add_item`](Self::add_item) but flags the entry as
589    /// having signal-driven `local_bounds`. The Scene re-reads
590    /// `item.local_bounds()` each rebuild via
591    /// [`refresh_dynamic_bounds`](Self::refresh_dynamic_bounds) — the
592    /// SceneView calls that at the start of every build pass. The
593    /// spatial index gets re-bucketed when the read-back differs
594    /// from the cached value, so `items_in_rect` / hit-test stay
595    /// correct without app-side `set_local_bounds` plumbing.
596    ///
597    /// Use only when the bounds genuinely depend on a `Signal<T>`
598    /// the item reads in `local_bounds`. Static items pay an
599    /// unnecessary per-rebuild bounds read otherwise; prefer
600    /// [`add_item`](Self::add_item) for the common case.
601    pub fn add_item_dynamic<I: SceneItem + 'static>(
602        &mut self,
603        item: I,
604        local_pos: Point,
605    ) -> ItemId {
606        self.add_item_inner(item, local_pos, true)
607    }
608
609    fn add_item_inner<I: SceneItem + 'static>(
610        &mut self,
611        item: I,
612        local_pos: Point,
613        dynamic_bounds: bool,
614    ) -> ItemId {
615        self.insert_boxed(Box::new(item), local_pos, dynamic_bounds)
616    }
617
618    /// The single lightweight-entry construction site, shared by the generic
619    /// [`add_item`](Self::add_item) / [`add_item_dynamic`](Self::add_item_dynamic)
620    /// path (via `add_item_inner`) and the boxed-`dyn`
621    /// [`add_boxed_item`](Self::add_boxed_item) path, so a future `SceneEntry`
622    /// field can't be added to one and silently missed by the other.
623    fn insert_boxed(
624        &mut self,
625        item: Box<dyn SceneItem>,
626        local_pos: Point,
627        dynamic_bounds: bool,
628    ) -> ItemId {
629        let id = ItemId::next();
630        let local_bounds = item.local_bounds();
631        let flags = item.initial_flags();
632        let entry = SceneEntry {
633            id,
634            local_pos,
635            local_bounds,
636            transform: Transform2D::identity(),
637            kind: SceneEntryKind::Item(item),
638            z: 0.0,
639            layer: SceneLayer::Under,
640            parent: None,
641            flags,
642            opacity: 1.0,
643            handlers: None,
644            dynamic_bounds,
645        };
646        self.push_entry(entry)
647    }
648
649    /// Re-read every dynamic item's current `local_bounds`, applying
650    /// `set_local_bounds` (and re-bucketing the spatial index) for
651    /// any entry whose value has changed. No-op for static entries.
652    /// Called by [`SceneView`](crate::SceneView) at the start of each
653    /// `build()` so signal-driven bounds propagate to bucketing
654    /// without explicit app-side calls.
655    ///
656    /// Returns `true` if at least one dynamic entry's bounds changed this call.
657    /// `SceneView` uses the `true → false` transition (an animation settling) as
658    /// the one moment to walk the final animated bounds into the AccessKit tree,
659    /// since it otherwise suppresses per-frame AT re-walks during the animation.
660    pub fn refresh_dynamic_bounds(&mut self) -> bool {
661        // Snapshot ids first to avoid borrow conflicts.
662        let dynamic_ids: Vec<ItemId> = self
663            .entries
664            .iter()
665            .filter(|e| e.dynamic_bounds)
666            .map(|e| e.id)
667            .collect();
668        let mut changed = false;
669        for id in dynamic_ids {
670            let Some(&pos) = self.entry_index.get(&id) else {
671                continue;
672            };
673            let SceneEntryKind::Item(item) = &self.entries[pos].kind else {
674                continue;
675            };
676            let new = item.local_bounds();
677            if new != self.entries[pos].local_bounds {
678                self.set_local_bounds(id, new);
679                changed = true;
680            }
681        }
682        changed
683    }
684
685    fn push_entry(&mut self, entry: SceneEntry) -> ItemId {
686        let id = entry.id;
687        let pos = self.entries.len();
688        self.entries.push(entry);
689        self.entry_index.insert(id, pos);
690        let aabb = self.compute_scene_aabb(id).unwrap_or(Rect::ZERO);
691        self.index.insert(id, aabb);
692        self.emit_item_change(ItemChange::Added { id });
693        id
694    }
695
696    /// Reactive notification stream for every Scene mutation. Apps
697    /// observe via `signal.observe(|change| …)` to wire snap-to-grid,
698    /// clamping, validation, and side effects without having to
699    /// poll the Scene each frame. The signal fires *after* the
700    /// mutation has been applied — by the time the observer runs
701    /// the Scene already reflects the new state.
702    pub fn item_change_signal(&self) -> Signal<ItemChange> {
703        self.item_change_signal.clone()
704    }
705
706    /// Reactive notification for logical-AT-structure mutations
707    /// (`add_a11y_group` / `remove_a11y_group` / `set_a11y_parent` /
708    /// `add_a11y_relation` / `set_a11y_live` / `set_a11y_landmark` /
709    /// `set_a11y_categories`). A monotonic counter bumped after each such
710    /// mutation. `SceneView` observes this to re-walk the AccessKit tree —
711    /// these changes don't flow through [`item_change_signal`](Self::item_change_signal)
712    /// because they aren't item geometry, and the AT tree is separate from the
713    /// visual scene.
714    pub fn a11y_change_signal(&self) -> Signal<u64> {
715        self.a11y_change_signal.clone()
716    }
717
718    /// Bump the logical-AT-structure change counter. Called at the end of every
719    /// a11y-structure mutator so observers re-walk AccessKit. Also advances the
720    /// unified [`mutation_version`](Self::mutation_version) so a logical-AT
721    /// mutation (which never fires `item_change_signal`) still un-gates the
722    /// SceneView's AT re-walk.
723    fn bump_a11y_change(&self) {
724        self.a11y_change_signal
725            .set(self.a11y_change_signal.get().wrapping_add(1));
726        self.bump_mutation();
727    }
728
729    /// Fire an [`ItemChange`] through `item_change_signal` and advance the
730    /// unified [`mutation_version`](Self::mutation_version). The single choke
731    /// point every geometry / visibility / structure mutation routes through, so
732    /// the version counts each one without per-site bookkeeping.
733    fn emit_item_change(&self, change: ItemChange) {
734        self.bump_mutation();
735        self.item_change_signal.set(change);
736    }
737
738    /// Advance the unified model-mutation counter (wrapping). Shared by
739    /// `emit_item_change` and `bump_a11y_change`; `&self` because both notify
740    /// paths are `&self`.
741    fn bump_mutation(&self) {
742        self.mutation_seq
743            .set(self.mutation_seq.get().wrapping_add(1));
744    }
745
746    /// Monotonic counter of every model mutation applied so far — item geometry
747    /// / visibility / structure (each [`ItemChange`]) **and** logical-AT
748    /// structure (groups, parents, relations, live, landmarks, categories).
749    ///
750    /// [`SceneView`](crate::SceneView) snapshots this each `build()` and only
751    /// re-walks the (separate, expensive) AccessKit tree when it has advanced
752    /// since the previous walk — so an actively-animating
753    /// [`add_item_dynamic`](Self::add_item_dynamic) item, which rebuilds every
754    /// frame, does not issue an AT re-walk per frame. The counter wraps; compare
755    /// for equality, not ordering.
756    pub fn mutation_version(&self) -> u64 {
757        self.mutation_seq.get()
758    }
759
760    // -----------------------------------------------------------------
761    // Geometry — local
762    // -----------------------------------------------------------------
763
764    /// Read an item's `local_pos` (its anchor in parent coords).
765    pub fn local_pos(&self, id: ItemId) -> Option<Point> {
766        let pos = *self.entry_index.get(&id)?;
767        Some(self.entries[pos].local_pos)
768    }
769
770    /// Move an item to a new `local_pos` in its parent's coordinate
771    /// frame. Re-buckets the item *and* every descendant in the
772    /// spatial index since the descendants' scene-AABBs shift along.
773    /// No-op if the id is unknown.
774    pub fn set_local_pos(&mut self, id: ItemId, local_pos: Point) {
775        if let Some(&pos) = self.entry_index.get(&id) {
776            let old = self.entries[pos].local_pos;
777            if old == local_pos {
778                return;
779            }
780            self.entries[pos].local_pos = local_pos;
781            self.rebucket_subtree(id);
782            self.emit_item_change(ItemChange::LocalPosChanged {
783                id,
784                old,
785                new: local_pos,
786            });
787        }
788    }
789
790    /// Read an item's `local_bounds` (its AABB in local coords).
791    pub fn local_bounds(&self, id: ItemId) -> Option<Rect> {
792        let pos = *self.entry_index.get(&id)?;
793        Some(self.entries[pos].local_bounds)
794    }
795
796    /// Update an item's `local_bounds`. For lightweight items this
797    /// also calls [`SceneItem::set_local_bounds`] on the item so its
798    /// next `paint` reflects the new geometry. The spatial index is
799    /// re-bucketed; only this item moves (descendants' local frames
800    /// are unchanged). No-op if the id is unknown.
801    pub fn set_local_bounds(&mut self, id: ItemId, local_bounds: Rect) {
802        if let Some(&pos) = self.entry_index.get(&id) {
803            let old = self.entries[pos].local_bounds;
804            if old == local_bounds {
805                return;
806            }
807            self.entries[pos].local_bounds = local_bounds;
808            if let SceneEntryKind::Item(item) = &mut self.entries[pos].kind {
809                item.set_local_bounds(local_bounds);
810            }
811            // Bounds are local — only this entry's scene-AABB shifts;
812            // descendants' local frames are unchanged.
813            let aabb = self.compute_scene_aabb(id).unwrap_or(Rect::ZERO);
814            self.index.insert(id, aabb);
815            self.emit_item_change(ItemChange::LocalBoundsChanged {
816                id,
817                old,
818                new: local_bounds,
819            });
820        }
821    }
822
823    /// Read an item's local→parent transform (rotation/scale around
824    /// the local origin). Identity by default.
825    pub fn transform(&self, id: ItemId) -> Option<Transform2D> {
826        let pos = *self.entry_index.get(&id)?;
827        Some(self.entries[pos].transform)
828    }
829
830    /// Set an item's local→parent transform. Re-buckets the item's
831    /// subtree in the spatial index. No-op if the id is unknown.
832    pub fn set_transform(&mut self, id: ItemId, transform: Transform2D) {
833        if let Some(&pos) = self.entry_index.get(&id) {
834            self.entries[pos].transform = transform;
835            self.rebucket_subtree(id);
836            self.emit_item_change(ItemChange::TransformChanged { id });
837        }
838    }
839
840    // -----------------------------------------------------------------
841    // Geometry — scene (computed via parent chain)
842    // -----------------------------------------------------------------
843
844    /// The composed local→scene transform for this item, walking up
845    /// the parent chain. Identity for an item that doesn't exist.
846    pub fn scene_transform(&self, id: ItemId) -> Transform2D {
847        let mut acc = Transform2D::identity();
848        let mut cur = Some(id);
849        let cap = self.entries.len();
850        let mut hops = 0;
851        while let Some(cid) = cur {
852            let Some(&pos) = self.entry_index.get(&cid) else {
853                break;
854            };
855            let entry = &self.entries[pos];
856            let l2p = local_to_parent(entry.local_pos, &entry.transform);
857            acc = acc.then(&l2p);
858            cur = entry.parent;
859            hops += 1;
860            if hops > cap {
861                break;
862            }
863        }
864        acc
865    }
866
867    /// The item's anchor in scene coords (its local origin
868    /// transformed through the parent chain).
869    pub fn scene_pos(&self, id: ItemId) -> Option<Point> {
870        if !self.entry_index.contains_key(&id) {
871            return None;
872        }
873        Some(self.scene_transform(id).apply_point(Point::ZERO))
874    }
875
876    /// The AABB enclosing the item's `local_bounds` after composing
877    /// through the parent chain — i.e. the rectangle the spatial
878    /// index buckets on. `None` if the id is unknown.
879    pub fn scene_rect(&self, id: ItemId) -> Option<Rect> {
880        let local_bounds = self.local_bounds(id)?;
881        Some(self.scene_transform(id).apply_rect(local_bounds))
882    }
883
884    /// Map a point in the item's local frame to scene coords.
885    pub fn map_to_scene(&self, id: ItemId, local_pt: Point) -> Option<Point> {
886        if !self.entry_index.contains_key(&id) {
887            return None;
888        }
889        Some(self.scene_transform(id).apply_point(local_pt))
890    }
891
892    /// Map a point in scene coords to the item's local frame.
893    /// Returns `None` if the item is unknown or its scene transform
894    /// is degenerate (zero scale).
895    pub fn map_from_scene(&self, id: ItemId, scene_pt: Point) -> Option<Point> {
896        if !self.entry_index.contains_key(&id) {
897            return None;
898        }
899        self.scene_transform(id)
900            .inverse()
901            .map(|inv| inv.apply_point(scene_pt))
902    }
903
904    fn compute_scene_aabb(&self, id: ItemId) -> Option<Rect> {
905        let pos = *self.entry_index.get(&id)?;
906        let local_bounds = self.entries[pos].local_bounds;
907        Some(self.scene_transform(id).apply_rect(local_bounds))
908    }
909
910    fn rebucket_subtree(&mut self, root: ItemId) {
911        // Re-bucket `root` and every descendant whose scene-AABB
912        // depends on the root's frame.
913        //
914        // Build a parent→children adjacency map once (O(N)) so the walk is
915        // O(N) instead of O(N²) (the previous code rescanned every entry per
916        // node).
917        let mut children: HashMap<ItemId, Vec<ItemId>> = HashMap::new();
918        for entry in &self.entries {
919            if let Some(parent) = entry.parent {
920                children.entry(parent).or_default().push(entry.id);
921            }
922        }
923
924        // Cycle guard: the parent-pointer walkers (`scene_transform` etc.)
925        // bound their *upward* walk with a hop cap; this *downward* walk can
926        // loop forever if the parent graph ever contains a cycle (e.g. from a
927        // future de-serialization bug), so we track visited nodes. A
928        // well-formed tree never revisits a node, so this is also a redundant-
929        // work guard.
930        let mut visited: HashSet<ItemId> = HashSet::new();
931        let mut stack: Vec<ItemId> = vec![root];
932        while let Some(id) = stack.pop() {
933            if !visited.insert(id) {
934                continue;
935            }
936            if let Some(aabb) = self.compute_scene_aabb(id) {
937                self.index.insert(id, aabb);
938            }
939            if let Some(kids) = children.get(&id) {
940                stack.extend(kids.iter().copied());
941            }
942        }
943    }
944
945    // -----------------------------------------------------------------
946    // Flags, visibility, opacity (per item)
947    // -----------------------------------------------------------------
948
949    /// Read an item's [`ItemFlags`] bitset.
950    pub fn flags(&self, id: ItemId) -> Option<ItemFlags> {
951        let pos = *self.entry_index.get(&id)?;
952        Some(self.entries[pos].flags)
953    }
954
955    /// Replace an item's flags wholesale. No-op if unknown.
956    pub fn set_flags(&mut self, id: ItemId, flags: ItemFlags) {
957        if let Some(&pos) = self.entry_index.get(&id) {
958            let old = self.entries[pos].flags;
959            if old == flags {
960                return;
961            }
962            self.entries[pos].flags = flags;
963            self.emit_item_change(ItemChange::FlagsChanged {
964                id,
965                old,
966                new: flags,
967            });
968        }
969    }
970
971    /// Set or clear a single flag on an item. No-op if unknown.
972    pub fn set_flag(&mut self, id: ItemId, flag: ItemFlags, on: bool) {
973        if let Some(&pos) = self.entry_index.get(&id) {
974            let old = self.entries[pos].flags;
975            self.entries[pos].flags.set(flag, on);
976            let new = self.entries[pos].flags;
977            if old != new {
978                if flag == ItemFlags::IS_VISIBLE {
979                    self.emit_item_change(ItemChange::VisibilityChanged { id, visible: on });
980                }
981                self.emit_item_change(ItemChange::FlagsChanged { id, old, new });
982            }
983        }
984    }
985
986    /// Toggle the [`ItemFlags::IS_VISIBLE`] bit. Convenience for
987    /// the common "hide this item" operation.
988    pub fn set_visible(&mut self, id: ItemId, visible: bool) {
989        self.set_flag(id, ItemFlags::IS_VISIBLE, visible);
990    }
991
992    /// Whether the item is visible AND every ancestor in its chain
993    /// is visible. Returns `true` when nothing in the chain has
994    /// `IS_VISIBLE` cleared. `false` for unknown ids.
995    pub fn is_effectively_visible(&self, id: ItemId) -> bool {
996        let cap = self.entries.len();
997        let mut hops = 0;
998        let mut cur = Some(id);
999        while let Some(cid) = cur {
1000            let Some(&pos) = self.entry_index.get(&cid) else {
1001                return false;
1002            };
1003            let entry = &self.entries[pos];
1004            if !entry.flags.contains(ItemFlags::IS_VISIBLE) {
1005                return false;
1006            }
1007            cur = entry.parent;
1008            hops += 1;
1009            if hops > cap {
1010                break;
1011            }
1012        }
1013        true
1014    }
1015
1016    /// Read an item's local opacity multiplier (`1.0` by default).
1017    pub fn opacity(&self, id: ItemId) -> Option<f32> {
1018        let pos = *self.entry_index.get(&id)?;
1019        Some(self.entries[pos].opacity)
1020    }
1021
1022    /// Set an item's local opacity, clamped to `[0.0, 1.0]`.
1023    pub fn set_opacity(&mut self, id: ItemId, opacity: f32) {
1024        if let Some(&pos) = self.entry_index.get(&id) {
1025            let new = opacity.clamp(0.0, 1.0);
1026            let old = self.entries[pos].opacity;
1027            if (old - new).abs() < f32::EPSILON {
1028                return;
1029            }
1030            self.entries[pos].opacity = new;
1031            self.emit_item_change(ItemChange::OpacityChanged { id, old, new });
1032        }
1033    }
1034
1035    /// Replace a lightweight item's fill colour live, emitting
1036    /// [`ItemChange::AppearanceChanged`] — **always repaint-only**, never a
1037    /// relayout, rebuild, or AccessKit re-walk. The colour is a [`ColorProp`],
1038    /// so it accepts a plain [`Color`](teksilo_tokens::Color), a theme role, a
1039    /// `Signal<Color>`, or a `Signal<Role>`. No-op for item kinds without a fill
1040    /// (e.g. `ImageItem`).
1041    ///
1042    /// # Reactivity contract
1043    ///
1044    /// A colour becomes **continuously** reactive by being registered at build
1045    /// time (`SceneItem::register_bindings`). So:
1046    ///
1047    /// - **Construct** the item with a `Signal`/role colour (`.fill(my_signal)`)
1048    ///   for a colour that tracks its signal forever. This is the recommended
1049    ///   path and needs no mutator at all.
1050    /// - **This mutator** installs a *snapshot*: it repaints immediately, which
1051    ///   is all a static colour ever needs. If you pass a `Signal`/dynamic role
1052    ///   here, it paints the signal's current value now and starts tracking it
1053    ///   continuously from the owning view's next rebuild (whenever some other
1054    ///   structural change re-runs `register_bindings`). Deliberately *not*
1055    ///   forced: a colour change must never cost a rebuild + AT re-walk.
1056    pub fn set_item_fill(&mut self, id: ItemId, fill: impl Into<ColorProp>) {
1057        let prop = fill.into();
1058        let Some(&pos) = self.entry_index.get(&id) else {
1059            return;
1060        };
1061        let applied = match &mut self.entries[pos].kind {
1062            SceneEntryKind::Item(item) => item.set_fill(Some(prop)),
1063            _ => false,
1064        };
1065        if applied {
1066            self.emit_item_change(ItemChange::AppearanceChanged { id });
1067        }
1068    }
1069
1070    /// Clear a lightweight item's fill (Rect/Path/Group become fill-less),
1071    /// emitting [`ItemChange::AppearanceChanged`] (repaint-only). No-op for items
1072    /// whose fill can't be cleared (e.g. `TextItem`, which always has a
1073    /// foreground colour).
1074    pub fn clear_item_fill(&mut self, id: ItemId) {
1075        let Some(&pos) = self.entry_index.get(&id) else {
1076            return;
1077        };
1078        let applied = match &mut self.entries[pos].kind {
1079            SceneEntryKind::Item(item) => item.set_fill(None),
1080            _ => false,
1081        };
1082        if applied {
1083            self.emit_item_change(ItemChange::AppearanceChanged { id });
1084        }
1085    }
1086
1087    /// Replace a lightweight item's stroke (colour + [`StrokeStyle`]) live,
1088    /// emitting [`ItemChange::AppearanceChanged`] (repaint-only). No-op for item
1089    /// kinds without a stroke slot (`TextItem` / `ImageItem`). See
1090    /// [`set_item_fill`](Self::set_item_fill) for the reactivity contract.
1091    pub fn set_item_stroke(&mut self, id: ItemId, color: impl Into<ColorProp>, style: StrokeStyle) {
1092        let prop = color.into();
1093        let Some(&pos) = self.entry_index.get(&id) else {
1094            return;
1095        };
1096        let applied = match &mut self.entries[pos].kind {
1097            SceneEntryKind::Item(item) => item.set_stroke(Some((prop, style))),
1098            _ => false,
1099        };
1100        if applied {
1101            self.emit_item_change(ItemChange::AppearanceChanged { id });
1102        }
1103    }
1104
1105    /// Clear a lightweight item's stroke, emitting
1106    /// [`ItemChange::AppearanceChanged`] (repaint-only). No-op for item kinds
1107    /// without a stroke.
1108    pub fn clear_item_stroke(&mut self, id: ItemId) {
1109        let Some(&pos) = self.entry_index.get(&id) else {
1110            return;
1111        };
1112        let applied = match &mut self.entries[pos].kind {
1113            SceneEntryKind::Item(item) => item.set_stroke(None),
1114            _ => false,
1115        };
1116        if applied {
1117            self.emit_item_change(ItemChange::AppearanceChanged { id });
1118        }
1119    }
1120
1121    /// Insert an already-boxed lightweight item at `local_pos`, returning its
1122    /// id. The boxed-`dyn` counterpart of [`add_item`](Self::add_item) — used by
1123    /// [`SceneListAdapter`](crate::SceneListAdapter) whose delegate yields
1124    /// `Box<dyn SceneItem>`.
1125    pub fn add_boxed_item(&mut self, item: Box<dyn SceneItem>, local_pos: Point) -> ItemId {
1126        self.insert_boxed(item, local_pos, false)
1127    }
1128
1129    /// Replace an item's handler set. Pass `None` to clear.
1130    pub fn set_item_handlers(&mut self, id: ItemId, handlers: Option<SceneItemHandlerSet>) {
1131        if let Some(&pos) = self.entry_index.get(&id) {
1132            self.entries[pos].handlers = handlers.map(Box::new);
1133        }
1134    }
1135
1136    /// Mutably borrow an item's handler set, lazily creating an
1137    /// empty one if none exists. Returns `None` for unknown ids.
1138    /// Allows fluent chains: `scene.handlers_mut(id).unwrap().on_tap(…).cursor(…);`.
1139    pub fn handlers_mut(&mut self, id: ItemId) -> Option<&mut SceneItemHandlerSet> {
1140        let pos = *self.entry_index.get(&id)?;
1141        let entry = self.entries.get_mut(pos)?;
1142        if entry.handlers.is_none() {
1143            entry.handlers = Some(Box::new(SceneItemHandlerSet::new()));
1144        }
1145        entry.handlers.as_deref_mut()
1146    }
1147
1148    /// Read-only access to an item's handler set, if one is set.
1149    pub fn handlers(&self, id: ItemId) -> Option<&SceneItemHandlerSet> {
1150        let pos = *self.entry_index.get(&id)?;
1151        self.entries[pos].handlers.as_deref()
1152    }
1153
1154    /// Effective opacity composed up the parent chain — the product
1155    /// of every ancestor's opacity and this item's. `1.0` for an
1156    /// unknown id (so callers don't end up multiplying by a stale
1157    /// value).
1158    pub fn effective_opacity(&self, id: ItemId) -> f32 {
1159        let cap = self.entries.len();
1160        let mut hops = 0;
1161        let mut cur = Some(id);
1162        let mut acc = 1.0_f32;
1163        while let Some(cid) = cur {
1164            let Some(&pos) = self.entry_index.get(&cid) else {
1165                return acc;
1166            };
1167            let entry = &self.entries[pos];
1168            acc *= entry.opacity;
1169            cur = entry.parent;
1170            hops += 1;
1171            if hops > cap {
1172                break;
1173            }
1174        }
1175        acc
1176    }
1177
1178    // -----------------------------------------------------------------
1179    // Scene rect (Qt setSceneRect) + pan/zoom policy
1180    // -----------------------------------------------------------------
1181
1182    /// Declare the scene's logical extent. `None` (the default)
1183    /// means "auto-compute from items each query"; `Some(rect)`
1184    /// fixes the extent regardless of item placement. Used by
1185    /// `SceneView` for pan clamping and `fit_to_content`.
1186    pub fn set_scene_rect(&mut self, rect: Option<Rect>) {
1187        self.user_scene_rect = rect;
1188    }
1189
1190    /// The resolved scene extent — user-declared via
1191    /// [`Scene::set_scene_rect`] if set, otherwise the AABB
1192    /// enclosing every item's scene rect. `None` when neither is
1193    /// available (the user didn't declare and the scene is empty).
1194    pub fn scene_rect_extent(&self) -> Option<Rect> {
1195        if let Some(r) = self.user_scene_rect {
1196            return Some(r);
1197        }
1198        let ids = self.ids();
1199        let mut acc: Option<Rect> = None;
1200        for id in ids {
1201            let Some(r) = self.scene_rect(id) else {
1202                continue;
1203            };
1204            acc = Some(match acc {
1205                None => r,
1206                Some(a) => union_two_rects(a, r),
1207            });
1208        }
1209        acc
1210    }
1211
1212    /// Set the axes the view may pan along. Default
1213    /// [`PanAxes::Both`]. Writes to the reactive signal; gesture
1214    /// closures pick the change up on the next event.
1215    pub fn pan_axes(&mut self, axes: PanAxes) {
1216        self.constraints.pan_axes.set(axes);
1217    }
1218
1219    /// The currently-declared pan axes. Live read of the signal.
1220    pub fn current_pan_axes(&self) -> PanAxes {
1221        self.constraints.pan_axes.get()
1222    }
1223
1224    /// Set whether the view honors zoom gestures. Default `true`.
1225    /// Writes to the reactive signal.
1226    pub fn zoomable(&mut self, on: bool) {
1227        self.constraints.zoomable.set(on);
1228    }
1229
1230    /// Whether the scene currently allows zoom. Live read.
1231    pub fn is_zoomable(&self) -> bool {
1232        self.constraints.zoomable.get()
1233    }
1234
1235    /// Clamp the visible viewport to this scene-coord rect. `None`
1236    /// (default) leaves pan unconstrained. When `Some(r)`, the
1237    /// [`SceneView`](crate::SceneView)'s pan is clamped so the
1238    /// visible scene region overlaps `r`. When `r` is smaller than
1239    /// the visible viewport, the rect is centered.
1240    ///
1241    /// Distinct from [`set_scene_rect`](Self::set_scene_rect):
1242    /// `scene_rect` declares the scene's logical extent (used by
1243    /// `adopt_scene_size`); `pan_bounds` controls what region the
1244    /// user can scroll to. A doc-style app typically sets both to
1245    /// the same rect.
1246    pub fn set_pan_bounds(&mut self, bounds: Option<Rect>) {
1247        self.constraints.pan_bounds.set(bounds);
1248    }
1249
1250    /// The currently-declared pan-bounds rect. Live read.
1251    pub fn current_pan_bounds(&self) -> Option<Rect> {
1252        self.constraints.pan_bounds.get()
1253    }
1254
1255    /// Inclusive `[min, max]` zoom-factor clamp. `None` (default)
1256    /// is unconstrained from the `Scene` side — the `SceneView`
1257    /// may still impose its own override.
1258    ///
1259    /// The effective range applied by the `SceneView` is the
1260    /// intersection of `Scene` + view-level override, so apps
1261    /// cannot loosen a `Scene`-declared range by setting a wider
1262    /// override on the view.
1263    pub fn set_zoom_range(&mut self, range: Option<std::ops::RangeInclusive<f32>>) {
1264        self.constraints.zoom_range.set(range);
1265    }
1266
1267    /// The currently-declared zoom range. Live read.
1268    pub fn current_zoom_range(&self) -> Option<std::ops::RangeInclusive<f32>> {
1269        self.constraints.zoom_range.get()
1270    }
1271
1272    /// Reactive accessors for live observation.
1273    pub fn pan_axes_signal(&self) -> Signal<PanAxes> {
1274        self.constraints.pan_axes_signal()
1275    }
1276    /// Reactive pan-bounds signal.
1277    pub fn pan_bounds_signal(&self) -> Signal<Option<Rect>> {
1278        self.constraints.pan_bounds_signal()
1279    }
1280    /// Reactive zoom-range signal.
1281    pub fn zoom_range_signal(&self) -> Signal<Option<std::ops::RangeInclusive<f32>>> {
1282        self.constraints.zoom_range_signal()
1283    }
1284    /// Reactive zoomable on/off signal.
1285    pub fn zoomable_signal(&self) -> Signal<bool> {
1286        self.constraints.zoomable_signal()
1287    }
1288
1289    /// Read-only view of the full constraint bundle. Useful when
1290    /// passing all four signals to a custom view implementation.
1291    pub fn constraints(&self) -> &SceneConstraints {
1292        &self.constraints
1293    }
1294
1295    // -----------------------------------------------------------------
1296    // Z-order and parenting
1297    // -----------------------------------------------------------------
1298
1299    /// Set paint z-order for an entry. Higher z paints later (on top);
1300    /// equal-z falls back to insertion order. Default 0.0.
1301    ///
1302    /// Works for **both** tiers: lightweight items re-sort within their
1303    /// band on the next paint, and heavyweight widget entries restack the
1304    /// arena children on the next rebuild (the SceneView reorders
1305    /// `node.children` by z without recreating the widgets, so focus /
1306    /// text-edit / animation state survives the restack). No-op for
1307    /// unknown ids.
1308    pub fn set_z(&mut self, id: ItemId, z: f32) {
1309        if let Some(&pos) = self.entry_index.get(&id) {
1310            let old = self.entries[pos].z;
1311            if (old - z).abs() < f32::EPSILON {
1312                return;
1313            }
1314            self.entries[pos].z = z;
1315            self.emit_item_change(ItemChange::ZChanged { id, old, new: z });
1316        }
1317    }
1318
1319    /// Raise an entry above all current entries by giving it a z one
1320    /// greater than the current maximum. The drag-to-front primitive —
1321    /// call it on drag-start so the grabbed card (and its text) renders
1322    /// over the others. Works for both tiers (see [`set_z`](Self::set_z)).
1323    pub fn bring_to_front(&mut self, id: ItemId) {
1324        if !self.entry_index.contains_key(&id) {
1325            return;
1326        }
1327        let max_z = self
1328            .entries
1329            .iter()
1330            .map(|e| e.z)
1331            .fold(f32::NEG_INFINITY, f32::max);
1332        let target = if max_z.is_finite() { max_z + 1.0 } else { 1.0 };
1333        self.set_z(id, target);
1334    }
1335
1336    /// Lower an entry below all current entries by giving it a z one less
1337    /// than the current minimum. Works for both tiers (see
1338    /// [`set_z`](Self::set_z)).
1339    pub fn send_to_back(&mut self, id: ItemId) {
1340        if !self.entry_index.contains_key(&id) {
1341            return;
1342        }
1343        let min_z = self
1344            .entries
1345            .iter()
1346            .map(|e| e.z)
1347            .fold(f32::INFINITY, f32::min);
1348        let target = if min_z.is_finite() { min_z - 1.0 } else { -1.0 };
1349        self.set_z(id, target);
1350    }
1351
1352    /// Read an entry's z-order.
1353    pub fn z(&self, id: ItemId) -> Option<f32> {
1354        let pos = *self.entry_index.get(&id)?;
1355        Some(self.entries[pos].z)
1356    }
1357
1358    /// Set the Under/Over paint band for a lightweight entry. `Over`
1359    /// items paint *after* the heavyweight widget children (in the
1360    /// SceneView's `post_paint`), so they sit on top of the cards;
1361    /// `Under` items (the default) paint before them. Within a band,
1362    /// [`set_z`](Self::set_z) still orders items among themselves.
1363    /// No-op for unknown ids.
1364    pub fn set_layer(&mut self, id: ItemId, layer: SceneLayer) {
1365        if let Some(&pos) = self.entry_index.get(&id) {
1366            let old = self.entries[pos].layer;
1367            if old == layer {
1368                return;
1369            }
1370            self.entries[pos].layer = layer;
1371            self.emit_item_change(ItemChange::LayerChanged {
1372                id,
1373                old,
1374                new: layer,
1375            });
1376        }
1377    }
1378
1379    /// Read an entry's Under/Over paint band. `None` for unknown ids.
1380    pub fn layer(&self, id: ItemId) -> Option<SceneLayer> {
1381        let pos = *self.entry_index.get(&id)?;
1382        Some(self.entries[pos].layer)
1383    }
1384
1385    /// Whether any entry is in the [`SceneLayer::Over`] band. The
1386    /// SceneView consults this in `wants_post_paint` to skip the
1387    /// foreground pass entirely when nothing is raised above the cards.
1388    /// Linear in entry count, called once per frame.
1389    pub(crate) fn has_over_layer_items(&self) -> bool {
1390        self.entries.iter().any(|e| e.layer == SceneLayer::Over)
1391    }
1392
1393    /// Declare a parent/child relationship. `child`'s `local_pos`
1394    /// and `transform` are reinterpreted as relative to the new
1395    /// parent's local frame — the visual position changes unless
1396    /// the caller compensates. Re-buckets `child`'s subtree.
1397    ///
1398    /// Pass `parent = None` to detach (child's local frame becomes
1399    /// scene-rooted again).
1400    ///
1401    /// **Cycle guard:** if the proposed parent is `child` itself
1402    /// or a descendant of `child`, the call is a no-op (no parent
1403    /// change, no rebucket, no signal fire). Without this guard
1404    /// the downstream `rebucket_subtree` walk loops indefinitely.
1405    pub fn set_item_parent(&mut self, child: ItemId, parent: Option<ItemId>) {
1406        if let Some(&pos) = self.entry_index.get(&child) {
1407            let old = self.entries[pos].parent;
1408            if old == parent {
1409                return;
1410            }
1411            // Reject self-parent and any parent in the child's
1412            // subtree (would create a cycle).
1413            if let Some(p) = parent
1414                && (p == child || self.is_descendant_of(p, child))
1415            {
1416                return;
1417            }
1418            self.entries[pos].parent = parent;
1419            self.rebucket_subtree(child);
1420            self.emit_item_change(ItemChange::ParentChanged {
1421                id: child,
1422                old,
1423                new: parent,
1424            });
1425        }
1426    }
1427
1428    /// Parent of `id`, if any.
1429    pub fn parent_of(&self, id: ItemId) -> Option<ItemId> {
1430        let pos = *self.entry_index.get(&id)?;
1431        self.entries[pos].parent
1432    }
1433
1434    /// Whether `id`'s ancestor chain contains `ancestor`.
1435    pub fn is_descendant_of(&self, id: ItemId, ancestor: ItemId) -> bool {
1436        let mut cur = self.parent_of(id);
1437        let cap = self.entries.len();
1438        let mut hops = 0;
1439        while let Some(p) = cur {
1440            if p == ancestor {
1441                return true;
1442            }
1443            cur = self.parent_of(p);
1444            hops += 1;
1445            if hops > cap {
1446                break;
1447            }
1448        }
1449        false
1450    }
1451
1452    /// Append every direct + transitive descendant of `id` into
1453    /// `out`, breadth-first across declaration order. The id
1454    /// itself is **not** included.
1455    pub fn collect_descendants(&self, id: ItemId, out: &mut Vec<ItemId>) {
1456        let mut frontier: Vec<ItemId> = vec![id];
1457        while let Some(parent) = frontier.pop() {
1458            for entry in &self.entries {
1459                if entry.parent == Some(parent) {
1460                    out.push(entry.id);
1461                    frontier.push(entry.id);
1462                }
1463            }
1464        }
1465    }
1466
1467    // -----------------------------------------------------------------
1468    // Lookup
1469    // -----------------------------------------------------------------
1470
1471    /// Borrow a lightweight [`SceneItem`] by id. `None` for unknown
1472    /// ids and for heavyweight widget entries.
1473    pub fn item(&self, id: ItemId) -> Option<&dyn SceneItem> {
1474        let pos = *self.entry_index.get(&id)?;
1475        match &self.entries[pos].kind {
1476            SceneEntryKind::Item(item) => Some(item.as_ref()),
1477            SceneEntryKind::Widget(_) => None,
1478        }
1479    }
1480
1481    /// Sort `ids` by z-order ascending, stable for equal values.
1482    /// Crate-private helper for `SceneView::paint`.
1483    pub(crate) fn sort_by_z(&self, ids: &mut [ItemId]) {
1484        ids.sort_by(|a, b| {
1485            let za = self.z(*a).unwrap_or(0.0);
1486            let zb = self.z(*b).unwrap_or(0.0);
1487            za.partial_cmp(&zb).unwrap_or(std::cmp::Ordering::Equal)
1488        });
1489    }
1490
1491    // -----------------------------------------------------------------
1492    // Removal
1493    // -----------------------------------------------------------------
1494
1495    /// Remove an item by id, recursively dropping every descendant.
1496    ///
1497    /// Mirrors Qt's `QGraphicsScene::removeItem` semantics: deleting
1498    /// a parent deletes its children too. No-op if `id` is unknown.
1499    /// Fires one [`ItemChange::Removed`] per id, descendants first
1500    /// then the named parent — observers see a consistent
1501    /// "leaves-then-root" order.
1502    ///
1503    /// To remove `id` without deleting its children, call
1504    /// [`Scene::orphan`] first to promote them to root-level, then
1505    /// `remove(id)`.
1506    pub fn remove(&mut self, id: ItemId) {
1507        use std::collections::HashSet;
1508        if !self.entry_index.contains_key(&id) {
1509            return;
1510        }
1511        // Descendants, deepest-first via collect_descendants's BFS
1512        // (the order is leaf-to-root because we push children as we
1513        // visit each parent). Append the named id last.
1514        let mut to_remove: Vec<ItemId> = Vec::new();
1515        self.collect_descendants(id, &mut to_remove);
1516        to_remove.reverse();
1517        to_remove.push(id);
1518        let removal_set: HashSet<ItemId> = to_remove.iter().copied().collect();
1519        self.entries.retain(|e| !removal_set.contains(&e.id));
1520        self.entry_index.clear();
1521        for (pos, entry) in self.entries.iter().enumerate() {
1522            self.entry_index.insert(entry.id, pos);
1523        }
1524        // The AT tree is separate from the visual tree, but a visually-removed
1525        // item must also vanish from AccessKit. Drop every logical-structure
1526        // entry that targets a removed item. For `a11y_parents` this also
1527        // re-roots any *still-alive* node that was AT-parented under a removed
1528        // item — dropping the `(child → removed)` mapping makes the child fall
1529        // back to the SceneView root (mirrors `remove_a11y_group`). Removal
1530        // itself fires `ItemChange::Removed`, so `SceneView` already re-walks
1531        // AT through the item-change observer; no `a11y_change_signal` bump
1532        // is needed here.
1533        let is_removed = |n: &A11yNode| matches!(n, A11yNode::Item(i) if removal_set.contains(i));
1534        self.a11y_parents
1535            .retain(|child, parent| !is_removed(child) && !is_removed(parent));
1536        self.a11y_relations
1537            .retain(|(from, _, to)| !is_removed(from) && !is_removed(to));
1538        for removed_id in &removal_set {
1539            let node = A11yNode::Item(*removed_id);
1540            self.a11y_live.remove(&node);
1541            self.a11y_landmarks.remove(&node);
1542            self.a11y_categories.remove(&node);
1543            // Drop any magnets attached to the removed item, retiring
1544            // their ids from the reverse-lookup map. Magnets are local
1545            // to the item, so a removed item takes its magnets with it.
1546            if let Some(magnets) = self.magnets.remove(removed_id) {
1547                for (mid, _) in magnets {
1548                    self.magnet_owner.remove(&mid);
1549                }
1550            }
1551        }
1552
1553        for removed_id in to_remove {
1554            self.index.remove(removed_id);
1555            self.emit_item_change(ItemChange::Removed { id: removed_id });
1556        }
1557    }
1558
1559    /// Promote `id`'s direct children to root-level (clear their
1560    /// `parent` field). Used when an app wants to remove `id` without
1561    /// dropping its children — call `orphan(id)` then `remove(id)`.
1562    /// No-op when `id` is unknown or has no children.
1563    ///
1564    /// Fires one [`ItemChange::ParentChanged`] per detached child and
1565    /// re-buckets every detached subtree in the spatial index — the
1566    /// children's `scene_transform` shifts (no longer composes
1567    /// `id`'s) so their scene-space AABBs change. Without re-bucketing
1568    /// the index, [`items_in_rect`](Self::items_in_rect) and
1569    /// [`item_at`](Self::item_at) would return stale results.
1570    ///
1571    /// Apps wanting *visual* stability across the orphan call should
1572    /// first bake `id`'s `scene_transform` into each child's
1573    /// `local_pos` + `transform`; otherwise children visibly jump.
1574    pub fn orphan(&mut self, id: ItemId) {
1575        if !self.entry_index.contains_key(&id) {
1576            return;
1577        }
1578        let children: Vec<ItemId> = self
1579            .entries
1580            .iter()
1581            .filter(|e| e.parent == Some(id))
1582            .map(|e| e.id)
1583            .collect();
1584        for child in children {
1585            if let Some(&pos) = self.entry_index.get(&child) {
1586                self.entries[pos].parent = None;
1587                // Re-bucket the entire detached subtree: each child's
1588                // scene_transform changed (no longer composes `id`'s),
1589                // so spatial-index AABBs are stale. Subtree-walk
1590                // because grandchildren depend on the chain too.
1591                self.rebucket_subtree(child);
1592                self.emit_item_change(ItemChange::ParentChanged {
1593                    id: child,
1594                    old: Some(id),
1595                    new: None,
1596                });
1597            }
1598        }
1599    }
1600
1601    // -----------------------------------------------------------------
1602    // Queries
1603    // -----------------------------------------------------------------
1604
1605    /// All items whose scene-AABB intersects `scene_rect`.
1606    ///
1607    /// Broad phase: the spatial index returns every id bucketed in
1608    /// any cell touched by `scene_rect`. Narrow phase: each candidate
1609    /// goes through [`scene_rect`](Self::scene_rect), which itself
1610    /// dispatches via `entry_index` (an `HashMap<ItemId, usize>`),
1611    /// so the per-candidate cost is O(parent-chain-depth) — not
1612    /// O(N). Total query is O(visible × chain) instead of O(N).
1613    pub fn items_in_rect(&self, scene_rect: Rect) -> Vec<ItemId> {
1614        self.index
1615            .query(scene_rect)
1616            .into_iter()
1617            .filter(|id| {
1618                self.scene_rect(*id)
1619                    .map(|r| rects_intersect(r, scene_rect))
1620                    .unwrap_or(false)
1621            })
1622            .collect()
1623    }
1624
1625    /// Snapshot every visible item — **both tiers** — as a `(scene_rect,
1626    /// color)` pair suitable for a minimap thumbnail. Filters out items with
1627    /// `HAS_NO_CONTENTS` (logical-only) and items hidden by `IS_VISIBLE` / a
1628    /// hidden ancestor — the visible-effective set matches what the SceneView's
1629    /// paint walk renders.
1630    ///
1631    /// Ordered by insertion (low z first). A lightweight item's color comes
1632    /// from [`SceneItem::thumbnail_color`] (its fill / stroke / a neutral grey);
1633    /// a heavyweight widget entry has no `SceneItem`, so it's shown in a neutral
1634    /// tint — a minimap that omitted the heavyweight tier would misrepresent a
1635    /// widget-heavy scene (cards, nodes), so both tiers are included.
1636    pub fn item_thumbnails(&self) -> Vec<(Rect, teksilo_tokens::Color)> {
1637        let mut out = Vec::new();
1638        for entry in &self.entries {
1639            // Skip invisible / logical-only items (either tier).
1640            if !self.is_effectively_visible(entry.id) {
1641                continue;
1642            }
1643            if entry.flags.contains(ItemFlags::HAS_NO_CONTENTS) {
1644                continue;
1645            }
1646            let Some(rect) = self.scene_rect(entry.id) else {
1647                continue;
1648            };
1649            let color = match &entry.kind {
1650                SceneEntryKind::Item(item) => item.thumbnail_color(),
1651                // Heavyweight widget: no `thumbnail_color`, so use a neutral tint.
1652                SceneEntryKind::Widget(_) => teksilo_tokens::Color::new(0.45, 0.52, 0.65, 0.85),
1653            };
1654            out.push((rect, color));
1655        }
1656        out
1657    }
1658
1659    /// Topmost lightweight item whose `shape_contains` fires for
1660    /// `scene_pt`. Iterates `items_in_rect` for a tiny rect around
1661    /// the point, sorts by z descending, and returns the first hit.
1662    /// Heavyweight widget entries are skipped (their hit-testing is
1663    /// handled by the arena event dispatch).
1664    ///
1665    /// **Limitation:** items flagged
1666    /// [`IGNORES_TRANSFORMATIONS`](crate::flags::ItemFlags::IGNORES_TRANSFORMATIONS)
1667    /// hit-test in screen space, not scene space — so this scene-only
1668    /// query may incorrectly hit them or miss them depending on the
1669    /// current view transform. Apps that route pointer events through
1670    /// `SceneView`'s dispatch get screen-space hit-test for IGNORES
1671    /// items automatically; only use `item_at` directly for normal
1672    /// items, or pair with the view transform to filter.
1673    pub fn item_at(&self, scene_pt: Point) -> Option<ItemId> {
1674        let probe = Rect::new(scene_pt.x, scene_pt.y, 0.0, 0.0);
1675        let mut candidates = self.items_in_rect(probe);
1676        candidates.sort_by(|a, b| {
1677            let za = self.z(*a).unwrap_or(0.0);
1678            let zb = self.z(*b).unwrap_or(0.0);
1679            zb.partial_cmp(&za).unwrap_or(std::cmp::Ordering::Equal)
1680        });
1681        for id in candidates {
1682            let Some(item) = self.item(id) else {
1683                continue;
1684            };
1685            let Some(local_pt) = self.map_from_scene(id, scene_pt) else {
1686                continue;
1687            };
1688            if item.shape_contains(local_pt) {
1689                return Some(id);
1690            }
1691        }
1692        None
1693    }
1694
1695    /// Items whose scene-AABB intersects the AABB of `id`. Excludes
1696    /// `id` itself. Apps use this for "which other items overlap
1697    /// this card?" queries — graph editors checking node-on-node
1698    /// overlap, CAD canvases finding adjacent geometry. Backed by
1699    /// the spatial index, so the cost is `O(visible)` not `O(N)`.
1700    pub fn colliding_items(&self, id: ItemId) -> Vec<ItemId> {
1701        let Some(rect) = self.scene_rect(id) else {
1702            return Vec::new();
1703        };
1704        self.items_in_rect(rect)
1705            .into_iter()
1706            .filter(|other| *other != id)
1707            .collect()
1708    }
1709
1710    /// Items whose scene-AABB intersects `path`'s bounding rect.
1711    /// Apps use this for "which items lie under this connector?"
1712    /// queries — graph editors highlighting hovered connectors,
1713    /// CAD canvases doing point-in-polygon style picking. The
1714    /// narrow phase is AABB-vs-AABB; per-segment-distance precision
1715    /// is left to the app.
1716    pub fn items_along_path(&self, path: &Path) -> Vec<ItemId> {
1717        let Some(rect) = path_aabb(path) else {
1718            return Vec::new();
1719        };
1720        self.items_in_rect(rect)
1721    }
1722
1723    /// All lightweight items whose `shape_contains` fires for
1724    /// `scene_pt`, sorted topmost-first by z.
1725    pub fn items_at(&self, scene_pt: Point) -> Vec<ItemId> {
1726        let probe = Rect::new(scene_pt.x, scene_pt.y, 0.0, 0.0);
1727        let mut candidates = self.items_in_rect(probe);
1728        candidates.sort_by(|a, b| {
1729            let za = self.z(*a).unwrap_or(0.0);
1730            let zb = self.z(*b).unwrap_or(0.0);
1731            zb.partial_cmp(&za).unwrap_or(std::cmp::Ordering::Equal)
1732        });
1733        candidates
1734            .into_iter()
1735            .filter(|id| {
1736                let Some(item) = self.item(*id) else {
1737                    return false;
1738                };
1739                let Some(local_pt) = self.map_from_scene(*id, scene_pt) else {
1740                    return false;
1741                };
1742                item.shape_contains(local_pt)
1743            })
1744            .collect()
1745    }
1746
1747    // -----------------------------------------------------------------
1748    // Metadata
1749    // -----------------------------------------------------------------
1750
1751    /// Number of entries in the scene.
1752    pub fn len(&self) -> usize {
1753        self.entries.len()
1754    }
1755
1756    /// Whether the scene is empty.
1757    pub fn is_empty(&self) -> bool {
1758        self.entries.is_empty()
1759    }
1760
1761    /// All ids in insertion order.
1762    pub fn ids(&self) -> Vec<ItemId> {
1763        self.entries.iter().map(|e| e.id).collect()
1764    }
1765
1766    /// Borrow the spatial index (diagnostics / tests).
1767    pub fn index(&self) -> &dyn SpatialIndex {
1768        &*self.index
1769    }
1770
1771    // -----------------------------------------------------------------
1772    // Magnetism
1773    // -----------------------------------------------------------------
1774
1775    /// Attach a [`Magnet`] to `item` and return its [`MagnetId`].
1776    ///
1777    /// Magnets are local to their item (their `local_pos` is in the
1778    /// item's frame), so they follow the item under any move / rotate /
1779    /// scale via the same `scene_transform` the item uses. No-op
1780    /// returning a fresh-but-unowned id if `item` is unknown — callers
1781    /// add magnets to items they just created.
1782    ///
1783    /// Bumps the AT-structure change counter (magnets are AT structure)
1784    /// so a `SceneView` with magnetism enabled re-walks its synthetic
1785    /// magnet nodes.
1786    pub fn add_magnet(&mut self, item: ItemId, magnet: Magnet) -> MagnetId {
1787        let id = MagnetId::next();
1788        if !self.entry_index.contains_key(&item) {
1789            return id;
1790        }
1791        self.magnets.entry(item).or_default().push((id, magnet));
1792        self.magnet_owner.insert(id, item);
1793        self.bump_a11y_change();
1794        id
1795    }
1796
1797    /// Remove a magnet by id. No-op if the id is unknown.
1798    pub fn remove_magnet(&mut self, magnet: MagnetId) {
1799        let Some(owner) = self.magnet_owner.remove(&magnet) else {
1800            return;
1801        };
1802        if let Some(list) = self.magnets.get_mut(&owner) {
1803            list.retain(|(mid, _)| *mid != magnet);
1804            if list.is_empty() {
1805                self.magnets.remove(&owner);
1806            }
1807        }
1808        self.bump_a11y_change();
1809    }
1810
1811    /// Remove every magnet attached to `item`. No-op if none.
1812    pub fn clear_magnets(&mut self, item: ItemId) {
1813        if let Some(list) = self.magnets.remove(&item) {
1814            for (mid, _) in list {
1815                self.magnet_owner.remove(&mid);
1816            }
1817            self.bump_a11y_change();
1818        }
1819    }
1820
1821    /// Move a magnet to a new position in its owning item's local
1822    /// frame. No-op if the id is unknown.
1823    pub fn set_magnet_local_pos(&mut self, magnet: MagnetId, local_pos: Point) {
1824        let Some(&owner) = self.magnet_owner.get(&magnet) else {
1825            return;
1826        };
1827        if let Some(list) = self.magnets.get_mut(&owner)
1828            && let Some((_, m)) = list.iter_mut().find(|(mid, _)| *mid == magnet)
1829        {
1830            m.local_pos = local_pos;
1831            self.bump_a11y_change();
1832        }
1833    }
1834
1835    /// Enable or disable a magnet. Disabled magnets are skipped by
1836    /// broad-phase, feedback, the keyboard cycle, and AT emission.
1837    /// No-op if the id is unknown.
1838    pub fn set_magnet_enabled(&mut self, magnet: MagnetId, enabled: bool) {
1839        let Some(&owner) = self.magnet_owner.get(&magnet) else {
1840            return;
1841        };
1842        if let Some(list) = self.magnets.get_mut(&owner)
1843            && let Some((_, m)) = list.iter_mut().find(|(mid, _)| *mid == magnet)
1844            && m.enabled != enabled
1845        {
1846            m.enabled = enabled;
1847            self.bump_a11y_change();
1848        }
1849    }
1850
1851    /// The ids of every magnet attached to `item`, in insertion order
1852    /// (enabled and disabled alike). Empty if `item` is unknown or has
1853    /// no magnets.
1854    pub fn magnet_ids_of(&self, item: ItemId) -> Vec<MagnetId> {
1855        self.magnets
1856            .get(&item)
1857            .map(|list| list.iter().map(|(mid, _)| *mid).collect())
1858            .unwrap_or_default()
1859    }
1860
1861    /// The owning item of a magnet, or `None` if the id is unknown.
1862    pub fn magnet_owner(&self, magnet: MagnetId) -> Option<ItemId> {
1863        self.magnet_owner.get(&magnet).copied()
1864    }
1865
1866    /// The label set on a magnet (for the AT walker). `None` if unset
1867    /// or the id is unknown.
1868    pub(crate) fn magnet_label(&self, magnet: MagnetId) -> Option<teksilo_i18n::LocalizedString> {
1869        let owner = self.magnet_owner.get(&magnet)?;
1870        let list = self.magnets.get(owner)?;
1871        list.iter()
1872            .find(|(mid, _)| *mid == magnet)
1873            .and_then(|(_, m)| m.label.clone())
1874    }
1875
1876    /// Whether a magnet is enabled. `false` for an unknown id.
1877    pub fn magnet_enabled(&self, magnet: MagnetId) -> bool {
1878        let Some(owner) = self.magnet_owner.get(&magnet) else {
1879            return false;
1880        };
1881        self.magnets
1882            .get(owner)
1883            .and_then(|list| list.iter().find(|(mid, _)| *mid == magnet))
1884            .map(|(_, m)| m.enabled)
1885            .unwrap_or(false)
1886    }
1887
1888    /// A magnet's position in scene coordinates (its local position
1889    /// projected through its owning item's `scene_transform`). `None`
1890    /// for an unknown id or a degenerate item transform.
1891    pub fn magnet_scene_pos(&self, magnet: MagnetId) -> Option<Point> {
1892        let &owner = self.magnet_owner.get(&magnet)?;
1893        let list = self.magnets.get(&owner)?;
1894        let (_, m) = list.iter().find(|(mid, _)| *mid == magnet)?;
1895        self.map_to_scene(owner, m.local_pos)
1896    }
1897
1898    /// Resolve a magnet to a borrow-free [`MagnetRef`] snapshot (id,
1899    /// owning item, role, payload clone, current scene position).
1900    /// `None` for an unknown id or a degenerate item transform.
1901    pub fn magnet(&self, magnet: MagnetId) -> Option<MagnetRef> {
1902        let &owner = self.magnet_owner.get(&magnet)?;
1903        let list = self.magnets.get(&owner)?;
1904        let (_, m) = list.iter().find(|(mid, _)| *mid == magnet)?;
1905        let scene_pos = self.map_to_scene(owner, m.local_pos)?;
1906        Some(MagnetRef {
1907            id: magnet,
1908            item: owner,
1909            role: m.role,
1910            payload: m.payload.clone(),
1911            scene_pos,
1912        })
1913    }
1914
1915    /// Collect every enabled magnet whose scene position lies inside
1916    /// `scene_rect`, as borrow-free [`MagnetRef`] snapshots, excluding
1917    /// any magnet on `exclude_item`. Broad-phase over the spatial index
1918    /// (`items_in_rect`) so the cost is `O(visible × magnets/item)`.
1919    ///
1920    /// This is the shared narrow-phase input for both snap helpers: the
1921    /// candidates are materialised as owned snapshots, so the predicate
1922    /// that runs over them touches no scene state. The predicate may read
1923    /// the model (a shared borrow is re-entrant) but must not mutate it;
1924    /// mutation belongs in the consumer's `on_connect`, which fires after
1925    /// the snap call returns and every borrow is dropped.
1926    fn collect_candidate_magnets(
1927        &self,
1928        scene_rect: Rect,
1929        exclude_item: Option<ItemId>,
1930    ) -> Vec<MagnetRef> {
1931        let mut out = Vec::new();
1932        for item in self.items_in_rect(scene_rect) {
1933            if Some(item) == exclude_item {
1934                continue;
1935            }
1936            let Some(list) = self.magnets.get(&item) else {
1937                continue;
1938            };
1939            let xform = self.scene_transform(item);
1940            for (mid, m) in list {
1941                if !m.enabled {
1942                    continue;
1943                }
1944                let scene_pos = xform.apply_point(m.local_pos);
1945                if !scene_rect.contains(scene_pos) {
1946                    continue;
1947                }
1948                out.push(MagnetRef {
1949                    id: *mid,
1950                    item,
1951                    role: m.role,
1952                    payload: m.payload.clone(),
1953                    scene_pos,
1954                });
1955            }
1956        }
1957        out
1958    }
1959
1960    /// Square-rect of half-extent `radius` centred on `center`.
1961    fn capture_rect(center: Point, radius: f32) -> Rect {
1962        Rect::new(
1963            center.x - radius,
1964            center.y - radius,
1965            radius * 2.0,
1966            radius * 2.0,
1967        )
1968    }
1969
1970    /// Compute the best item-drag snap: the dragged item is visually
1971    /// offset by `drag_delta`, and each of its enabled magnets seeks the
1972    /// nearest *accepting* magnet on another item within `capture_radius`
1973    /// (in scene units). Returns the globally closest accepting pair, or
1974    /// `None` if nothing accepts within range.
1975    ///
1976    /// Pure mechanism: it collects candidates under a brief read, then
1977    /// runs the consumer `predicate` with no scene borrow held, so the
1978    /// predicate may inspect payloads freely. `snap_vector` added to
1979    /// `drag_delta` aligns the dragged magnet onto its target.
1980    pub fn compute_item_snap(
1981        &self,
1982        dragged: ItemId,
1983        drag_delta: Vec2,
1984        capture_radius: f32,
1985        predicate: &dyn Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict,
1986    ) -> Option<MagnetSnap> {
1987        if capture_radius <= 0.0 {
1988            return None;
1989        }
1990        let dragged_list = self.magnets.get(&dragged)?;
1991        if dragged_list.is_empty() {
1992            return None;
1993        }
1994        // Visual scene positions of the dragged item's enabled magnets:
1995        // committed scene pos + the live drag delta.
1996        let xform = self.scene_transform(dragged);
1997        let dragged_magnets: Vec<MagnetRef> = dragged_list
1998            .iter()
1999            .filter(|(_, m)| m.enabled)
2000            .map(|(mid, m)| {
2001                let committed = xform.apply_point(m.local_pos);
2002                MagnetRef {
2003                    id: *mid,
2004                    item: dragged,
2005                    role: m.role,
2006                    payload: m.payload.clone(),
2007                    scene_pos: Point::new(committed.x + drag_delta.x, committed.y + drag_delta.y),
2008                }
2009            })
2010            .collect();
2011        if dragged_magnets.is_empty() {
2012            return None;
2013        }
2014
2015        let mut best: Option<MagnetSnap> = None;
2016        for from in &dragged_magnets {
2017            let rect = Self::capture_rect(from.scene_pos, capture_radius);
2018            let candidates = self.collect_candidate_magnets(rect, Some(dragged));
2019            for to in &candidates {
2020                let dx = to.scene_pos.x - from.scene_pos.x;
2021                let dy = to.scene_pos.y - from.scene_pos.y;
2022                let dist = (dx * dx + dy * dy).sqrt();
2023                if dist > capture_radius {
2024                    continue;
2025                }
2026                let MagnetVerdict::Accept(payload) = predicate(from, to) else {
2027                    continue;
2028                };
2029                let better = best.as_ref().map(|b| dist < b.distance).unwrap_or(true);
2030                if better {
2031                    best = Some(MagnetSnap {
2032                        from: from.id,
2033                        to: to.id,
2034                        snap_vector: Vec2::new(dx, dy),
2035                        payload,
2036                        distance: dist,
2037                    });
2038                }
2039            }
2040        }
2041        best
2042    }
2043
2044    /// Compute the best port-drag snap: a single `source` magnet is
2045    /// dragging a transient wire whose free end is at `cursor_scene`.
2046    /// Finds the nearest *accepting* target magnet within
2047    /// `capture_radius` (scene units), excluding the source's own
2048    /// magnet. Returns the target [`MagnetRef`] and the accepting
2049    /// verdict's payload, or `None`.
2050    pub fn compute_port_snap(
2051        &self,
2052        source: MagnetId,
2053        cursor_scene: Point,
2054        capture_radius: f32,
2055        predicate: &dyn Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict,
2056    ) -> Option<(MagnetRef, Option<Rc<dyn std::any::Any>>)> {
2057        if capture_radius <= 0.0 {
2058            return None;
2059        }
2060        let from = self.magnet(source)?;
2061        let rect = Self::capture_rect(cursor_scene, capture_radius);
2062        // Don't exclude the source's whole item — a node may legitimately
2063        // connect to another of its own ports in some graphs; only the
2064        // source magnet itself is excluded (below).
2065        let candidates = self.collect_candidate_magnets(rect, None);
2066        let mut best: Option<(MagnetRef, Option<Rc<dyn std::any::Any>>, f32)> = None;
2067        for to in candidates {
2068            if to.id == source {
2069                continue;
2070            }
2071            let dx = to.scene_pos.x - cursor_scene.x;
2072            let dy = to.scene_pos.y - cursor_scene.y;
2073            let dist = (dx * dx + dy * dy).sqrt();
2074            if dist > capture_radius {
2075                continue;
2076            }
2077            let MagnetVerdict::Accept(payload) = predicate(&from, &to) else {
2078                continue;
2079            };
2080            let better = best.as_ref().map(|b| dist < b.2).unwrap_or(true);
2081            if better {
2082                best = Some((to, payload, dist));
2083            }
2084        }
2085        best.map(|(to, payload, _)| (to, payload))
2086    }
2087
2088    /// The nearest enabled magnet to `scene_pt` within `radius` (scene
2089    /// units), or `None`. Used by the view to start a port-drag from a
2090    /// grabbed magnet handle (the handle's grab area is a screen-pixel
2091    /// disc, converted to scene units by the caller).
2092    pub fn nearest_magnet(&self, scene_pt: Point, radius: f32) -> Option<MagnetId> {
2093        if radius <= 0.0 {
2094            return None;
2095        }
2096        let rect = Self::capture_rect(scene_pt, radius);
2097        let mut best: Option<(MagnetId, f32)> = None;
2098        for c in self.collect_candidate_magnets(rect, None) {
2099            let dx = c.scene_pos.x - scene_pt.x;
2100            let dy = c.scene_pos.y - scene_pt.y;
2101            let dist = (dx * dx + dy * dy).sqrt();
2102            if dist > radius {
2103                continue;
2104            }
2105            let better = best.map(|b| dist < b.1).unwrap_or(true);
2106            if better {
2107                best = Some((c.id, dist));
2108            }
2109        }
2110        best.map(|(id, _)| id)
2111    }
2112
2113    // -----------------------------------------------------------------
2114    // Logical AT structure (kept verbatim from R0)
2115    // -----------------------------------------------------------------
2116
2117    /// Declare a virtual AT group. The group has no visual
2118    /// counterpart — it exists so the AT walker can emit an AT node
2119    /// under which items / other groups / widgets can be reparented.
2120    pub fn add_a11y_group(&mut self, builder: A11yGroupBuilder) -> A11yGroupId {
2121        let id = A11yGroupId::next();
2122        let group = A11yGroup {
2123            id,
2124            label: builder.label,
2125            role: builder.role,
2126        };
2127        let pos = self.a11y_groups.len();
2128        self.a11y_groups.push(group);
2129        self.a11y_group_index.insert(id, pos);
2130        self.bump_a11y_change();
2131        id
2132    }
2133
2134    /// Remove a logical group; orphaned references fall back to
2135    /// SceneView root. Relations / live / landmarks / categories
2136    /// targeting this group are cleaned up too.
2137    pub fn remove_a11y_group(&mut self, id: A11yGroupId) {
2138        let prev = self.a11y_groups.len();
2139        self.a11y_groups.retain(|g| g.id != id);
2140        if self.a11y_groups.len() != prev {
2141            self.a11y_group_index.clear();
2142            for (pos, group) in self.a11y_groups.iter().enumerate() {
2143                self.a11y_group_index.insert(group.id, pos);
2144            }
2145        }
2146        let target = A11yNode::Group(id);
2147        self.a11y_parents
2148            .retain(|child, parent| *child != target && *parent != target);
2149        self.a11y_relations
2150            .retain(|(from, _, to)| *from != target && *to != target);
2151        self.a11y_live.remove(&target);
2152        self.a11y_landmarks.remove(&target);
2153        self.a11y_categories.remove(&target);
2154        self.bump_a11y_change();
2155    }
2156
2157    /// Borrow a logical group by id.
2158    pub fn a11y_group(&self, id: A11yGroupId) -> Option<&A11yGroup> {
2159        let pos = *self.a11y_group_index.get(&id)?;
2160        self.a11y_groups.get(pos)
2161    }
2162
2163    /// Declare a logical-parent relationship for AT (independent of
2164    /// visual placement).
2165    pub fn set_a11y_parent(&mut self, child: A11yNode, parent: Option<A11yNode>) {
2166        match parent {
2167            Some(p) => {
2168                self.a11y_parents.insert(child, p);
2169            }
2170            None => {
2171                self.a11y_parents.remove(&child);
2172            }
2173        }
2174        self.bump_a11y_change();
2175    }
2176
2177    /// The currently-declared logical parent of a node.
2178    pub fn a11y_parent_of(&self, child: A11yNode) -> Option<A11yNode> {
2179        self.a11y_parents.get(&child).copied()
2180    }
2181
2182    /// Declare an AT relationship between two nodes.
2183    pub fn add_a11y_relation(&mut self, from: A11yNode, kind: A11yRelation, to: A11yNode) {
2184        self.a11y_relations.push((from, kind, to));
2185        self.bump_a11y_change();
2186    }
2187
2188    /// All declared AT relations.
2189    pub fn a11y_relations(&self) -> &[(A11yNode, A11yRelation, A11yNode)] {
2190        &self.a11y_relations
2191    }
2192
2193    /// Mark a node as a live region. Pass `Live::Off` to clear.
2194    pub fn set_a11y_live(&mut self, node: A11yNode, live: accesskit::Live) {
2195        if matches!(live, accesskit::Live::Off) {
2196            self.a11y_live.remove(&node);
2197        } else {
2198            self.a11y_live.insert(node, live);
2199        }
2200        self.bump_a11y_change();
2201    }
2202
2203    /// Mark a node as a landmark by overriding its role. Pass
2204    /// `Role::Unknown` to clear.
2205    pub fn set_a11y_landmark(&mut self, node: A11yNode, role: accesskit::Role) {
2206        if matches!(role, accesskit::Role::Unknown) {
2207            self.a11y_landmarks.remove(&node);
2208        } else {
2209            self.a11y_landmarks.insert(node, role);
2210        }
2211        self.bump_a11y_change();
2212    }
2213
2214    /// Tag a node with rotor / quick-nav categories.
2215    pub fn set_a11y_categories(&mut self, node: A11yNode, categories: &[A11yCategory]) {
2216        if categories.is_empty() {
2217            self.a11y_categories.remove(&node);
2218        } else {
2219            self.a11y_categories.insert(node, categories.to_vec());
2220        }
2221        self.bump_a11y_change();
2222    }
2223
2224    /// Read declared categories for a node.
2225    pub fn a11y_categories_of(&self, node: A11yNode) -> Option<&[A11yCategory]> {
2226        self.a11y_categories.get(&node).map(|v| v.as_slice())
2227    }
2228}
2229
2230impl Default for Scene {
2231    fn default() -> Self {
2232        Self::new()
2233    }
2234}
2235
2236impl std::fmt::Debug for Scene {
2237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2238        f.debug_struct("Scene")
2239            .field("len", &self.entries.len())
2240            .field("index", &self.index)
2241            .finish_non_exhaustive()
2242    }
2243}
2244
2245/// Half-open AABB intersection: two rects intersect iff their
2246/// projections overlap on both axes.
2247pub(crate) fn rects_intersect(a: Rect, b: Rect) -> bool {
2248    a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
2249}
2250
2251/// AABB of the union of two rectangles.
2252fn union_two_rects(a: Rect, b: Rect) -> Rect {
2253    let x = a.x.min(b.x);
2254    let y = a.y.min(b.y);
2255    let r = a.right().max(b.right());
2256    let bot = a.bottom().max(b.bottom());
2257    Rect::new(x, y, r - x, bot - y)
2258}
2259
2260/// AABB enclosing every point in a path. Returns `None` for an
2261/// empty path. Curves contribute their control / end points only —
2262/// callers needing tight bounds for cubics should pre-compute and
2263/// pass the AABB directly via `Scene::items_in_rect`.
2264fn path_aabb(path: &Path) -> Option<Rect> {
2265    let mut min_x = f32::INFINITY;
2266    let mut min_y = f32::INFINITY;
2267    let mut max_x = f32::NEG_INFINITY;
2268    let mut max_y = f32::NEG_INFINITY;
2269    let mut include = |p: Point| {
2270        min_x = min_x.min(p.x);
2271        min_y = min_y.min(p.y);
2272        max_x = max_x.max(p.x);
2273        max_y = max_y.max(p.y);
2274    };
2275    for cmd in &path.commands {
2276        match cmd {
2277            teksilo_canvas::PathCommand::MoveTo(p) | teksilo_canvas::PathCommand::LineTo(p) => {
2278                include(*p)
2279            }
2280            teksilo_canvas::PathCommand::QuadTo { control, to } => {
2281                include(*control);
2282                include(*to);
2283            }
2284            teksilo_canvas::PathCommand::CubicTo {
2285                control1,
2286                control2,
2287                to,
2288            } => {
2289                include(*control1);
2290                include(*control2);
2291                include(*to);
2292            }
2293            teksilo_canvas::PathCommand::ArcTo { rect, .. } => {
2294                include(Point::new(rect.x, rect.y));
2295                include(Point::new(rect.right(), rect.bottom()));
2296            }
2297            teksilo_canvas::PathCommand::Close => {}
2298        }
2299    }
2300    if !min_x.is_finite() {
2301        return None;
2302    }
2303    Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
2304}
2305
2306#[cfg(test)]
2307mod tests {
2308    use super::*;
2309    use crate::items::RectItem;
2310    use teksilo_canvas::{Size, SizeProposal};
2311    use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget};
2312    use teksilo_tokens::Color;
2313
2314    #[derive(Debug)]
2315    struct FillWidget;
2316
2317    impl FillWidget {
2318        fn new() -> Self {
2319            Self
2320        }
2321    }
2322
2323    impl Widget for FillWidget {
2324        fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
2325            Size::new(0.0, 0.0).into()
2326        }
2327    }
2328
2329    #[test]
2330    fn add_widget_round_trip() {
2331        let mut scene = Scene::new();
2332        let r = Rect::new(10.0, 20.0, 100.0, 50.0);
2333        let id = scene.add_widget(FillWidget::new(), r);
2334        assert_eq!(scene.len(), 1);
2335        // scene_rect is computed from local_pos + local_bounds.
2336        assert_eq!(scene.scene_rect(id), Some(r));
2337        assert_eq!(scene.local_pos(id), Some(Point::new(10.0, 20.0)));
2338        assert_eq!(
2339            scene.local_bounds(id),
2340            Some(Rect::new(0.0, 0.0, 100.0, 50.0))
2341        );
2342        assert_eq!(scene.ids(), vec![id]);
2343    }
2344
2345    #[test]
2346    fn add_item_at_local_pos() {
2347        let mut scene = Scene::new();
2348        let id = scene.add_item(
2349            RectItem::new(Rect::new(0.0, 0.0, 30.0, 40.0)).fill(Color::RED),
2350            Point::new(10.0, 20.0),
2351        );
2352        assert_eq!(
2353            scene.scene_rect(id),
2354            Some(Rect::new(10.0, 20.0, 30.0, 40.0))
2355        );
2356        assert_eq!(scene.scene_pos(id), Some(Point::new(10.0, 20.0)));
2357    }
2358
2359    #[test]
2360    fn set_local_pos_updates_scene_rect_and_index() {
2361        let mut scene = Scene::new();
2362        let id = scene.add_item(
2363            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2364            Point::new(0.0, 0.0),
2365        );
2366        scene.set_local_pos(id, Point::new(500.0, 500.0));
2367        assert_eq!(
2368            scene.scene_rect(id),
2369            Some(Rect::new(500.0, 500.0, 10.0, 10.0))
2370        );
2371        let near_origin = scene.items_in_rect(Rect::new(0.0, 0.0, 50.0, 50.0));
2372        assert!(!near_origin.contains(&id));
2373        let near_far = scene.items_in_rect(Rect::new(490.0, 490.0, 30.0, 30.0));
2374        assert!(near_far.contains(&id));
2375    }
2376
2377    #[test]
2378    fn parent_relative_position_composes_through_chain() {
2379        let mut scene = Scene::new();
2380        let parent = scene.add_item(
2381            RectItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)),
2382            Point::new(50.0, 50.0),
2383        );
2384        let child = scene.add_item(
2385            RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)),
2386            Point::new(10.0, 10.0),
2387        );
2388        scene.set_item_parent(child, Some(parent));
2389
2390        // Child's scene_pos = parent local_pos + child local_pos.
2391        assert_eq!(scene.scene_pos(child), Some(Point::new(60.0, 60.0)));
2392        // Move parent — child's scene_pos shifts in lockstep.
2393        scene.set_local_pos(parent, Point::new(150.0, 150.0));
2394        assert_eq!(scene.scene_pos(child), Some(Point::new(160.0, 160.0)));
2395    }
2396
2397    #[test]
2398    fn set_local_pos_propagates_to_descendants_scene_pos() {
2399        // Three-deep chain: grandparent → parent → child.
2400        let mut scene = Scene::new();
2401        let gp = scene.add_item(
2402            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2403            Point::new(0.0, 0.0),
2404        );
2405        let p = scene.add_item(
2406            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2407            Point::new(20.0, 0.0),
2408        );
2409        let c = scene.add_item(
2410            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2411            Point::new(5.0, 0.0),
2412        );
2413        scene.set_item_parent(p, Some(gp));
2414        scene.set_item_parent(c, Some(p));
2415
2416        assert_eq!(scene.scene_pos(c), Some(Point::new(25.0, 0.0)));
2417        scene.set_local_pos(gp, Point::new(100.0, 100.0));
2418        assert_eq!(scene.scene_pos(c), Some(Point::new(125.0, 100.0)));
2419    }
2420
2421    #[test]
2422    fn remove_drops_the_entry() {
2423        let mut scene = Scene::new();
2424        let a = scene.add_widget(FillWidget::new(), Rect::ZERO);
2425        let b = scene.add_widget(FillWidget::new(), Rect::ZERO);
2426        scene.remove(a);
2427        assert_eq!(scene.len(), 1);
2428        assert_eq!(scene.scene_rect(a), None);
2429        assert!(scene.scene_rect(b).is_some());
2430    }
2431
2432    #[test]
2433    fn items_in_rect_brute_force() {
2434        let mut scene = Scene::new();
2435        let a = scene.add_widget(FillWidget::new(), Rect::new(0.0, 0.0, 10.0, 10.0));
2436        let b = scene.add_widget(FillWidget::new(), Rect::new(100.0, 100.0, 10.0, 10.0));
2437        let c = scene.add_widget(FillWidget::new(), Rect::new(5.0, 5.0, 10.0, 10.0));
2438
2439        let near_origin = scene.items_in_rect(Rect::new(0.0, 0.0, 20.0, 20.0));
2440        assert!(near_origin.contains(&a));
2441        assert!(near_origin.contains(&c));
2442        assert!(!near_origin.contains(&b));
2443
2444        let far = scene.items_in_rect(Rect::new(95.0, 95.0, 20.0, 20.0));
2445        assert_eq!(far, vec![b]);
2446
2447        let empty = scene.items_in_rect(Rect::new(500.0, 500.0, 1.0, 1.0));
2448        assert!(empty.is_empty());
2449    }
2450
2451    #[test]
2452    fn item_at_picks_topmost() {
2453        let mut scene = Scene::new();
2454        let bottom = scene.add_item(
2455            RectItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)),
2456            Point::new(0.0, 0.0),
2457        );
2458        let top = scene.add_item(
2459            RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)),
2460            Point::new(25.0, 25.0),
2461        );
2462        scene.set_z(top, 1.0);
2463        scene.set_z(bottom, 0.0);
2464        // Click in the overlap region.
2465        assert_eq!(scene.item_at(Point::new(50.0, 50.0)), Some(top));
2466        // Click outside the top, inside the bottom.
2467        assert_eq!(scene.item_at(Point::new(10.0, 10.0)), Some(bottom));
2468        // Click outside everything.
2469        assert_eq!(scene.item_at(Point::new(500.0, 500.0)), None);
2470    }
2471
2472    #[test]
2473    fn item_accessor_returns_lightweight_only() {
2474        let mut scene = Scene::new();
2475        let widget_id = scene.add_widget(FillWidget::new(), Rect::ZERO);
2476        let item_id = scene.add_item(
2477            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2478            Point::new(0.0, 0.0),
2479        );
2480        assert!(scene.item(item_id).is_some());
2481        assert!(scene.item(widget_id).is_none());
2482    }
2483
2484    #[test]
2485    fn map_to_scene_round_trips() {
2486        let mut scene = Scene::new();
2487        let id = scene.add_item(
2488            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2489            Point::new(50.0, 50.0),
2490        );
2491        let local = Point::new(3.0, 4.0);
2492        let scene_pt = scene.map_to_scene(id, local).unwrap();
2493        let back = scene.map_from_scene(id, scene_pt).unwrap();
2494        assert!((back.x - local.x).abs() < 1e-5);
2495        assert!((back.y - local.y).abs() < 1e-5);
2496    }
2497
2498    #[test]
2499    fn rects_intersect_edge_touching_excluded() {
2500        let a = Rect::new(0.0, 0.0, 10.0, 10.0);
2501        let b = Rect::new(10.0, 0.0, 10.0, 10.0);
2502        assert!(!rects_intersect(a, b));
2503        assert!(!rects_intersect(b, a));
2504    }
2505
2506    #[test]
2507    fn flags_default_carries_visible_enabled_selectable() {
2508        let mut scene = Scene::new();
2509        let id = scene.add_item(
2510            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2511            Point::new(0.0, 0.0),
2512        );
2513        let f = scene.flags(id).unwrap();
2514        assert!(f.contains(ItemFlags::IS_VISIBLE));
2515        assert!(f.contains(ItemFlags::IS_ENABLED));
2516        assert!(f.contains(ItemFlags::IS_SELECTABLE));
2517        assert!(!f.contains(ItemFlags::IS_DRAGGABLE));
2518    }
2519
2520    #[test]
2521    fn draggable_builder_sets_is_draggable_flag() {
2522        let mut scene = Scene::new();
2523        let id = scene.add_item(
2524            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)).draggable(true),
2525            Point::ZERO,
2526        );
2527        assert!(scene.flags(id).unwrap().contains(ItemFlags::IS_DRAGGABLE));
2528    }
2529
2530    #[test]
2531    fn set_visible_flag_chains_through_parent() {
2532        let mut scene = Scene::new();
2533        let parent = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
2534        let child = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 5.0, 5.0)), Point::ZERO);
2535        scene.set_item_parent(child, Some(parent));
2536        assert!(scene.is_effectively_visible(child));
2537        scene.set_visible(parent, false);
2538        assert!(!scene.is_effectively_visible(child));
2539        assert!(!scene.is_effectively_visible(parent));
2540    }
2541
2542    #[test]
2543    fn effective_opacity_composes_through_chain() {
2544        let mut scene = Scene::new();
2545        let p = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
2546        let c = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 5.0, 5.0)), Point::ZERO);
2547        scene.set_item_parent(c, Some(p));
2548        scene.set_opacity(p, 0.5);
2549        scene.set_opacity(c, 0.5);
2550        assert!((scene.effective_opacity(c) - 0.25).abs() < 1e-5);
2551    }
2552
2553    #[test]
2554    fn opacity_clamps_to_unit_range() {
2555        let mut scene = Scene::new();
2556        let id = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
2557        scene.set_opacity(id, 1.5);
2558        assert_eq!(scene.opacity(id), Some(1.0));
2559        scene.set_opacity(id, -0.3);
2560        assert_eq!(scene.opacity(id), Some(0.0));
2561    }
2562
2563    #[test]
2564    fn scene_rect_extent_uses_user_set_when_present() {
2565        let mut scene = Scene::new();
2566        let user = Rect::new(0.0, 0.0, 1000.0, 1000.0);
2567        scene.set_scene_rect(Some(user));
2568        assert_eq!(scene.scene_rect_extent(), Some(user));
2569        scene.set_scene_rect(None);
2570        // No items, no auto-extent.
2571        assert_eq!(scene.scene_rect_extent(), None);
2572    }
2573
2574    #[test]
2575    fn scene_rect_extent_auto_unions_items_when_unset() {
2576        let mut scene = Scene::new();
2577        scene.add_item(
2578            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2579            Point::new(5.0, 5.0),
2580        );
2581        scene.add_item(
2582            RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)),
2583            Point::new(100.0, 100.0),
2584        );
2585        let extent = scene.scene_rect_extent().unwrap();
2586        // (5,5)-(15,15) ∪ (100,100)-(120,120) = (5,5)-(120,120).
2587        assert!((extent.x - 5.0).abs() < 1e-3);
2588        assert!((extent.y - 5.0).abs() < 1e-3);
2589        assert!((extent.width - 115.0).abs() < 1e-3);
2590        assert!((extent.height - 115.0).abs() < 1e-3);
2591    }
2592
2593    #[test]
2594    fn pan_axes_default_is_both() {
2595        let scene = Scene::new();
2596        assert_eq!(scene.current_pan_axes(), PanAxes::Both);
2597        assert!(scene.is_zoomable());
2598    }
2599
2600    #[test]
2601    fn pan_axes_set_round_trip() {
2602        let mut scene = Scene::new();
2603        scene.pan_axes(PanAxes::Horizontal);
2604        assert_eq!(scene.current_pan_axes(), PanAxes::Horizontal);
2605        scene.zoomable(false);
2606        assert!(!scene.is_zoomable());
2607    }
2608
2609    #[test]
2610    fn item_change_signal_fires_on_set_local_pos() {
2611        use std::cell::Cell;
2612        use std::rc::Rc;
2613        let mut scene = Scene::new();
2614        let id = scene.add_item(
2615            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2616            Point::new(0.0, 0.0),
2617        );
2618        let last = Rc::new(Cell::new(None::<ItemChange>));
2619        let last_clone = last.clone();
2620        let _h = scene.item_change_signal().observe(move |c| {
2621            last_clone.set(Some(*c));
2622        });
2623        scene.set_local_pos(id, Point::new(50.0, 60.0));
2624        match last.get() {
2625            Some(ItemChange::LocalPosChanged { new, .. }) => {
2626                assert_eq!(new, Point::new(50.0, 60.0));
2627            }
2628            other => panic!("expected LocalPosChanged, got {:?}", other),
2629        }
2630    }
2631
2632    #[test]
2633    fn item_change_signal_fires_on_set_visible() {
2634        use std::cell::Cell;
2635        use std::rc::Rc;
2636        let mut scene = Scene::new();
2637        let id = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
2638        let count = Rc::new(Cell::new(0_u32));
2639        let count_clone = count.clone();
2640        let _h = scene.item_change_signal().observe(move |c| {
2641            if matches!(c, ItemChange::VisibilityChanged { .. }) {
2642                count_clone.set(count_clone.get() + 1);
2643            }
2644        });
2645        scene.set_visible(id, false);
2646        scene.set_visible(id, true);
2647        // Same value twice: only one fire.
2648        scene.set_visible(id, true);
2649        assert_eq!(count.get(), 2);
2650    }
2651
2652    #[test]
2653    fn colliding_items_returns_overlapping_set_excluding_self() {
2654        let mut scene = Scene::new();
2655        let a = scene.add_item(
2656            RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)),
2657            Point::new(10.0, 10.0),
2658        );
2659        let b = scene.add_item(
2660            RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)),
2661            Point::new(40.0, 10.0),
2662        );
2663        let c = scene.add_item(
2664            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2665            Point::new(500.0, 500.0),
2666        );
2667        let collisions = scene.colliding_items(a);
2668        assert!(collisions.contains(&b));
2669        assert!(!collisions.contains(&a));
2670        assert!(!collisions.contains(&c));
2671    }
2672
2673    #[test]
2674    fn items_along_path_finds_items_under_path_aabb() {
2675        let mut scene = Scene::new();
2676        let a = scene.add_item(
2677            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2678            Point::new(20.0, 20.0),
2679        );
2680        let b = scene.add_item(
2681            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2682            Point::new(200.0, 200.0),
2683        );
2684        let mut path = Path::new();
2685        path.move_to(Point::new(15.0, 15.0));
2686        path.line_to(Point::new(40.0, 40.0));
2687        let hits = scene.items_along_path(&path);
2688        assert!(hits.contains(&a));
2689        assert!(!hits.contains(&b));
2690    }
2691
2692    // -----------------------------------------------------------------
2693    // R6 — code-level fixes
2694    // -----------------------------------------------------------------
2695
2696    #[test]
2697    fn scene_remove_recursively_removes_descendants() {
2698        let mut scene = Scene::new();
2699        let parent = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)), Point::ZERO);
2700        let child = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)), Point::ZERO);
2701        let grandchild = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 5.0, 5.0)), Point::ZERO);
2702        scene.set_item_parent(child, Some(parent));
2703        scene.set_item_parent(grandchild, Some(child));
2704        assert_eq!(scene.entries.len(), 3);
2705        scene.remove(parent);
2706        // Parent + child + grandchild all gone.
2707        assert!(scene.scene_rect(parent).is_none());
2708        assert!(scene.scene_rect(child).is_none());
2709        assert!(scene.scene_rect(grandchild).is_none());
2710        assert_eq!(scene.entries.len(), 0);
2711    }
2712
2713    #[test]
2714    fn scene_orphan_promotes_children_to_root() {
2715        let mut scene = Scene::new();
2716        let parent = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)), Point::ZERO);
2717        let child = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)), Point::ZERO);
2718        scene.set_item_parent(child, Some(parent));
2719        scene.orphan(parent);
2720        // Child's parent is now None.
2721        assert_eq!(scene.parent_of(child), None);
2722        // Both still present.
2723        scene.remove(parent);
2724        assert!(scene.scene_rect(child).is_some());
2725    }
2726
2727    #[test]
2728    fn scene_orphan_rebuckets_detached_children() {
2729        // After orphaning, the spatial index must reflect children's
2730        // new scene-AABBs. Move a parent off-origin, attach a child,
2731        // then orphan — items_in_rect at the child's *child-local*
2732        // origin must now return it (because its scene_transform no
2733        // longer composes the parent's offset).
2734        let mut scene = Scene::new();
2735        let parent = scene.add_item(
2736            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2737            Point::new(500.0, 500.0),
2738        );
2739        let child = scene.add_item(
2740            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
2741            Point::new(0.0, 0.0),
2742        );
2743        scene.set_item_parent(child, Some(parent));
2744        // Pre-orphan: child sits at scene (500, 500).
2745        assert!(
2746            scene
2747                .items_in_rect(Rect::new(495.0, 495.0, 20.0, 20.0))
2748                .contains(&child)
2749        );
2750        scene.orphan(parent);
2751        // Post-orphan: child sits at scene (0, 0); the index must
2752        // reflect that — query at the new origin must hit, query at
2753        // the old origin must miss.
2754        assert!(
2755            scene
2756                .items_in_rect(Rect::new(-5.0, -5.0, 20.0, 20.0))
2757                .contains(&child)
2758        );
2759        assert!(
2760            !scene
2761                .items_in_rect(Rect::new(495.0, 495.0, 20.0, 20.0))
2762                .contains(&child)
2763        );
2764    }
2765
2766    #[test]
2767    fn add_item_dynamic_re_reads_bounds_on_refresh() {
2768        // An item whose `local_bounds` reads from a Cell. Mutating
2769        // the cell + calling refresh_dynamic_bounds must update the
2770        // entry and re-bucket the spatial index.
2771        use crate::item::{SceneItem, SceneItemPaintContext};
2772        use std::cell::Cell;
2773        use std::rc::Rc;
2774
2775        #[derive(Debug)]
2776        struct DynRect {
2777            bounds: Rc<Cell<Rect>>,
2778        }
2779        impl SceneItem for DynRect {
2780            fn local_bounds(&self) -> Rect {
2781                self.bounds.get()
2782            }
2783            fn set_local_bounds(&mut self, b: Rect) {
2784                self.bounds.set(b);
2785            }
2786            fn paint(&self, _: &mut teksilo_canvas::Canvas, _: &SceneItemPaintContext<'_>) {}
2787        }
2788
2789        let bounds = Rc::new(Cell::new(Rect::new(0.0, 0.0, 10.0, 10.0)));
2790        let mut scene = Scene::new();
2791        let id = scene.add_item_dynamic(
2792            DynRect {
2793                bounds: bounds.clone(),
2794            },
2795            Point::ZERO,
2796        );
2797        // Initially items_in_rect over the small AABB hits.
2798        assert!(
2799            scene
2800                .items_in_rect(Rect::new(0.0, 0.0, 50.0, 50.0))
2801                .contains(&id)
2802        );
2803        // Grow the bounds via the Cell — Scene's cached entry/index
2804        // is stale until refresh_dynamic_bounds runs.
2805        bounds.set(Rect::new(0.0, 0.0, 500.0, 500.0));
2806        scene.refresh_dynamic_bounds();
2807        // After refresh, the spatial index sees the larger AABB.
2808        assert!(
2809            scene
2810                .items_in_rect(Rect::new(400.0, 400.0, 10.0, 10.0))
2811                .contains(&id)
2812        );
2813    }
2814
2815    #[test]
2816    fn add_item_static_does_not_track_signal_changes() {
2817        // Counterpart to the dynamic test: a static item's bounds
2818        // are snapshotted at insert time; refresh_dynamic_bounds
2819        // does not re-read them.
2820        use crate::item::{SceneItem, SceneItemPaintContext};
2821        use std::cell::Cell;
2822        use std::rc::Rc;
2823
2824        #[derive(Debug)]
2825        struct DynRect {
2826            bounds: Rc<Cell<Rect>>,
2827        }
2828        impl SceneItem for DynRect {
2829            fn local_bounds(&self) -> Rect {
2830                self.bounds.get()
2831            }
2832            fn set_local_bounds(&mut self, b: Rect) {
2833                self.bounds.set(b);
2834            }
2835            fn paint(&self, _: &mut teksilo_canvas::Canvas, _: &SceneItemPaintContext<'_>) {}
2836        }
2837
2838        let bounds = Rc::new(Cell::new(Rect::new(0.0, 0.0, 10.0, 10.0)));
2839        let mut scene = Scene::new();
2840        let id = scene.add_item(
2841            DynRect {
2842                bounds: bounds.clone(),
2843            },
2844            Point::ZERO,
2845        );
2846        bounds.set(Rect::new(0.0, 0.0, 500.0, 500.0));
2847        scene.refresh_dynamic_bounds();
2848        // Static entry's spatial index unchanged.
2849        assert!(
2850            !scene
2851                .items_in_rect(Rect::new(400.0, 400.0, 10.0, 10.0))
2852                .contains(&id)
2853        );
2854    }
2855}