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//! // Each notification is a `SceneChange`: the change itself, plus the
49//! // transaction it belongs to, whose it was, and whether it counts as
50//! // history. See `teksilo_scene::SceneChange`.
51//! let _guard = scene.item_change_signal().observe(|notification| {
52//!     if let ItemChange::LocalPosChanged { id: _, old: _, new } = &notification.change {
53//!         let _ = new; // react to the new position
54//!     }
55//! });
56//!
57//! // Move the item; the observer fires and the spatial index updates.
58//! scene.set_local_pos(id, Point::new(100.0, 100.0));
59//! assert_eq!(scene.scene_pos(id), Some(Point::new(100.0, 100.0)));
60//! ```
61
62use std::cell::{Cell, RefCell};
63use std::collections::HashMap;
64use std::collections::HashSet;
65use std::collections::VecDeque;
66use std::rc::Rc;
67
68use crate::a11y::{A11yCategory, A11yGroup, A11yGroupBuilder, A11yGroupId, A11yNode, A11yRelation};
69use crate::flags::ItemFlags;
70use crate::index::{GridHashIndex, SpatialIndex};
71use crate::item::{AppearanceWrite, ItemId, SceneItem};
72use crate::item_handlers::SceneItemHandlerSet;
73use crate::journal::{
74    ChangeSource, EditJournal, EphemeralScope, HistoryMode, RemovalScope, Salvage, SceneChange,
75    SceneEdit,
76};
77use crate::magnet::{Magnet, MagnetId, MagnetRef, MagnetSnap, MagnetVerdict};
78use crate::pick::PaintKey;
79use crate::salvage::{
80    ItemA11yDecorations, RemovedItem, ReplaceItemError, ReplaceRejected, RestoreError,
81};
82use crate::shape::{ItemSelectionMode, ItemShape, SceneRegion};
83use crate::transform::local_to_parent;
84use teksilo_canvas::{Path, Point, Rect, Size, StrokeStyle, Transform2D, Vec2};
85use teksilo_core::color_prop::ColorProp;
86use teksilo_core::signal::Signal;
87use teksilo_core::widget::Widget;
88use teksilo_core::widget_id::WidgetId;
89
90/// A change to an item's state, fired through
91/// [`Scene::item_change_signal`] for every mutation. Apps observe to wire
92/// validation, persistence, telemetry, mirroring to a data layer, and other
93/// side effects. The model is "fire after the change has been applied" — by
94/// the time the observer sees the event, the Scene already reflects it, and
95/// (when the mutation came through a [`SceneModel`](crate::SceneModel)) the
96/// observer may freely read *and* write the scene back.
97///
98/// # Edits and derived notifications
99///
100/// Most variants are **edits**: one mutation, one variant, both sides of the
101/// value it replaced, and [`SceneTransactionRecord::edits`](crate::SceneTransactionRecord)
102/// carries them so a data layer can invert the transaction by walking them in
103/// reverse.
104///
105/// [`VisibilityChanged`](Self::VisibilityChanged) is not one. It is a
106/// *derived* notification — a convenience beside the
107/// [`FlagsChanged`](Self::FlagsChanged) that actually describes the mutation,
108/// emitted by both flag doors whenever `IS_VISIBLE` flips so a consumer need
109/// not diff two bitsets. It rides the signal and stays out of the record, so
110/// hiding a card is one edit whichever door hid it.
111/// [`is_edit`](Self::is_edit) is the test, for a consumer that counts changes
112/// off the signal and wants the same number the record has.
113///
114/// `#[non_exhaustive]`: this is the crate's outbound event vocabulary, matched
115/// by every observer, and it grows whenever the scene learns to report
116/// something new — `MeasuredSizeChanged` and `SizePolicyChanged` are the most
117/// recent. Without the attribute each such addition would stop a downstream
118/// `match` from compiling; with it, a consumer's wildcard arm keeps meaning
119/// "a change I do not act on".
120#[derive(Debug, Clone)]
121#[non_exhaustive]
122pub enum ItemChange {
123    /// `set_local_pos`: position in parent coords moved.
124    LocalPosChanged {
125        /// The item that moved.
126        id: ItemId,
127        /// Where it was, in its parent's frame.
128        old: Point,
129        /// Where it is now.
130        new: Point,
131    },
132    /// `set_local_bounds`: AABB in local coords changed.
133    LocalBoundsChanged {
134        /// The item that resized.
135        id: ItemId,
136        /// Its AABB in local coordinates before the write.
137        old: Rect,
138        /// Its AABB now.
139        new: Rect,
140    },
141    /// `set_measured_size`: a **derived** geometry write — the size an entry
142    /// under a non-[`Fixed`](crate::SizePolicy::Fixed)
143    /// [`SizePolicy`] measured from its widget, or one an
144    /// app computed from content it already owns.
145    ///
146    /// Separate from [`LocalBoundsChanged`](Self::LocalBoundsChanged) because
147    /// it means something different to every consumer of the channel:
148    ///
149    /// * It is **not an edit**. [`is_edit`](Self::is_edit) is `false` and the
150    ///   change is stamped [`ephemeral`](crate::SceneChange::ephemeral), so a
151    ///   history above the scene does not gain an undo step because a
152    ///   paragraph re-wrapped. A reflow is a function of content the document
153    ///   already holds; undoing it would mean undoing the width or the words.
154    /// * It is **not structural**. It is counted out of
155    ///   [`Scene::structural_version`], the way
156    ///   [`refresh_dynamic_bounds`](crate::Scene::refresh_dynamic_bounds)'s
157    ///   per-frame churn is, so a line wrap does not buy an AccessKit re-walk.
158    /// * The observing [`SceneView`](crate::SceneView) answers it with a
159    ///   **relayout**, not a rebuild: the geometry moved, nothing was
160    ///   materialised or reaped, and the delegate does not need re-running.
161    ///
162    /// An app mirroring scene geometry into its document should match
163    /// `LocalBoundsChanged` and ignore this one. An app that wants the measured
164    /// height persisted anyway (so a cold start opens at the right size without
165    /// waiting for a measurement) can read it and store it as a hint.
166    MeasuredSizeChanged {
167        /// The entry whose derived size changed.
168        id: ItemId,
169        /// Its AABB in local coordinates before the write.
170        old: Rect,
171        /// Its AABB now. Same origin; the size is what moved.
172        new: Rect,
173    },
174    /// `set_size_policy`: who decides this entry's box changed.
175    ///
176    /// An **edit**, unlike the [`MeasuredSizeChanged`](Self::MeasuredSizeChanged)
177    /// that follows it: "this note's height follows its words" is a decision
178    /// someone made about the document, the way a flag change is, and a history
179    /// above the scene should be able to put it back.
180    ///
181    /// It carries no geometry — the policy decides how the *next* layout pass
182    /// computes one. It exists at all because nothing else would tell a view
183    /// that the answer to a question it asks every pass has changed: a policy
184    /// written silently would take effect whenever something unrelated happened
185    /// to dirty the view, which in a test is never and in an app is worse —
186    /// unpredictable.
187    SizePolicyChanged {
188        /// The heavyweight entry whose policy changed.
189        id: ItemId,
190        /// Who decided its box before.
191        old: SizePolicy,
192        /// Who decides it now.
193        new: SizePolicy,
194    },
195    /// `set_transform`: local→parent transform changed.
196    TransformChanged {
197        /// The item whose basis changed.
198        id: ItemId,
199        /// Its local→parent transform before the write.
200        old: Transform2D,
201        /// Its local→parent transform now.
202        new: Transform2D,
203    },
204    /// `IS_VISIBLE` flipped, by whichever door flipped it — `set_visible`,
205    /// `set_flag` or a wholesale `set_flags`.
206    ///
207    /// A **derived notification**, always emitted immediately before the
208    /// [`FlagsChanged`](Self::FlagsChanged) that describes the same mutation.
209    /// It exists so a consumer that only cares about visibility need not diff
210    /// two [`ItemFlags`] bitsets, and it is deliberately not an edit:
211    /// [`is_edit`](Self::is_edit) is `false` for it and it never reaches a
212    /// transaction record, so one mutation is one recorded edit no matter which
213    /// door made it. (It used to be emitted by `set_flag` and not by
214    /// `set_flags`, which made hiding a card two recorded changes through one
215    /// door and one through the other.)
216    VisibilityChanged {
217        /// The item whose `IS_VISIBLE` bit flipped.
218        id: ItemId,
219        /// The bit's new value. Its own value, not the inherited one —
220        /// [`Scene::is_effectively_visible`] chains the parents.
221        visible: bool,
222    },
223    /// `set_flags` / `set_flag` changed the bitset. The edit that describes a
224    /// flag change, visibility included.
225    FlagsChanged {
226        /// The item whose flags changed.
227        id: ItemId,
228        /// The whole bitset before the write.
229        old: ItemFlags,
230        /// The whole bitset now. Diff the two to see which bits moved.
231        new: ItemFlags,
232    },
233    /// `set_opacity`: local opacity multiplier changed.
234    OpacityChanged {
235        /// The item whose opacity changed.
236        id: ItemId,
237        /// Its local multiplier before the write.
238        old: f32,
239        /// Its local multiplier now.
240        new: f32,
241    },
242    /// `set_z`: paint z-order changed.
243    ZChanged {
244        /// The item that restacked.
245        id: ItemId,
246        /// Its paint z before the write.
247        old: f32,
248        /// Its paint z now.
249        new: f32,
250    },
251    /// `set_layer`: the Under/Over paint band changed.
252    LayerChanged {
253        /// The item that changed band.
254        id: ItemId,
255        /// The band it was in.
256        old: SceneLayer,
257        /// The band it is in now.
258        new: SceneLayer,
259    },
260    /// `set_item_parent`: logical parent changed.
261    ParentChanged {
262        /// The child that was reparented.
263        id: ItemId,
264        /// Its logical parent before the write; `None` when it was a root.
265        old: Option<ItemId>,
266        /// Its logical parent now; `None` when it is now a root.
267        new: Option<ItemId>,
268    },
269    /// `remove`: item is gone.
270    Removed {
271        /// The item that is gone. Its contents travel the owning channel:
272        /// [`Scene::take`] hands the caller a [`RemovedItem`],
273        /// and [`Scene::remove`] routes one to the edit sink.
274        id: ItemId,
275    },
276    /// `add_item` / `add_widget`: item was inserted.
277    Added {
278        /// The item that was inserted.
279        id: ItemId,
280    },
281    /// `set_payload`: the type-erased payload of a `Delegated` heavyweight
282    /// entry was replaced. A `SceneView` rebuilds that entry's widget
283    /// (re-invokes its delegate) on the next build. Routed through
284    /// `emit_item_change`, so `mutation_seq` advances and the AT-walk gate
285    /// notices.
286    PayloadChanged {
287        /// The `Delegated` heavyweight entry whose data was replaced.
288        id: ItemId,
289        /// The payload every view built its widget from until now.
290        old: ItemPayload,
291        /// The payload every view will rebuild from.
292        new: ItemPayload,
293    },
294    /// `set_item_fill` / `set_item_stroke` / `clear_item_*`: a lightweight
295    /// item's paint-only appearance (fill / stroke colour or style) changed.
296    /// Never moves geometry, so the observing `SceneView` evicts the item's
297    /// cached frame and repaints **without** relayout or rebuild.
298    ///
299    /// It *can* move the item's hit **shape**, though: a stroked
300    /// [`PathItem`](crate::PathItem) derives its hit band from the stroke it
301    /// draws, so a view caching hit geometry must re-read this item's shape
302    /// even while skipping relayout.
303    AppearanceChanged {
304        /// The lightweight item that was recoloured.
305        id: ItemId,
306        /// Which of fill or stroke moved, and to what.
307        change: AppearanceChange,
308    },
309    /// `replace_item`: the lightweight item box at this id was swapped for a
310    /// different one, keeping the entry — position, transform, z, layer,
311    /// parent, flags, opacity, handlers, magnets and logical-AT decorations
312    /// all survive, and so does the [`ItemId`].
313    ///
314    /// One change, not `Removed` + `Added`, because nothing about the item's
315    /// identity changed. Consumers must treat it as a **geometry** change: the
316    /// new item carries its own `local_bounds` and its own hit shape.
317    ///
318    /// `old_bounds` / `new_bounds` are the entry's AABB either side of the
319    /// swap; the replaced box itself is returned to whoever called
320    /// [`Scene::replace_item`].
321    ItemReplaced {
322        /// The entry whose item box was swapped. Its [`ItemId`] is unchanged.
323        id: ItemId,
324        /// The entry's AABB before the swap.
325        old_bounds: Rect,
326        /// The entry's AABB after it, carried by the new box.
327        new_bounds: Rect,
328    },
329    /// `set_placement`: parent, z, local position and transform written
330    /// together as one property.
331    ///
332    /// The atomic form of the four separate mutators. A visually-stable
333    /// reparent ("drag this card into that group") is one edit here, where
334    /// `set_item_parent` + `set_local_pos` + `set_transform` is three edits,
335    /// three undo steps, and two intermediate states in which the item is
336    /// visibly in the wrong place.
337    PlacementChanged {
338        /// The item that was placed.
339        id: ItemId,
340        /// Parent, z, local position and transform before the write.
341        old: Placement,
342        /// All four now.
343        new: Placement,
344    },
345    /// `set_item_handlers` / `handlers_mut`: the item's handler set was
346    /// replaced or handed out for mutation.
347    ///
348    /// Always means at least "this item's handlers are no longer what you last
349    /// read", which is what a consumer caching them — the `SceneView`'s
350    /// dispatch snapshot — needs. Without it, the two handler mutators were the
351    /// only doors in the model that changed observable state silently.
352    ///
353    /// Whether it *also* describes the change is
354    /// [`replaced`](Self::HandlersChanged::replaced); see
355    /// [`HandlerReplacement`].
356    HandlersChanged {
357        /// The item whose handlers changed.
358        id: ItemId,
359        /// Both sides, when the door that fired this knew them.
360        ///
361        /// `Some` from [`Scene::set_item_handlers`], which is handed the new
362        /// set and can clone the old one out — so that door produces a
363        /// reversible edit like every other mutator.
364        ///
365        /// `None` from [`Scene::handlers_mut`], which fires on the way *in*: a
366        /// `&mut` borrow cannot report what the caller will do with it, and
367        /// there is no later moment the scene is told about. That call is an
368        /// invalidation notice and nothing finer, so it is **not** recorded as
369        /// an edit — a record claiming to carry both sides must not carry an
370        /// entry that carries neither. An app that wants its handler edits in
371        /// the history makes them through `set_item_handlers`.
372        ///
373        /// Boxed because a [`SceneItemHandlerSet`] is a wide struct and this is
374        /// the rarest variant: inline, two of them would set the size of every
375        /// [`ItemChange`], every [`SceneChange`] and every
376        /// queued notification — a per-pointer-sample cost for a setup-time
377        /// call.
378        replaced: Option<Box<HandlerReplacement>>,
379    },
380}
381
382/// The two sides of a [`Scene::set_item_handlers`], as they ride an
383/// [`ItemChange::HandlersChanged`].
384///
385/// `None` on either side means "no handler set at all", which is a state an
386/// item can be in and is distinct from an empty one.
387///
388/// [`SceneItemHandlerSet`] stores its closures as `Rc<dyn Fn>`, so carrying
389/// both sides is a handful of refcount bumps rather than a deep copy — which
390/// is why this could be carried and, until it was, simply was not.
391///
392/// `#[non_exhaustive]`: this crate has out-of-tree consumers.
393#[derive(Clone)]
394#[non_exhaustive]
395pub struct HandlerReplacement {
396    /// What the item had before. `None` when it had none.
397    pub old: Option<SceneItemHandlerSet>,
398    /// What it has now. `None` when the call cleared them.
399    pub new: Option<SceneItemHandlerSet>,
400}
401
402impl std::fmt::Debug for HandlerReplacement {
403    /// Hand-written: [`SceneItemHandlerSet`] holds `Rc<dyn Fn>` closures and is
404    /// not `Debug`, and [`ItemChange`] is. Reports which side was present,
405    /// which is the part of a handler swap that reads usefully in a log — the
406    /// same call the hand-written `Debug` for
407    /// [`RemovedItem`] makes about its magnets.
408    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409        fn side(set: &Option<SceneItemHandlerSet>) -> &'static str {
410            match set {
411                Some(_) => "SceneItemHandlerSet",
412                None => "none",
413            }
414        }
415        f.debug_struct("HandlerReplacement")
416            .field("old", &side(&self.old))
417            .field("new", &side(&self.new))
418            .finish()
419    }
420}
421
422impl ItemChange {
423    /// The item this change is about.
424    ///
425    /// Every variant names exactly one item, so an observer that only needs
426    /// *which* item moved need not match the whole enum. The fan-out's runaway
427    /// detector reads it too: it charges each delivery to the subject it is
428    /// about, so it needs that subject without caring what kind of change it
429    /// is. (Those per-subject counts name the culprit in the panic; the bound
430    /// that trips is a flat total — see [`CascadeBudget`].)
431    pub fn id(&self) -> ItemId {
432        match *self {
433            ItemChange::LocalPosChanged { id, .. }
434            | ItemChange::LocalBoundsChanged { id, .. }
435            | ItemChange::TransformChanged { id, .. }
436            | ItemChange::VisibilityChanged { id, .. }
437            | ItemChange::FlagsChanged { id, .. }
438            | ItemChange::OpacityChanged { id, .. }
439            | ItemChange::ZChanged { id, .. }
440            | ItemChange::LayerChanged { id, .. }
441            | ItemChange::ParentChanged { id, .. }
442            | ItemChange::Removed { id }
443            | ItemChange::Added { id }
444            | ItemChange::PayloadChanged { id, .. }
445            | ItemChange::AppearanceChanged { id, .. }
446            | ItemChange::ItemReplaced { id, .. }
447            | ItemChange::PlacementChanged { id, .. }
448            | ItemChange::HandlersChanged { id, .. }
449            | ItemChange::MeasuredSizeChanged { id, .. }
450            | ItemChange::SizePolicyChanged { id, .. } => id,
451        }
452    }
453
454    /// Whether this change is an **edit** — a mutation a transaction record
455    /// carries — rather than a *derived notification* the scene emits beside
456    /// one for a consumer's convenience.
457    ///
458    /// Three variants are not edits:
459    ///
460    /// * [`VisibilityChanged`](Self::VisibilityChanged), which always
461    ///   accompanies the [`FlagsChanged`](Self::FlagsChanged) that describes
462    ///   the same mutation.
463    /// * [`HandlersChanged`](Self::HandlersChanged) with no
464    ///   [`replaced`](Self::HandlersChanged::replaced) — the `handlers_mut`
465    ///   door, which cannot say what the handlers became.
466    /// * [`MeasuredSizeChanged`](Self::MeasuredSizeChanged), which is a size
467    ///   *derived* from content the document already holds, not a change to it.
468    ///
469    /// Filtering the change signal by this gives the same count the
470    /// transaction record has, which is what an app showing "N changes in this
471    /// edit" needs: hiding a card is one change through
472    /// [`Scene::set_visible`] and one through [`Scene::set_flags`].
473    pub fn is_edit(&self) -> bool {
474        !matches!(
475            self,
476            ItemChange::VisibilityChanged { .. }
477                | ItemChange::HandlersChanged { replaced: None, .. }
478                // A measured size is derived from content the document already
479                // owns. Recording it would put "the text re-wrapped" in the
480                // undo stack, one step per line break.
481                | ItemChange::MeasuredSizeChanged { .. }
482        )
483    }
484}
485
486/// A `Delegated` heavyweight entry's type-erased payload, as it rides an
487/// [`ItemChange::PayloadChanged`].
488///
489/// A newtype rather than a bare `Rc<dyn Any>` for one reason: `dyn Any` is not
490/// `Debug`, and [`ItemChange`] is. Cloning one is a refcount bump, never a deep
491/// copy, so carrying both sides of a payload swap costs two increments.
492#[derive(Clone)]
493pub struct ItemPayload(Rc<dyn std::any::Any>);
494
495impl std::fmt::Debug for ItemPayload {
496    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497        // `dyn Any` can report its concrete `TypeId` but not its name, and the
498        // payload is the app's own type — printing the pointer would be noise.
499        f.write_str("ItemPayload(<dyn Any>)")
500    }
501}
502
503impl ItemPayload {
504    /// The underlying handle, for a consumer that wants to downcast it itself.
505    pub fn as_rc(&self) -> &Rc<dyn std::any::Any> {
506        &self.0
507    }
508
509    /// Consume the wrapper for the handle.
510    pub fn into_rc(self) -> Rc<dyn std::any::Any> {
511        self.0
512    }
513
514    /// Downcast to the concrete payload type the app stored, or `None` when it
515    /// is something else.
516    pub fn downcast<T: 'static>(&self) -> Option<Rc<T>> {
517        self.0.clone().downcast::<T>().ok()
518    }
519}
520
521impl From<Rc<dyn std::any::Any>> for ItemPayload {
522    fn from(rc: Rc<dyn std::any::Any>) -> Self {
523        Self(rc)
524    }
525}
526
527/// Which paint-only appearance slot changed, with both sides of the write.
528///
529/// One variant per slot rather than one struct carrying both, because a write
530/// touches exactly one of them and a struct would have to invent a value for
531/// the other — which is the class of "plausible but wrong" data a journal must
532/// not contain.
533///
534/// `#[non_exhaustive]`: this crate has out-of-tree consumers, and an item may
535/// grow a third appearance slot.
536#[derive(Debug, Clone)]
537#[non_exhaustive]
538pub enum AppearanceChange {
539    /// [`Scene::set_item_fill`] / [`Scene::clear_item_fill`]. `None` on either
540    /// side means "no fill".
541    Fill {
542        /// The fill the item held before the write.
543        old: Option<ColorProp>,
544        /// The fill it holds now.
545        new: Option<ColorProp>,
546    },
547    /// [`Scene::set_item_stroke`] / [`Scene::clear_item_stroke`]. `None` on
548    /// either side means "no stroke".
549    Stroke {
550        /// The stroke the item held before the write.
551        old: Option<(ColorProp, StrokeStyle)>,
552        /// The stroke it holds now.
553        new: Option<(ColorProp, StrokeStyle)>,
554    },
555}
556
557/// Where an item sits, as **one** property: logical parent, paint z, position
558/// in the parent frame, and local to parent transform.
559///
560/// # Why these four together
561///
562/// [`Scene::set_item_parent`] deliberately does not rebase `local_pos` — a
563/// child's position is stated in its parent's frame, so adopting a new parent
564/// moves the item unless the caller compensates. A visually-stable reparent is
565/// therefore `set_item_parent` + `set_local_pos` + `set_transform`: three
566/// events, three things for a history to undo separately, and two intermediate
567/// states in which the item is visibly somewhere it never was.
568///
569/// Writing all four at once removes all three, and it is the half of Figma's
570/// "parent + fractional index as one property" that Teksilo was missing. The
571/// *other* half — O(1) reorder with no sibling rewrite — is already here: `z`
572/// is an `f32` and [`Scene::set_z`] writes one field, so
573/// [`Scene::z_between`] is a fractional insert.
574///
575/// `#[non_exhaustive]`: the crate hands this *to* consumer code and takes it
576/// back — and it is the one place a caller states a whole placement, so a fifth
577/// property joining the set is exactly the growth this guards. Build one with
578/// [`new`](Self::new).
579#[derive(Debug, Clone, Copy, PartialEq)]
580#[non_exhaustive]
581pub struct Placement {
582    /// Logical parent, or `None` for scene-rooted.
583    pub parent: Option<ItemId>,
584    /// Paint z-order within the band.
585    pub z: f32,
586    /// Origin of the item's local frame, in the parent's coordinates.
587    pub local_pos: Point,
588    /// Rotation / scale applied around the local origin.
589    pub transform: Transform2D,
590}
591
592impl Placement {
593    /// A placement stated field by field — the constructor
594    /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
595    pub fn new(parent: Option<ItemId>, z: f32, local_pos: Point, transform: Transform2D) -> Self {
596        Self {
597            parent,
598            z,
599            local_pos,
600            transform,
601        }
602    }
603}
604
605/// One queued notification, awaiting a drain by whoever opened the write
606/// scope that produced it.
607///
608/// The two change channels share a single FIFO so that a mutation touching
609/// item geometry **and** logical AT structure delivers in the order it
610/// happened, rather than splitting into two independently-ordered streams.
611///
612/// Deliberately covers only the two *notification* channels. The scene's
613/// constraint signals (`pan_axes` / `zoomable` / `pan_bounds` / `zoom_range`)
614/// are **state**, not events — [`Scene::current_pan_axes`] and friends read
615/// them back — so deferring their writes would make the scene lie about its
616/// own configuration inside an open write scope. They stay synchronous.
617#[derive(Debug, Clone)]
618pub(crate) enum PendingNotification {
619    /// One [`SceneChange`] — an [`ItemChange`] plus its transaction envelope —
620    /// through `item_change_signal`.
621    Item(SceneChange),
622    /// One `a11y_change_signal` bump, carrying the node the mutation was
623    /// *about* (groups / parents / relations / live / landmarks / categories /
624    /// magnets).
625    ///
626    /// The delivery itself is a bare counter bump — the signal carries no
627    /// payload, because an AT re-walk reads the whole logical tree back. The
628    /// node rides along for one reason: the runaway detector charges each
629    /// delivery to its subject, and an AT channel with no subject would put
630    /// every logical-AT notification in the scene on a single budget. On a
631    /// crate whose differentiator is per-item accessibility, that is the one
632    /// channel that must not be the bottleneck.
633    A11y(A11yNode),
634}
635
636/// What the runaway detector counts a delivery against: the **subject** a
637/// notification is about, on either channel.
638///
639/// Per *subject*, not per change kind or per channel: a runaway rewrites the
640/// same subject over and over, and which field of it — or which of the two
641/// signals — is not what makes it a runaway. Keying on `(subject, kind)` would
642/// let a two-field ping-pong (`set_local_pos` reacting to `ZChanged` and back)
643/// spend two budgets instead of one, and keying the whole logical-AT channel on
644/// one key would charge an app that maintains AT structure per item — the
645/// intended use — as if every item were the same subject.
646///
647/// So an `ItemChange` about item *i* and an `a11y_change_signal` bump about
648/// `A11yNode::Item(i)` share one budget: they are the same subject seen through
649/// two channels, and a cycle that ping-pongs between them is one runaway, not
650/// two half-runaways.
651#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
652enum NotifyKey {
653    /// One scene entry: every [`ItemChange`] about it, and every logical-AT
654    /// notification whose subject is `A11yNode::Item(id)`.
655    Item(ItemId),
656    /// One virtual AT group (`A11yNode::Group`).
657    A11yGroup(A11yGroupId),
658    /// One widget addressed directly in the logical AT tree
659    /// (`A11yNode::Widget`) — a descendant relocated into the scene's AT tree
660    /// without being a scene entry of its own.
661    A11yWidget(WidgetId),
662}
663
664impl From<A11yNode> for NotifyKey {
665    fn from(node: A11yNode) -> Self {
666        match node {
667            A11yNode::Item(id) => NotifyKey::Item(id),
668            A11yNode::Group(id) => NotifyKey::A11yGroup(id),
669            A11yNode::Widget(id) => NotifyKey::A11yWidget(id),
670        }
671    }
672}
673
674/// Observer-generated deliveries one drain may make before it is called a
675/// runaway. See [`CascadeBudget::total`].
676const DEFAULT_CASCADE_TOTAL: u64 = 100_000;
677
678/// The smallest budget a scene will hold. A budget of zero would trip on the
679/// first delivery an observer generated — i.e. it would forbid the reactive
680/// write-back this whole channel is advertised for — and its diagnostic would
681/// have no cascade to describe, so [`Scene::set_cascade_budget`] clamps to
682/// this instead of storing it. See [`CascadeBudget::new`].
683const MIN_CASCADE_TOTAL: u64 = 1;
684
685/// The runaway-detection budget for one scene's change fan-out — how much work
686/// **observers** may generate from one batch before the drain declares the
687/// cascade non-terminating and panics.
688///
689/// # Why a budget at all
690///
691/// The drain runs until its queue is empty, which is what lets an observer read
692/// the scene and write it back. An observer whose write never converges
693/// therefore never empties it, and — unlike `Signal::try_set`, which recurses
694/// and so blows the stack loudly on its own — this is an iterative loop whose
695/// only other exit is an empty queue. Unchecked it is a frozen UI thread with
696/// no diagnostic. The limit is enforced in **every** build profile for that
697/// reason.
698///
699/// "Never converges" is the precise condition, and it is not the same as
700/// "unguarded". The geometry mutators already suppress a write that changes
701/// nothing — [`Scene::set_local_pos`] returns without emitting when the
702/// position is unchanged — so an observer that keeps writing the *same*
703/// position settles on its own. A geometry cascade that does not settle is one
704/// computing a *different* value every time: an accumulating offset, a rounding
705/// drift, a spring with no rest state. The `set_a11y_*` mutators do not
706/// self-suppress; they bump on every call, so there an equality check before
707/// the call is a real guard.
708///
709/// # One trip condition, and why it is flat
710///
711/// **Total observer-generated deliveries in a single drain**, counted across
712/// both channels and every subject. Nothing else trips.
713///
714/// Two earlier shapes of this budget each fixed the previous one's false
715/// positive and bought a worse problem, and the third is why this one is flat:
716///
717/// - A cap on **rounds** aborted a legitimate 300-link settling chain, because a
718///   round is what was queued when it began, so an N-link chain costs N rounds.
719/// - A cap **per subject** moved the limit onto fan-in: a guarded relaxation
720///   over a hub passed at 4000 incident edges and aborted at 4200.
721/// - **Scaling** the per-subject cap with the scene's entry count fixed the
722///   fan-in false positive and destroyed the guard, because the bound it
723///   resolved grew with the model.
724///
725/// A flat total is the only one of the three that bounds a runaway to the same
726/// amount of work whatever the scene's size. Measured in release, changing only
727/// this budget: an unguarded write-back aborts after 100 001 deliveries in a
728/// 10 000-entry scene and in a 50 000-entry one alike, where the budget the
729/// scaled design resolved for those scenes (640 000 and 3 200 000) let the same
730/// loop run 6× and 32× longer — 3.10 s and 131 s against 520 ms and 3.95 s. A
731/// spawner allocates ~100 000 items before tripping at either size, against
732/// 640 002 and **3 200 002** under the scaled budget.
733///
734/// (The flat write-back's wall clock still grows with the scene, and that is the
735/// *app's* write, not the drain: `Scene::set_local_pos` re-buckets the moved
736/// subtree, which walks every entry. The identical runaway on the logical-AT
737/// channel, whose mutator does not, aborts in 4.8 ms at both sizes — that is
738/// what this loop itself costs. The budget bounds how many times an observer's
739/// write runs, not what one costs.)
740///
741/// The default also clears every legitimate shape this tier runs by more than an
742/// order of magnitude: a 4200-edge guarded aggregate is about 4 200 deliveries,
743/// and a 2000-link chain that re-places eight port magnets and refreshes three
744/// AT properties per link is about 24 000.
745///
746/// # What it still does not decide
747///
748/// "Guarded cascade that is genuinely enormous" and "unguarded write-back" are
749/// not distinguishable from the queue alone. This budget does not pretend
750/// otherwise: it draws the line where the shapes this tier actually runs stop,
751/// reports the subject with the highest delivery count so the diagnostic points
752/// somewhere, and
753/// [`SceneModel::set_cascade_budget`](crate::SceneModel::set_cascade_budget) is
754/// the supported answer for a graph that genuinely lives past it. Mechanism in
755/// the framework, policy in the consumer.
756///
757/// # What is exempt
758///
759/// The caller's own batch — everything already queued when the drain began — is
760/// not charged, however large. A bulk load, a ten-thousand-item teardown, or a
761/// scene-wide a11y re-tag inside one [`SceneWriteGuard`](crate::SceneWriteGuard)
762/// is finite by construction, so charging it would make the budget a cap on
763/// batch size instead — the mistake a plain delivery counter makes, whose fix is
764/// to raise it until it catches nothing. Cascade depth and the number of
765/// *distinct* subjects a cascade touches are not limited either.
766///
767/// ```
768/// use teksilo_scene::{CascadeBudget, SceneModel};
769///
770/// let model = SceneModel::new();
771/// model.set_cascade_budget(CascadeBudget::new(1_000_000));
772/// assert_eq!(model.cascade_budget().total, 1_000_000);
773/// ```
774///
775/// `#[non_exhaustive]`: a budget is built through
776/// [`CascadeBudget::new`](Self::new) or [`Default`], never by struct literal,
777/// so a second dimension (a per-subject cap, a depth limit) can be added
778/// without breaking a caller.
779#[derive(Debug, Clone, Copy, PartialEq, Eq)]
780#[non_exhaustive]
781pub struct CascadeBudget {
782    /// Observer-generated deliveries one drain may make before it panics.
783    ///
784    /// Flat: not scaled by the scene's entry count, by design — see the type's
785    /// own docs for the measurements that settled that. Default 100 000.
786    ///
787    /// Zero is not a usable budget and is clamped to 1 by
788    /// [`CascadeBudget::new`] and by
789    /// [`SceneModel::set_cascade_budget`](crate::SceneModel::set_cascade_budget).
790    pub total: u64,
791}
792
793impl Default for CascadeBudget {
794    fn default() -> Self {
795        Self {
796            total: DEFAULT_CASCADE_TOTAL,
797        }
798    }
799}
800
801impl CascadeBudget {
802    /// A budget of `total` observer-generated deliveries per drain, clamped up
803    /// to the smallest usable value.
804    ///
805    /// The clamp is here rather than at the trip so that a nonsensical budget is
806    /// rejected where it is written, instead of producing a panic whose numbers
807    /// describe no cascade that happened.
808    pub fn new(total: u64) -> Self {
809        Self {
810            total: total.max(MIN_CASCADE_TOTAL),
811        }
812    }
813}
814
815/// The scene's deferred-notification queue and the two signals it feeds.
816///
817/// Lives behind an `Rc` *beside* the scene rather than inside the borrow, so a
818/// drain runs holding **no** borrow on the [`Scene`] at all — which is exactly
819/// what lets an observer read the scene and write it back (see
820/// [`SceneModel::flush_changes`](crate::SceneModel::flush_changes)).
821pub(crate) struct ChangeQueue {
822    /// Queued notifications in emission order. A `VecDeque` because the drain
823    /// consumes from the front one at a time while observers append to the
824    /// back; see [`ChangeQueue::drain`] for why it is never emptied in bulk.
825    notifications: RefCell<VecDeque<PendingNotification>>,
826    /// `true` while a drain loop is running, so a drain re-entered from inside
827    /// an observer returns immediately and lets the outermost loop pick that
828    /// observer's changes up. Cleared by [`DrainGuard`], so an observer that
829    /// panics mid-drain cannot leave it stuck — a stuck flag would silence the
830    /// scene permanently, which is worse than the panic this whole mechanism
831    /// replaced.
832    draining: Cell<bool>,
833    item_signal: Signal<SceneChange>,
834    a11y_signal: Signal<u64>,
835}
836
837impl std::fmt::Debug for ChangeQueue {
838    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
839        f.debug_struct("ChangeQueue")
840            .field("queued", &self.notifications.borrow().len())
841            .field("draining", &self.draining.get())
842            .finish()
843    }
844}
845
846/// RAII clear of [`ChangeQueue::draining`]. Holds the queue `Rc` directly, so
847/// `Drop` never needs the `RefCell<Scene>` — which may be unavailable during an
848/// unwind out of a panicking observer.
849struct DrainGuard(Rc<ChangeQueue>);
850
851impl Drop for DrainGuard {
852    fn drop(&mut self) {
853        self.0.draining.set(false);
854    }
855}
856
857impl ChangeQueue {
858    fn new() -> Self {
859        Self {
860            notifications: RefCell::new(VecDeque::new()),
861            draining: Cell::new(false),
862            item_signal: Signal::new(SceneChange {
863                txn: crate::journal::TxnId::default(),
864                source: ChangeSource::Programmatic,
865                history: HistoryMode::Record,
866                ephemeral: false,
867                change: ItemChange::Added { id: ItemId(0) },
868            }),
869            a11y_signal: Signal::new(0),
870        }
871    }
872
873    pub(crate) fn item_signal(&self) -> Signal<SceneChange> {
874        self.item_signal.clone()
875    }
876
877    pub(crate) fn a11y_signal(&self) -> Signal<u64> {
878        self.a11y_signal.clone()
879    }
880
881    /// Whether anything is waiting to be delivered.
882    pub(crate) fn has_work(&self) -> bool {
883        !self.notifications.borrow().is_empty()
884    }
885
886    /// Whether a drain loop is running right now.
887    ///
888    /// Read by [`SceneModel::deliver_records`](crate::SceneModel::deliver_records)
889    /// so a committed transaction waits for the change fan-out to settle. An
890    /// observer that writes the scene commits its own transaction *inside* the
891    /// drain, and delivering records from there would hand the edit sink a
892    /// transaction some views had not reconciled from yet — which is the one
893    /// ordering this seam promises.
894    pub(crate) fn is_draining(&self) -> bool {
895        self.draining.get()
896    }
897
898    /// Append one notification to the back of the queue.
899    fn push(&self, notification: PendingNotification) {
900        self.notifications.borrow_mut().push_back(notification);
901    }
902
903    /// Deliver every queued notification, FIFO, until the queue is empty.
904    ///
905    /// Holds no borrow on anything across an observer call, so an observer may
906    /// read the scene and write it back; a write queues at the back and is
907    /// delivered by this same loop on a later round.
908    ///
909    /// # Rounds
910    ///
911    /// A round is exactly the notifications that were queued when it began.
912    /// Anything an observer appends while a round runs belongs to the next one,
913    /// which is what keeps delivery in emission order rather than depth-first.
914    ///
915    /// The round *count* is not a runaway signal and nothing here caps it: a
916    /// cascade that settles link by link — the chain-drag observer this channel
917    /// is advertised for — takes one round per link, so capping rounds means
918    /// capping how long a legitimate chain may be.
919    ///
920    /// # Termination
921    ///
922    /// The first round is the caller's own batch: finite by construction,
923    /// however large, so it is delivered free. From the second round on, every
924    /// delivery is work an observer queued from inside this drain, and is
925    /// counted against one flat bound — `budget.total`. Exceeding it panics,
926    /// naming the notification that did it and the subject charged most often.
927    /// The bound is unconditional in every build profile: unlike
928    /// `Signal::try_set`, which recurses and so blows the stack loudly on its
929    /// own, this is an iterative loop whose only other exit is an empty queue —
930    /// unchecked it would be a silent hang.
931    ///
932    /// Per-subject counts are still kept, because they are what lets the panic
933    /// point at a culprit — but they are **diagnostics only** and trip nothing.
934    /// A per-subject *limit* is a limit on fan-in (a guarded relaxation over a
935    /// hub aborted at 4200 incident edges against a flat 4096), and scaling it
936    /// with the scene makes the abort cost scale with the scene too. See
937    /// [`CascadeBudget`] for the measurements.
938    ///
939    /// # Unwind
940    ///
941    /// Each notification is removed from the queue immediately **before** it is
942    /// delivered, and nothing is held out of the queue in a local batch, so an
943    /// observer that panics costs exactly its own delivery: everything not yet
944    /// delivered is still in the queue, in order, and the next
945    /// [`flush_changes`](crate::SceneModel::flush_changes) delivers it. Draining
946    /// into a local `Vec` instead would let the unwind discard the untouched
947    /// tail with nothing left able to redeliver it — the scene would advance
948    /// with no notification, permanently, which is the corruption this
949    /// mechanism exists to prevent.
950    pub(crate) fn drain(self: &Rc<Self>, budget: CascadeBudget) {
951        if self.draining.get() {
952            // Re-entered from an observer: the outermost drain owns the queue
953            // and will pick up whatever that observer just emitted.
954            return;
955        }
956        self.draining.set(true);
957        // Clears the flag even if an observer panics out of the loop below.
958        let _guard = DrainGuard(Rc::clone(self));
959
960        // Observer-generated deliveries so far, and how they split across
961        // subjects. The total is the trip condition; the split is the
962        // diagnostic. The caller's own batch — round one — is counted in
963        // neither: it is finite by construction, so charging it would make the
964        // budget a cap on batch size, which is the mistake a plain delivery
965        // counter makes and the reason it gets raised until it catches nothing.
966        //
967        // Both are bounded by `budget.total`, so this bookkeeping — and the
968        // number of observer writes a runaway gets to make before it is
969        // stopped — is constant in the size of the scene.
970        let mut charged: HashMap<NotifyKey, u32> = HashMap::new();
971        let mut charged_total: u64 = 0;
972        let mut cascading = false;
973        loop {
974            let mut remaining = self.notifications.borrow().len();
975            if remaining == 0 {
976                break;
977            }
978            while remaining > 0 {
979                // Popped before delivery, and never held in a local buffer: an
980                // observer panic must cost its own delivery and nothing else.
981                let next = self.notifications.borrow_mut().pop_front();
982                let Some(notification) = next else {
983                    break;
984                };
985                remaining -= 1;
986                if cascading {
987                    // Charged *before* delivery, so the notification named in
988                    // the diagnostic is the one that broke the budget.
989                    Self::charge(
990                        &mut charged,
991                        &mut charged_total,
992                        &notification,
993                        budget.total,
994                    );
995                }
996                match notification {
997                    PendingNotification::Item(change) => self.item_signal.set(change),
998                    PendingNotification::A11y(_) => {
999                        self.a11y_signal.set(self.a11y_signal.get().wrapping_add(1));
1000                    }
1001                }
1002            }
1003            // Everything from here on was queued by an observer this drain ran.
1004            cascading = true;
1005        }
1006    }
1007
1008    /// Count one observer-generated delivery, and hand off to
1009    /// [`runaway`](Self::runaway) if that takes the drain past `limit`.
1010    ///
1011    /// Split out of [`drain`](Self::drain) so the hot path stays a counter bump
1012    /// and a hash-map bump, with the whole diagnostic — which walks every
1013    /// subject counted so far — behind a `#[cold]` call that only a trip
1014    /// reaches.
1015    fn charge(
1016        charged: &mut HashMap<NotifyKey, u32>,
1017        charged_total: &mut u64,
1018        notification: &PendingNotification,
1019        limit: u64,
1020    ) {
1021        *charged_total += 1;
1022        let key: NotifyKey = match notification {
1023            PendingNotification::Item(change) => NotifyKey::Item(change.id()),
1024            PendingNotification::A11y(node) => (*node).into(),
1025        };
1026        // Saturating: this count is a diagnostic, and a budget raised past
1027        // `u32::MAX` must not make the *counter* the thing that panics.
1028        let seen = charged.entry(key).or_insert(0);
1029        *seen = seen.saturating_add(1);
1030        if *charged_total > limit {
1031            Self::runaway(charged, *charged_total, notification, limit);
1032        }
1033    }
1034
1035    /// Panic for a cascade that passed its budget, describing **what was
1036    /// observed** and naming the likeliest cause as a likely cause.
1037    ///
1038    /// It cannot prove one: a guarded cascade that is genuinely enormous and an
1039    /// unguarded write-back are the same picture from the queue's side. So the
1040    /// message states the bound this scene enforced, how much was delivered
1041    /// against it, and the subject charged most often — the number that
1042    /// separates the two runaway shapes from each other, since a write-back
1043    /// piles up on one subject while a spawner leaves every count near 1 — and
1044    /// then says how to raise the bound.
1045    #[cold]
1046    #[inline(never)]
1047    fn runaway(
1048        charged: &HashMap<NotifyKey, u32>,
1049        delivered: u64,
1050        notification: &PendingNotification,
1051        limit: u64,
1052    ) -> ! {
1053        let (top_key, top_count) = charged
1054            .iter()
1055            .max_by_key(|(_, count)| **count)
1056            .map(|(key, count)| (*key, *count))
1057            .expect("every charged delivery records its subject, so a trip has at least one");
1058        panic!(
1059            "scene change fan-out did not settle: scene observers have generated \
1060             {delivered} notifications from one batch without the queue emptying, past \
1061             this scene's budget of {limit} (`CascadeBudget::total`). The last delivery \
1062             was {notification:?}, and the most-charged subject was {top_key:?}, with \
1063             {top_count} of the {delivered}. The likeliest cause is a scene observer \
1064             writing one subject back on every change. What to do depends on the \
1065             channel, because the two do not behave alike. The geometry mutators \
1066             already suppress a write that changes nothing (`set_local_pos` returns \
1067             without emitting when the position is unchanged), so a geometry trip means \
1068             the observer computes a *different* value each time — an accumulating \
1069             offset, a rounding drift, a spring that never reaches rest. Re-adding an \
1070             equality guard there changes nothing; make the reaction converge, or \
1071             record it in the observer and apply it after the mutation returns. The \
1072             `set_a11y_*` mutators do *not* self-suppress: they bump on every call, so \
1073             comparing before you call one is a real fix there. A most-charged count \
1074             near 1 means the other shape instead: an observer that keeps producing \
1075             *new* subjects, typically by adding or removing an item on every change. \
1076             If the cascade is genuinely this large and does settle, raise \
1077             `CascadeBudget::total` via `SceneModel::set_cascade_budget` — which only \
1078             postpones the freeze if the cycle does not terminate."
1079        )
1080    }
1081}
1082
1083/// Which paint band a lightweight [`SceneItem`] sits in, relative to
1084/// the heavyweight widget tier.
1085///
1086/// The band is the leading digit of [`PaintKey`], so it decides both what is
1087/// drawn on top and what the pointer picks — one rule, not two.
1088///
1089/// | band | rank | where it paints |
1090/// |------|------|-----------------|
1091/// | [`Under`](Self::Under) | [`RANK_UNDER`](crate::pick::RANK_UNDER) | in `SceneView::paint`, a backdrop beneath every card |
1092/// | [`Interleaved`](Self::Interleaved) | [`RANK_WIDGET`](crate::pick::RANK_WIDGET) | among the cards, ordered against them by `z` |
1093/// | [`Over`](Self::Over) | [`RANK_OVER`](crate::pick::RANK_OVER) | in `SceneView::post_paint`, above every card |
1094///
1095/// Within a band, [`set_z`](Scene::set_z) orders items among themselves; in
1096/// `Interleaved` that same `z` is compared against the **cards'** `z`.
1097///
1098/// # What `Interleaved` orders against, and what it does not
1099///
1100/// Cards, and only cards — the heavyweight widget children, which are what the
1101/// arena's child walk contains. It is **not** an ordering against the other two
1102/// bands, and in particular not against `Under`: the whole of the `Under` band
1103/// is painted inside `SceneView::paint`, which runs before the first child, so
1104/// every `Under` item is below every interleaved one no matter what the two
1105/// `z`s say. Likewise every `Over` item is above every interleaved one.
1106///
1107/// The consequence worth stating outright, because it surprises: in a scene
1108/// with **no heavyweight children at all**, an interleaved item is on top of
1109/// everything. There is nothing in the child walk for it to sit between, and
1110/// its `z` is compared against an empty set. A lightweight-only page that wants
1111/// ink under some of its content wants `Under` plus a `z`, or wants the content
1112/// it is drawing on to be cards.
1113///
1114/// # Why `Interleaved` is not free
1115///
1116/// `Under` and `Over` cost nothing: the render walker offers a paint position
1117/// before and after a node's child subtree, and those are the two bands. There
1118/// is no third position *between* two children, so an interleaved item is
1119/// painted by a node of its own that the view materialises and slots into the
1120/// z-sorted child list.
1121///
1122/// That node is **paint only**. It is
1123/// [`event_pass_through`](teksilo_core::widget_builder::WidgetBuilder::event_pass_through),
1124/// so the pointer still resolves through the lightweight hit snapshot exactly
1125/// as it does for the other two bands, and it is left out of
1126/// `SceneView::accessibility_children`, so the item keeps the same synthetic
1127/// [`SyntheticKind::SceneItem`](teksilo_core::accessibility::SyntheticKind)
1128/// node it had in `Under` — same off-screen culling, same reparenting, same
1129/// rotor category. Choosing this band changes what the item is painted
1130/// *between* and nothing else.
1131///
1132/// What it does cost is one arena node per interleaved item. Ink is the case
1133/// this exists for ("this stroke is above note A and below note B"), and a
1134/// page of ink wants a [`GroupItem`](crate::items::GroupItem) per layer rather
1135/// than a band per stroke — see `docs/ink.md`.
1136///
1137/// `#[non_exhaustive]`: this enum has now grown a variant once, and the reason
1138/// it could is that the render walker's two paint positions were never the
1139/// whole answer. A downstream `match` gets a `_` arm today rather than a break
1140/// the next time.
1141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1142#[non_exhaustive]
1143pub enum SceneLayer {
1144    /// Painted under the heavyweight widget children (the default).
1145    #[default]
1146    Under,
1147    /// Painted **among** the heavyweight widget children, ordered against them
1148    /// by `z` — and against nothing else: still above every `Under` item and
1149    /// below every `Over` one, and on top of everything in a scene that has no
1150    /// heavyweight children. See the type docs for that and for what it costs.
1151    Interleaved,
1152    /// Painted over the heavyweight widget children.
1153    Over,
1154}
1155
1156/// How an entry's `local_bounds` is decided each layout pass.
1157///
1158/// The default, [`Fixed`](Self::Fixed), is what every scene had before this
1159/// existed: the model owns both axes and nothing measures anything. The other
1160/// two hand one or both axes to the widget, which is the only thing that can
1161/// answer "how tall is this text at this width".
1162///
1163/// # Heavyweight only, and why
1164///
1165/// A lightweight [`SceneItem`] has no `layout_response` to
1166/// ask — it publishes its own AABB, and
1167/// [`Scene::add_item_dynamic`](crate::Scene::add_item_dynamic) already re-reads that
1168/// each build. A widget's height is not a value it publishes; it is the answer
1169/// to a question asked at a width. So the two mechanisms are not variants of
1170/// each other, and this one is refused for a lightweight entry rather than
1171/// silently doing nothing: [`Scene::set_size_policy`](crate::Scene::set_size_policy)
1172/// returns `false`.
1173///
1174/// # What is measured, and what is not
1175///
1176/// A view measures exactly the cards it gives a **non-zero size** — the ones
1177/// inside its viewport. Not the ones it keeps alive in the retention band, and
1178/// not the one the user is interacting with if the camera has left it behind:
1179/// those are laid out at `Size::ZERO`, so there is nothing a measurement could
1180/// be for. A card the camera has never shown keeps the size `add_widget_item`
1181/// was handed, and is corrected on the first pass that places it, in the same
1182/// frame it appears. That is the
1183/// contract `ListView::auto_item_height` already makes for rows: the number in
1184/// the model is an **estimate** until the thing has been on screen, and every
1185/// whole-scene query that reads it —
1186/// [`scene_rect_extent`](crate::Scene::scene_rect_extent), the spatial index, a marquee,
1187/// a minimap — reads the estimate for a card that has never been realised.
1188/// Give a plausible one; the alternative is measuring every card in the scene
1189/// on every pass, which is the cost the viewport cull exists to avoid.
1190///
1191/// # The measurement must be idempotent
1192///
1193/// A measured height feeds back into the model, and the model feeds the next
1194/// pass. A body whose `layout_response` answers differently for the same width
1195/// — because it mutates state, or because its natural height depends on the
1196/// height it was given — would oscillate forever. The view bounds that, and it
1197/// is careful about *what an oscillation is*: `100 → 120 → 100` is one when the
1198/// body is answering its own last answer, and is a user typing a character that
1199/// wraps and then deleting it when it is not. The two are indistinguishable
1200/// from the numbers, and freezing the second is the most ordinary editing
1201/// action there is.
1202///
1203/// What separates them is **who drove the pass**. A body's answer is a function
1204/// of its content and of the width it is offered; at a fixed width its content
1205/// cannot change without something dirtying the card — a `Signal` bound at
1206/// `Relayout`, a rebuild, a fresh node — whereas the view's own write dirties
1207/// only the view and what is above it. So on the pass a write causes, the card
1208/// reads clean, and a clean card that answers differently is reading back what
1209/// was written to it. The view takes the taller of the two answers, and after a
1210/// couple of such contradictions stops taking new ones for the rest of the
1211/// chain — which writes nothing, and a pass that writes nothing schedules no
1212/// successor. A non-idempotent body therefore costs a bounded number of passes
1213/// per external event rather than an unbounded number for ever, and an edited
1214/// one is never bounded at all. See
1215/// [`teksilo_core::widget::Widget::cacheable_layout`]
1216/// for the framework's own statement of the same obligation.
1217///
1218/// # A resize does not reach an axis the content owns
1219///
1220/// [`HeightForWidth`](Self::HeightForWidth) keeps its width, so the selection
1221/// frame resizes it horizontally and the words decide the rest; the vertical
1222/// half of a corner drag is neutralised in
1223/// [`Scene::apply_transform_delta`](crate::Scene::apply_transform_delta),
1224/// because a height written there is one the next pass measures straight back
1225/// over. [`Intrinsic`](Self::Intrinsic) owns neither axis, so
1226/// [`Scene::transformable_roots`](crate::Scene::transformable_roots) offers it
1227/// no resize handles at all. And a measurement taken while a gesture is
1228/// previewing a transform is placed but never written: the preview is
1229/// recomputed from the gesture's frozen start frame each sample, so a previewed
1230/// width that reached the model would be scaled again on the next one.
1231///
1232/// `#[non_exhaustive]`: this crate has out-of-tree consumers.
1233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1234#[non_exhaustive]
1235pub enum SizePolicy {
1236    /// The model owns both axes. Today's behaviour, and the default, so every
1237    /// existing scene is bit-identical.
1238    #[default]
1239    Fixed,
1240    /// The model owns the width; the height is **measured from the widget** at
1241    /// that width on every pass that lays it out.
1242    ///
1243    /// The right policy for text: a note's width is authored (you drag its edge)
1244    /// and its height follows the words. `scene_rect(id).height` becomes
1245    /// advisory for such an entry — it reports the last measured height, not the
1246    /// one `add_widget_item` was handed.
1247    HeightForWidth,
1248    /// Both axes are measured: the entry shrink-wraps its widget.
1249    ///
1250    /// For content that knows its own size on both axes — a pinned label, a
1251    /// badge, a fixed-aspect thumbnail. The rect passed to `add_widget_item`
1252    /// supplies only the position.
1253    Intrinsic,
1254}
1255
1256/// Which axes a [`SceneView`](crate::SceneView) is allowed to pan
1257/// along. Set on the [`Scene`] (not the View) because a given scene
1258/// model often makes sense at one orientation only — a horizontal
1259/// timeline, a vertical timeline, a fixed-extent diagram. All views
1260/// of the same scene inherit the constraint.
1261#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1262pub enum PanAxes {
1263    /// No user-driven pan in either axis. Programmatic
1264    /// [`SceneView::set_pan`](crate::SceneView::set_pan) /
1265    /// [`pan_to`](crate::SceneView::pan_to) become no-ops too.
1266    None,
1267    /// Pan only along X. Vertical scroll deltas pass through to
1268    /// ancestor scrollables.
1269    Horizontal,
1270    /// Pan only along Y. Horizontal scroll deltas pass through to
1271    /// ancestor scrollables.
1272    Vertical,
1273    /// Default: pan freely in both axes.
1274    #[default]
1275    Both,
1276}
1277
1278/// Reactive interaction-policy bundle owned by [`Scene`]. Apps
1279/// configure pan/zoom behaviour by writing to these signals; gesture
1280/// closures in [`SceneView`](crate::SceneView) read them live, so
1281/// runtime mode switches (e.g. a toolbar toggling pan locks) take
1282/// effect on the next event without rebuilding the view.
1283///
1284/// All four signals are exposed individually via [`Scene`] accessors
1285/// (`pan_axes_signal`, `pan_bounds_signal`, `zoom_range_signal`,
1286/// `zoomable_signal`). Per-(sub-)scene independence falls out of the
1287/// model: each nested `SceneView` carries its own `Scene` with its
1288/// own `SceneConstraints`.
1289///
1290/// View-level *tightening* overrides (`pan_bounds_override`,
1291/// `zoom_range_override`) layer on top per-`SceneView` — the
1292/// effective constraint is the intersection. Two views over the
1293/// same `Scene` can lock down independently; neither can loosen
1294/// what the `Scene` declares.
1295pub struct SceneConstraints {
1296    pan_axes: Signal<PanAxes>,
1297    /// Scene-coord rectangle that the visible viewport must stay
1298    /// inside. `None` (default) = unconstrained. When `Some(r)`,
1299    /// pan is clamped so the visible scene region overlaps the
1300    /// rect; when the viewport is bigger than the rect, the rect
1301    /// is centered.
1302    pan_bounds: Signal<Option<Rect>>,
1303    /// Inclusive `[min, max]` clamp on zoom factor. `None` =
1304    /// unconstrained from the `Scene` side (the `SceneView` may
1305    /// still impose its own range override).
1306    zoom_range: Signal<Option<std::ops::RangeInclusive<f32>>>,
1307    zoomable: Signal<bool>,
1308}
1309
1310impl SceneConstraints {
1311    fn new() -> Self {
1312        Self {
1313            pan_axes: Signal::new(PanAxes::Both),
1314            pan_bounds: Signal::new(None),
1315            zoom_range: Signal::new(None),
1316            zoomable: Signal::new(true),
1317        }
1318    }
1319
1320    /// Reactive pan-axes signal. Gesture handlers read live.
1321    pub fn pan_axes_signal(&self) -> Signal<PanAxes> {
1322        self.pan_axes.clone()
1323    }
1324    /// Reactive pan-bounds signal. `None` = unconstrained.
1325    pub fn pan_bounds_signal(&self) -> Signal<Option<Rect>> {
1326        self.pan_bounds.clone()
1327    }
1328    /// Reactive zoom-range signal. `None` = unconstrained from
1329    /// the Scene side.
1330    pub fn zoom_range_signal(&self) -> Signal<Option<std::ops::RangeInclusive<f32>>> {
1331        self.zoom_range.clone()
1332    }
1333    /// Reactive zoomable-on/off signal. Equivalent to a zero-width
1334    /// zoom_range — kept as a separate boolean for clarity and
1335    /// efficient short-circuit at gesture time.
1336    pub fn zoomable_signal(&self) -> Signal<bool> {
1337        self.zoomable.clone()
1338    }
1339}
1340
1341impl Default for SceneConstraints {
1342    fn default() -> Self {
1343        Self::new()
1344    }
1345}
1346
1347impl std::fmt::Debug for SceneConstraints {
1348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1349        f.debug_struct("SceneConstraints")
1350            .field("pan_axes", &self.pan_axes.get())
1351            .field("pan_bounds", &self.pan_bounds.get())
1352            .field("zoom_range", &self.zoom_range.get())
1353            .field("zoomable", &self.zoomable.get())
1354            .finish()
1355    }
1356}
1357
1358/// A single entry in a [`Scene`]. The two variants mirror the two
1359/// content tiers: heavyweight `Widget`s consumed into the arena at
1360/// build time, and lightweight `SceneItem`s painted directly from the
1361/// SceneView's paint walk.
1362pub(crate) struct SceneEntry {
1363    pub(crate) id: ItemId,
1364    /// Origin of the item's local coordinate frame, in **parent**
1365    /// coordinates (or scene coords if `parent == None`).
1366    pub(crate) local_pos: Point,
1367    /// Item's AABB in **local** coordinates. For lightweight items
1368    /// this is read once at insert time from `SceneItem::local_bounds`
1369    /// and tracked through `Scene::set_local_bounds`. For widgets it
1370    /// records the size requested at `add_widget` time, anchored at
1371    /// the origin: `Rect::new(0, 0, w, h)`.
1372    pub(crate) local_bounds: Rect,
1373    /// Optional rotation/scale applied around the local origin
1374    /// before translating by `local_pos`. Default identity.
1375    pub(crate) transform: Transform2D,
1376    pub(crate) kind: SceneEntryKind,
1377    /// Z-order for paint — higher values paint *later* (on top).
1378    /// Equal-z entries fall back to insertion order. Applies to **both**
1379    /// tiers: lightweight items sort within their band each paint, and
1380    /// heavyweight widget entries restack the SceneView's arena children
1381    /// by z on the next rebuild (see [`Scene::set_z`]).
1382    pub(crate) z: f32,
1383    /// Which lightweight paint band this item sits in relative to the
1384    /// heavyweight tier — [`SceneLayer::Under`] (default, backdrop),
1385    /// [`SceneLayer::Interleaved`] (among the cards, ordered against them by
1386    /// `z`) or [`SceneLayer::Over`] (foreground). Lightweight tier only;
1387    /// ignored for heavyweight widget entries (they paint via the arena).
1388    pub(crate) layer: SceneLayer,
1389    /// Logical parent. `None` means the item is rooted directly in
1390    /// the Scene. Composes coordinate frames: a child's `local_pos`
1391    /// is in the parent's local frame, and the child's
1392    /// `scene_transform` is the parent's `scene_transform` composed
1393    /// with the child's `local_to_parent`.
1394    pub(crate) parent: Option<ItemId>,
1395    /// Direct children, in the order they became children — the downward half
1396    /// of the parent pointer above, maintained by `push_entry` / `remove` /
1397    /// [`Scene::set_item_parent`] / [`Scene::set_placement`] / [`Scene::orphan`],
1398    /// the only five doors that can change a parent link.
1399    ///
1400    /// Kept rather than derived because every downward walk needs it and
1401    /// deriving it means scanning the whole model: `rebucket_subtree` used to
1402    /// rebuild a scene-wide `HashMap` on **each** `set_local_pos`, which made
1403    /// moving one leaf cost `O(entries)` — 67 µs per call at 50 000 items,
1404    /// against 80 ns for the same call through `set_z`, which re-buckets
1405    /// nothing. See `crates/teksilo-scene/tests/mutation_scaling_probe.rs`.
1406    ///
1407    /// The invariant, asserted by `parent_and_children_agree_through_every_door`
1408    /// in this module's tests: `entries[p].children.contains(&c)` **iff**
1409    /// `entries[c].parent == Some(p)`, with no duplicates.
1410    pub(crate) children: Vec<ItemId>,
1411    /// Per-item behavior flags. Read once from
1412    /// [`SceneItem::initial_flags`] at insert time, mutable through
1413    /// [`Scene::set_flags`] / [`Scene::set_flag`].
1414    pub(crate) flags: ItemFlags,
1415    /// Multiplicative opacity in `[0.0, 1.0]`. Composes through the
1416    /// parent chain: an item's `effective_opacity` is the product
1417    /// of every ancestor's opacity and its own.
1418    pub(crate) opacity: f32,
1419    /// Per-item event handlers, cursor and tooltip overrides.
1420    /// `None` until the app calls `Scene::set_item_handlers` /
1421    /// `Scene::handlers_mut`.
1422    pub(crate) handlers: Option<Box<SceneItemHandlerSet>>,
1423    /// Whether the item's `local_bounds` may change between
1424    /// build/layout passes (a signal-driven AABB). Static items
1425    /// (default) snapshot bounds at insert and only update through
1426    /// explicit [`Scene::set_local_bounds`]. Dynamic items added via
1427    /// [`Scene::add_item_dynamic`] have their `local_bounds` re-read
1428    /// each rebuild via [`Scene::refresh_dynamic_bounds`], with the
1429    /// spatial index re-bucketed when the value changes.
1430    pub(crate) dynamic_bounds: bool,
1431    /// Which axes of `local_bounds` the **widget** decides, for a heavyweight
1432    /// entry. [`SizePolicy::Fixed`] (the default) means none of them, which is
1433    /// what every entry was before the policy existed. See [`SizePolicy`].
1434    pub(crate) size_policy: SizePolicy,
1435}
1436
1437impl SceneEntry {
1438    /// The one place a `SceneEntry` is built.
1439    ///
1440    /// All three insertion paths — [`Scene::add_widget`],
1441    /// [`Scene::add_widget_delegated`] and the lightweight `insert_boxed` —
1442    /// come through here, so a field added to the struct cannot be set by one
1443    /// and silently missed by the others. (It could before: the two widget
1444    /// sites each carried their own struct literal, and only the lightweight
1445    /// one carried the comment saying it must not.)
1446    fn new(
1447        id: ItemId,
1448        local_pos: Point,
1449        local_bounds: Rect,
1450        kind: SceneEntryKind,
1451        flags: ItemFlags,
1452        dynamic_bounds: bool,
1453    ) -> Self {
1454        Self {
1455            id,
1456            local_pos,
1457            local_bounds,
1458            transform: Transform2D::identity(),
1459            kind,
1460            z: 0.0,
1461            layer: SceneLayer::Under,
1462            parent: None,
1463            children: Vec::new(),
1464            flags,
1465            opacity: 1.0,
1466            handlers: None,
1467            dynamic_bounds,
1468            size_policy: SizePolicy::Fixed,
1469        }
1470    }
1471}
1472
1473/// How a heavyweight `Widget` entry makes its instance available to a
1474/// `SceneView`. The two variants are the single-view and multi-view
1475/// production paths.
1476pub(crate) enum WidgetSource {
1477    /// Single-view sugar ([`Scene::add_widget`]). The first `SceneView` to
1478    /// build drains the `Option` via `take()`; subsequent views (sharing the
1479    /// same [`SceneModel`](crate::SceneModel)) find `None` and produce no
1480    /// arena child for this entry. Use [`Scene::add_widget_delegated`] +
1481    /// a view delegate for multi-view content.
1482    Once(Option<Box<dyn Widget>>),
1483    /// Multi-view path ([`Scene::add_widget_delegated`], surfaced as
1484    /// [`SceneModel::add_widget_item`](crate::SceneModel::add_widget_item)).
1485    /// Each view calls its own delegate with this type-erased `payload`
1486    /// to build a fresh `Widget` instance. The payload is `Rc` so a view
1487    /// can clone it out of a model borrow before invoking the delegate.
1488    Delegated { payload: Rc<dyn std::any::Any> },
1489}
1490
1491pub(crate) enum SceneEntryKind {
1492    /// A heavyweight `Widget` materialised into the arena, via either the
1493    /// single-view `Once` slot or the multi-view `Delegated` payload.
1494    Widget(WidgetSource),
1495    /// A lightweight `SceneItem` that lives in the scene
1496    /// permanently; painted by the SceneView's paint walk.
1497    Item(Box<dyn SceneItem>),
1498}
1499
1500/// The data model behind a `SceneView`: a flat list of entries in a
1501/// parent-relative scene-graph plus a [`SpatialIndex`] for rectangular
1502/// queries.
1503///
1504/// The Scene itself does no rendering — it's a passive container the view
1505/// reads from at build / place / paint time. Mutations (`add_widget`,
1506/// `add_item`, `set_local_pos`, `set_transform`, `set_local_bounds`, `remove`)
1507/// update the spatial index in lockstep, so `items_in_rect`, `item_at`, and
1508/// SceneView's viewport-cull path are all `O(visible)` instead of `O(N)`. When
1509/// a parent's `local_pos` or `transform` changes, every descendant's
1510/// scene-AABB shifts; the Scene re-buckets the entire subtree.
1511///
1512/// In practice most callers operate on a [`SceneModel`](crate::SceneModel)
1513/// handle (`Rc<RefCell<Scene>>` with `&self` mutators) rather than a bare
1514/// `Scene`. Prefer `SceneModel` for any widget or handler that needs to share
1515/// the scene across multiple owners.
1516pub struct Scene {
1517    pub(crate) entries: Vec<SceneEntry>,
1518    /// `ItemId` → index into `entries` for O(1) lookup.
1519    entry_index: HashMap<ItemId, usize>,
1520    index: Box<dyn SpatialIndex>,
1521
1522    /// Every heavyweight (`Widget`) entry, in entry order.
1523    ///
1524    /// Kept rather than filtered out of `entries` on demand because a
1525    /// `SceneView`'s `build()` asks for it three times — the `Once` drain, the
1526    /// `Delegated` payloads, and the child-ordering / orphan-reap set — and
1527    /// `build()` runs on **every** model mutation, so a scene of 20 000
1528    /// lightweight items paid three full scans to re-materialise nothing. The
1529    /// three are now proportional to the number of cards, which is what they
1530    /// were always about.
1531    heavyweight: Vec<ItemId>,
1532    /// Every entry added through [`add_item_dynamic`](Scene::add_item_dynamic),
1533    /// in entry order — the ones
1534    /// [`refresh_dynamic_bounds`](Scene::refresh_dynamic_bounds) re-reads each
1535    /// build. Same reason as `heavyweight`: a scene with no dynamic items used
1536    /// to pay a full scan and an allocation per build to discover that.
1537    dynamic: Vec<ItemId>,
1538
1539    /// User-declared scene extent. `None` means "auto-compute from
1540    /// items each query". Set via [`Scene::set_scene_rect`]. Used
1541    /// by [`SceneView::adopt_scene_size`](crate::SceneView::adopt_scene_size).
1542    /// (Distinct from `constraints.pan_bounds` which clamps the
1543    /// visible viewport.)
1544    user_scene_rect: Option<Rect>,
1545    /// Reactive interaction policy: pan axes, pan bounds, zoom
1546    /// range, zoomable on/off. Apps mutate via the dedicated
1547    /// `Scene::pan_axes` / `set_pan_bounds` / `set_zoom_range` /
1548    /// `zoomable` methods (still classic mutator shape) or read
1549    /// the underlying signals via the `*_signal` accessors for
1550    /// live observation.
1551    constraints: SceneConstraints,
1552    /// The deferred-notification queue plus the two signals it feeds
1553    /// (`item_change_signal` and `a11y_change_signal`).
1554    ///
1555    /// Behind an `Rc` so a drain can run with **no** borrow on this `Scene`,
1556    /// which is what lets an observer read the scene and write it back. Both
1557    /// notification channels share the one queue, so a mutation touching item
1558    /// geometry *and* logical AT structure still delivers in the order it
1559    /// happened rather than splitting into two independently-ordered streams.
1560    pending: Rc<ChangeQueue>,
1561    /// The transaction state machine, the edit sink, and the queue of
1562    /// committed [`SceneTransactionRecord`](crate::SceneTransactionRecord)s
1563    /// awaiting delivery.
1564    ///
1565    /// Behind an `Rc` beside this `Scene` for the same reason `pending` is: the
1566    /// sink is invoked with **no** borrow on the scene, which is what lets it
1567    /// read the scene and write it back. See [`crate::journal`].
1568    journal: Rc<EditJournal>,
1569    /// Monotonic counter of *every* model mutation — item geometry / visibility
1570    /// / structure (each [`ItemChange`] fire) **and** logical-AT structure (each
1571    /// `bump_a11y_change`). Read via [`Scene::mutation_version`], or as
1572    /// [`Scene::structural_version`] with the per-frame dynamic-bounds churn
1573    /// subtracted out — which is the form `SceneView` gates its (expensive)
1574    /// AccessKit re-walk on. A plain `Cell` because the bump path
1575    /// (`bump_mutation`) is `&self` (shared with `bump_a11y_change`).
1576    mutation_seq: Cell<u64>,
1577
1578    /// Per-frame dynamic-bounds churn, subtracted out of
1579    /// [`mutation_version`](Scene::mutation_version) by
1580    /// [`structural_version`](Scene::structural_version).
1581    ///
1582    /// Every `LocalBoundsChanged` that [`refresh_dynamic_bounds`](Scene::refresh_dynamic_bounds)
1583    /// emits is counted here as well as in `mutation_seq`, so a consumer gating
1584    /// an expensive rebuild (the `SceneView`'s AccessKit re-walk) can exclude
1585    /// that churn by *name* rather than by bracketing the call with two version
1586    /// snapshots — which silently swallows anything an observer wrote during
1587    /// the refresh's fan-out.
1588    dynamic_seq: Cell<u64>,
1589
1590    /// How many [`ItemChange`]s this scene has **emitted**, ever.
1591    ///
1592    /// Distinct from [`mutation_version`](Scene::mutation_version), which also
1593    /// counts logical-AT structure bumps: this one counts exactly the events
1594    /// that travel `item_change_signal`, so a consumer that keeps a cache
1595    /// invalidated *by* that signal can ask two different questions and get two
1596    /// honest answers — "is my cache still current?" (compare this counter with
1597    /// the one I last refreshed at) and "did I see every change in between?"
1598    /// (compare it with my own delivery count). The second is what makes the
1599    /// cache safe: any disagreement means an emission this consumer never saw,
1600    /// and the answer is a full rebuild.
1601    ///
1602    /// Bumped at **emission**, not delivery, so it is correct even while the
1603    /// fan-out is deferred inside an open write scope. Wraps; compare for
1604    /// equality, not ordering.
1605    item_change_seq: Cell<u64>,
1606
1607    // --- deferred change fan-out -------------------------------------
1608    /// Number of write scopes currently open on this scene.
1609    ///
1610    /// Raised for the lifetime of the `RefMut` held by
1611    /// [`SceneModel`](crate::SceneModel)'s mutators and by
1612    /// [`SceneWriteGuard`](crate::SceneWriteGuard), so `emit_item_change`
1613    /// queues instead of fanning out under a live `borrow_mut()`. Zero for a
1614    /// bare `&mut Scene`, which keeps the historical synchronous behaviour —
1615    /// see the "Notification timing" section on [`Scene::item_change_signal`].
1616    ///
1617    /// A counter rather than a flag: a scope is only ever *closed* by the guard
1618    /// that opened it, so nesting (were a second door ever to open one inside
1619    /// another) must not clear the outer scope early.
1620    defer_depth: Cell<u32>,
1621
1622    /// How much observer-generated work a single drain of this scene's queue
1623    /// may do before it is declared a runaway. Read once on the way into a
1624    /// drain and not again, so an observer cannot enlarge the drain it is
1625    /// already inside; a `Cell` because that read sits on the `&self` notify
1626    /// path.
1627    cascade_budget: Cell<CascadeBudget>,
1628
1629    // --- logical AT structure ----------------------------------------
1630    pub(crate) a11y_groups: Vec<A11yGroup>,
1631    pub(crate) a11y_group_index: HashMap<A11yGroupId, usize>,
1632    pub(crate) a11y_parents: HashMap<A11yNode, A11yNode>,
1633    pub(crate) a11y_relations: Vec<(A11yNode, A11yRelation, A11yNode)>,
1634    pub(crate) a11y_live: HashMap<A11yNode, accesskit::Live>,
1635    pub(crate) a11y_landmarks: HashMap<A11yNode, accesskit::Role>,
1636    pub(crate) a11y_categories: HashMap<A11yNode, Vec<A11yCategory>>,
1637
1638    // --- magnetism ---------------------------------------------------
1639    /// Magnets attached to each item, in insertion order. Kept in a
1640    /// side map (not on `SceneEntry`) so the magnet subsystem is
1641    /// modular — the same shape as the logical-AT maps above.
1642    magnets: HashMap<ItemId, Vec<(MagnetId, Magnet)>>,
1643    /// Reverse lookup `MagnetId -> owning ItemId` for O(1) resolution
1644    /// of a magnet's owner (and cleanup on `remove_magnet`).
1645    magnet_owner: HashMap<MagnetId, ItemId>,
1646
1647    // --- geometry constraint -----------------------------------------
1648    /// The document's standing geometry rule — snap-to-grid, axis lock, page
1649    /// clamp — consulted by every **user-driven** gesture before anything is
1650    /// applied, and by no mutator at all. See [`crate::ProposedChange`].
1651    geometry_constraint: Option<crate::constrain::GeometryConstraint>,
1652    /// Whether a geometry-constraint call is running on this scene right now.
1653    ///
1654    /// Read by [`SceneModel::write_guard`](crate::SceneModel::write_guard) when
1655    /// its borrow fails, so a constraint that tries to *write* the scene gets a
1656    /// diagnostic naming the hook rather than `RefCell already borrowed`.
1657    ///
1658    /// A **flag**, not a counter, because the state it tracks is two-valued:
1659    /// `ConstraintCall::new` asserts that no constraint is already running (a
1660    /// constraint asking for another one would recurse without end, and the
1661    /// assertion says so), so nesting is refused rather than counted. A counter
1662    /// here would be a type promising a depth the code cannot reach — and
1663    /// `saturating_sub` would quietly absorb an unbalanced exit instead of
1664    /// surfacing it.
1665    constraint_running: Cell<bool>,
1666}
1667
1668impl Scene {
1669    /// An empty scene with the default [`GridHashIndex`].
1670    pub fn new() -> Self {
1671        Self::with_index(Box::new(GridHashIndex::default()))
1672    }
1673
1674    /// An empty scene with a custom [`SpatialIndex`].
1675    pub fn with_index(index: Box<dyn SpatialIndex>) -> Self {
1676        Self {
1677            entries: Vec::new(),
1678            entry_index: HashMap::new(),
1679            index,
1680            heavyweight: Vec::new(),
1681            dynamic: Vec::new(),
1682            user_scene_rect: None,
1683            constraints: SceneConstraints::new(),
1684            pending: Rc::new(ChangeQueue::new()),
1685            journal: Rc::new(EditJournal::new()),
1686            mutation_seq: Cell::new(0),
1687            dynamic_seq: Cell::new(0),
1688            item_change_seq: Cell::new(0),
1689            defer_depth: Cell::new(0),
1690            cascade_budget: Cell::new(CascadeBudget::default()),
1691            a11y_groups: Vec::new(),
1692            a11y_group_index: HashMap::new(),
1693            a11y_parents: HashMap::new(),
1694            a11y_relations: Vec::new(),
1695            a11y_live: HashMap::new(),
1696            a11y_landmarks: HashMap::new(),
1697            a11y_categories: HashMap::new(),
1698            magnets: HashMap::new(),
1699            magnet_owner: HashMap::new(),
1700            geometry_constraint: None,
1701            constraint_running: Cell::new(false),
1702        }
1703    }
1704
1705    // -----------------------------------------------------------------
1706    // Insertion
1707    // -----------------------------------------------------------------
1708
1709    /// Place a heavyweight `Widget` at `local_rect`'s origin, sized
1710    /// `local_rect.size`. The rect is interpreted as
1711    /// `(local_pos = local_rect.origin, local_bounds = (0, 0, w, h))`.
1712    /// Returns the [`ItemId`] for later mutation. The widget is
1713    /// consumed at SceneView build time and added to the arena.
1714    pub fn add_widget<W: Widget + 'static>(&mut self, widget: W, local_rect: Rect) -> ItemId {
1715        let id = ItemId::next();
1716        let local_pos = Point::new(local_rect.x, local_rect.y);
1717        let local_bounds = Rect::new(0.0, 0.0, local_rect.width, local_rect.height);
1718        let entry = SceneEntry::new(
1719            id,
1720            local_pos,
1721            local_bounds,
1722            SceneEntryKind::Widget(WidgetSource::Once(Some(Box::new(widget)))),
1723            ItemFlags::default(),
1724            false,
1725        );
1726        self.push_entry(entry)
1727    }
1728
1729    /// Multi-view heavyweight insertion: store a type-erased `payload`; each
1730    /// [`SceneView`](crate::SceneView) builds its own instance via its
1731    /// delegate. Surfaced publicly as
1732    /// [`SceneModel::add_widget_item`](crate::SceneModel::add_widget_item).
1733    pub(crate) fn add_widget_delegated(
1734        &mut self,
1735        payload: Rc<dyn std::any::Any>,
1736        local_rect: Rect,
1737    ) -> ItemId {
1738        let id = ItemId::next();
1739        let local_pos = Point::new(local_rect.x, local_rect.y);
1740        let local_bounds = Rect::new(0.0, 0.0, local_rect.width, local_rect.height);
1741        let entry = SceneEntry::new(
1742            id,
1743            local_pos,
1744            local_bounds,
1745            SceneEntryKind::Widget(WidgetSource::Delegated { payload }),
1746            ItemFlags::default(),
1747            false,
1748        );
1749        self.push_entry(entry)
1750    }
1751
1752    /// Replace the type-erased payload of a `Delegated` heavyweight entry and
1753    /// fire [`ItemChange::PayloadChanged`].
1754    ///
1755    /// # Panics
1756    ///
1757    /// Panics if `id` is unknown, refers to a `Once` widget entry, or refers to
1758    /// a lightweight item. These are all caller-side precondition violations:
1759    /// the caller obtained `id` from `add_widget_item` and is responsible for
1760    /// only passing it back to `set_payload` while the entry is alive.
1761    pub(crate) fn set_payload(&mut self, id: ItemId, payload: Rc<dyn std::any::Any>) {
1762        let Some(&pos) = self.entry_index.get(&id) else {
1763            panic!("set_payload: unknown ItemId {id:?}");
1764        };
1765        let old = match &mut self.entries[pos].kind {
1766            SceneEntryKind::Widget(WidgetSource::Delegated { payload: slot }) => {
1767                std::mem::replace(slot, payload.clone())
1768            }
1769            _ => panic!("set_payload: {id:?} is not a Delegated widget entry"),
1770        };
1771        // Entry borrow dropped above; `emit_item_change` is `&self`.
1772        self.emit_item_change(ItemChange::PayloadChanged {
1773            id,
1774            old: ItemPayload::from(old),
1775            new: ItemPayload::from(payload),
1776        });
1777    }
1778
1779    /// The current type-erased payload of a `Delegated` heavyweight entry.
1780    /// `None` for unknown ids, `Once` widget entries, and lightweight items.
1781    pub(crate) fn payload(&self, id: ItemId) -> Option<Rc<dyn std::any::Any>> {
1782        let pos = *self.entry_index.get(&id)?;
1783        match &self.entries[pos].kind {
1784            SceneEntryKind::Widget(WidgetSource::Delegated { payload }) => Some(payload.clone()),
1785            _ => None,
1786        }
1787    }
1788
1789    /// Drain every still-pending `Once` heavyweight widget, in entry order.
1790    /// Each is `take()`n from its slot, so a second `SceneView` over the same
1791    /// model returns nothing for it — `Once` widgets are single-view. Called
1792    /// by `SceneView::build`.
1793    pub(crate) fn drain_all_once(&mut self) -> Vec<(ItemId, Box<dyn Widget>)> {
1794        let mut out = Vec::new();
1795        for i in 0..self.heavyweight.len() {
1796            let id = self.heavyweight[i];
1797            let Some(&pos) = self.entry_index.get(&id) else {
1798                continue;
1799            };
1800            if let SceneEntryKind::Widget(WidgetSource::Once(pending)) = &mut self.entries[pos].kind
1801                && let Some(w) = pending.take()
1802            {
1803                out.push((id, w));
1804            }
1805        }
1806        out
1807    }
1808
1809    /// `(id, payload)` for every `Delegated` heavyweight entry, in entry order.
1810    /// The payload `Rc` is cloned so the caller can drop the model borrow before
1811    /// invoking its delegate (the reentrancy contract). Called by `SceneView::build`.
1812    pub(crate) fn delegated_payloads(&self) -> Vec<(ItemId, Rc<dyn std::any::Any>)> {
1813        self.heavyweight
1814            .iter()
1815            .filter_map(|id| {
1816                let pos = *self.entry_index.get(id)?;
1817                match &self.entries[pos].kind {
1818                    SceneEntryKind::Widget(WidgetSource::Delegated { payload }) => {
1819                        Some((*id, payload.clone()))
1820                    }
1821                    _ => None,
1822                }
1823            })
1824            .collect()
1825    }
1826
1827    /// Ids of every heavyweight `Widget` entry (`Once` and `Delegated`), in
1828    /// entry order. Used by `SceneView::build` for child ordering and the
1829    /// orphan-reap live-set.
1830    pub(crate) fn heavyweight_ids(&self) -> Vec<ItemId> {
1831        self.heavyweight.clone()
1832    }
1833
1834    /// Place a lightweight [`SceneItem`] at `local_pos`. The item's
1835    /// `local_bounds` and `initial_flags` are read once at insert
1836    /// time. The item is **not** added to the arena — it's painted
1837    /// directly from `SceneView::paint`.
1838    pub fn add_item<I: SceneItem + 'static>(&mut self, item: I, local_pos: Point) -> ItemId {
1839        self.add_item_inner(item, local_pos, false)
1840    }
1841
1842    /// Like [`add_item`](Self::add_item) but flags the entry as
1843    /// having signal-driven `local_bounds`. The Scene re-reads
1844    /// `item.local_bounds()` each rebuild via
1845    /// [`refresh_dynamic_bounds`](Self::refresh_dynamic_bounds) — the
1846    /// SceneView calls that at the start of every build pass. The
1847    /// spatial index gets re-bucketed when the read-back differs
1848    /// from the cached value, so `items_in_rect` / hit-test stay
1849    /// correct without app-side `set_local_bounds` plumbing.
1850    ///
1851    /// Use only when the bounds genuinely depend on a `Signal<T>`
1852    /// the item reads in `local_bounds`. Static items pay an
1853    /// unnecessary per-rebuild bounds read otherwise; prefer
1854    /// [`add_item`](Self::add_item) for the common case.
1855    pub fn add_item_dynamic<I: SceneItem + 'static>(
1856        &mut self,
1857        item: I,
1858        local_pos: Point,
1859    ) -> ItemId {
1860        self.add_item_inner(item, local_pos, true)
1861    }
1862
1863    fn add_item_inner<I: SceneItem + 'static>(
1864        &mut self,
1865        item: I,
1866        local_pos: Point,
1867        dynamic_bounds: bool,
1868    ) -> ItemId {
1869        self.insert_boxed(Box::new(item), local_pos, dynamic_bounds)
1870    }
1871
1872    /// The lightweight-entry insertion path, shared by the generic
1873    /// [`add_item`](Self::add_item) / [`add_item_dynamic`](Self::add_item_dynamic)
1874    /// path (via `add_item_inner`) and the boxed-`dyn`
1875    /// [`add_boxed_item`](Self::add_boxed_item) path. The entry itself is built
1876    /// by [`SceneEntry::new`], which the two heavyweight paths share too.
1877    fn insert_boxed(
1878        &mut self,
1879        item: Box<dyn SceneItem>,
1880        local_pos: Point,
1881        dynamic_bounds: bool,
1882    ) -> ItemId {
1883        let id = ItemId::next();
1884        let local_bounds = item.local_bounds();
1885        let flags = item.initial_flags();
1886        let entry = SceneEntry::new(
1887            id,
1888            local_pos,
1889            local_bounds,
1890            SceneEntryKind::Item(item),
1891            flags,
1892            dynamic_bounds,
1893        );
1894        self.push_entry(entry)
1895    }
1896
1897    /// Re-read every dynamic item's current `local_bounds`, applying
1898    /// `set_local_bounds` (and re-bucketing the spatial index) for
1899    /// any entry whose value has changed. No-op for static entries.
1900    /// Called by [`SceneView`](crate::SceneView) at the start of each
1901    /// `build()` so signal-driven bounds propagate to bucketing
1902    /// without explicit app-side calls.
1903    ///
1904    /// Returns `true` if at least one dynamic entry's bounds changed this call.
1905    /// `SceneView` uses the `true → false` transition (an animation settling) as
1906    /// the one moment to walk the final animated bounds into the AccessKit tree,
1907    /// since it otherwise suppresses per-frame AT re-walks during the animation.
1908    pub fn refresh_dynamic_bounds(&mut self) -> bool {
1909        // Nothing declared dynamic: the common case, and it must cost nothing —
1910        // this runs at the top of every `build()`, and `build()` runs on every
1911        // model mutation.
1912        if self.dynamic.is_empty() {
1913            return false;
1914        }
1915        // Snapshot ids first to avoid borrow conflicts.
1916        let dynamic_ids: Vec<ItemId> = self.dynamic.clone();
1917        let mut changed = false;
1918        let seq_before = self.mutation_seq.get();
1919        // This is the crate's one genuine per-frame model-mutation stream: an
1920        // animating `add_item_dynamic` item re-reads its signal-driven AABB
1921        // every build and writes it back, so it emits a `LocalBoundsChanged`
1922        // per frame. Those changes must be *rendered* and must not be
1923        // *recorded*, so they are tagged ephemeral for the duration of the
1924        // refresh. An app driving its own per-frame stream marks it the same
1925        // way with `SceneTransaction::ephemeral`.
1926        let journal = Rc::clone(&self.journal);
1927        let _ephemeral = EphemeralScope::new(&journal);
1928        for id in dynamic_ids {
1929            let Some(&pos) = self.entry_index.get(&id) else {
1930                continue;
1931            };
1932            let SceneEntryKind::Item(item) = &self.entries[pos].kind else {
1933                continue;
1934            };
1935            let new = item.local_bounds();
1936            if new != self.entries[pos].local_bounds {
1937                self.set_local_bounds(id, new);
1938                changed = true;
1939            }
1940        }
1941        // Everything this pass emitted is per-frame dynamic-bounds churn, not a
1942        // structural change: record it so `structural_version` can exclude it
1943        // by name. Counting here (rather than at the call site, by bracketing
1944        // this call with two `mutation_version` snapshots) is what keeps a
1945        // mutation an *observer* makes during the fan-out out of the exclusion —
1946        // it happens after this line, so it lands in `mutation_seq` alone and
1947        // `structural_version` sees it.
1948        let churn = self.mutation_seq.get().wrapping_sub(seq_before);
1949        self.dynamic_seq
1950            .set(self.dynamic_seq.get().wrapping_add(churn));
1951        changed
1952    }
1953
1954    fn push_entry(&mut self, entry: SceneEntry) -> ItemId {
1955        let id = entry.id;
1956        let parent = entry.parent;
1957        let heavyweight = matches!(entry.kind, SceneEntryKind::Widget(_));
1958        let dynamic = entry.dynamic_bounds;
1959        let pos = self.entries.len();
1960        self.entries.push(entry);
1961        self.entry_index.insert(id, pos);
1962        // A fresh insertion only ever appends, so both side lists stay in entry
1963        // order — which `heavyweight_ids` promises and `SceneView` relies on for
1964        // child ordering — at O(1). `push_entry_at` pays a scan to keep the same
1965        // promise when it inserts in the middle; this path must not.
1966        if heavyweight {
1967            self.heavyweight.push(id);
1968        }
1969        if dynamic {
1970            self.dynamic.push(id);
1971        }
1972        self.finish_insert(id, parent)
1973    }
1974
1975    /// Insert an entry at a **given** position in declaration order, for
1976    /// [`Scene::restore`]: an item that comes back at the end of the order
1977    /// comes back in the wrong place in the accessibility reading order, which
1978    /// is the order the walk publishes siblings in.
1979    ///
1980    /// `at` is clamped to the current length, so a recorded index that no
1981    /// longer exists (the scene shrank while the salvage was held) appends
1982    /// rather than failing.
1983    ///
1984    /// Costs a reindex of the tail and a scan of each side list, which is the
1985    /// price of not appending. A restore is not a hot path; `push_entry` is,
1986    /// and keeps its O(1).
1987    fn push_entry_at(&mut self, entry: SceneEntry, at: usize) -> ItemId {
1988        let id = entry.id;
1989        let parent = entry.parent;
1990        let heavyweight = matches!(entry.kind, SceneEntryKind::Widget(_));
1991        let dynamic = entry.dynamic_bounds;
1992        let at = at.min(self.entries.len());
1993        self.entries.insert(at, entry);
1994        let reindexed: Vec<(ItemId, usize)> = self
1995            .entries
1996            .iter()
1997            .enumerate()
1998            .skip(at)
1999            .map(|(pos, e)| (e.id, pos))
2000            .collect();
2001        for (eid, pos) in reindexed {
2002            self.entry_index.insert(eid, pos);
2003        }
2004        if heavyweight {
2005            let slot = self
2006                .heavyweight
2007                .iter()
2008                .position(|hid| self.entry_index.get(hid).copied().unwrap_or(0) > at)
2009                .unwrap_or(self.heavyweight.len());
2010            self.heavyweight.insert(slot, id);
2011        }
2012        if dynamic {
2013            let slot = self
2014                .dynamic
2015                .iter()
2016                .position(|did| self.entry_index.get(did).copied().unwrap_or(0) > at)
2017                .unwrap_or(self.dynamic.len());
2018            self.dynamic.insert(slot, id);
2019        }
2020        self.finish_insert(id, parent)
2021    }
2022
2023    /// The tail every insertion shares: adjacency, spatial index, `Added`.
2024    ///
2025    /// The `parent` ⇄ `children` adjacency is maintained here rather than
2026    /// assumed, so a constructor that arrives parented — which
2027    /// [`push_entry_at`](Self::push_entry_at) does, restoring a child — cannot
2028    /// silently break the invariant.
2029    fn finish_insert(&mut self, id: ItemId, parent: Option<ItemId>) -> ItemId {
2030        self.link_child(parent, id);
2031        let aabb = self.compute_scene_aabb(id).unwrap_or(Rect::ZERO);
2032        self.index.insert(id, aabb);
2033        self.emit_item_change(ItemChange::Added { id });
2034        id
2035    }
2036
2037    /// Reactive notification stream for every Scene mutation. Apps observe via
2038    /// `signal.observe(|change| …)` to wire validation, persistence,
2039    /// telemetry, or mirroring into a data layer without polling the Scene
2040    /// each frame. The signal fires *after* the mutation has been applied — by
2041    /// the time the observer runs, the Scene already reflects the new state.
2042    ///
2043    /// # Notification timing
2044    ///
2045    /// Where the fan-out happens depends on which door mutated the scene, and
2046    /// the difference is exactly the `RefCell` an observer would have to
2047    /// re-enter:
2048    ///
2049    /// - **Through a [`SceneModel`](crate::SceneModel)** (the normal path,
2050    ///   including [`SceneWriteGuard`](crate::SceneWriteGuard)) the mutation
2051    ///   runs inside a *write scope*: changes queue in emission order and fan
2052    ///   out once the model has released its `borrow_mut()`. An observer may
2053    ///   therefore read the scene, and write it back — a write made from an
2054    ///   observer queues in turn and is delivered by the same drain, after the
2055    ///   changes already queued ahead of it.
2056    /// - **Through a bare `&mut Scene`** there is nothing holding a `RefCell`
2057    ///   open to escape from, so the fan-out is synchronous, inside the
2058    ///   mutator, exactly as before.
2059    ///
2060    ///   This is safe for a `Scene` you own outright — no second handle to it
2061    ///   can exist. It is **not** safe for a `&mut Scene` reborrowed out of a
2062    ///   [`SceneModel`](crate::SceneModel): a `RefMut` taken from the shared
2063    ///   cell leaves `defer_depth` at zero, so observers still run under the
2064    ///   live exclusive borrow and one that re-enters the model panics. Take
2065    ///   [`SceneModel::write_guard`](crate::SceneModel::write_guard) instead —
2066    ///   it derefs to `&mut Scene`, so the call sites are identical, and it
2067    ///   opens the write scope the observers need.
2068    ///
2069    /// Either way the notification has been delivered by the time the mutator
2070    /// call returns.
2071    ///
2072    /// # What is not deferred
2073    ///
2074    /// The scene's constraint signals — [`pan_axes_signal`](Self::pan_axes_signal),
2075    /// [`zoomable_signal`](Self::zoomable_signal),
2076    /// [`pan_bounds_signal`](Self::pan_bounds_signal),
2077    /// [`zoom_range_signal`](Self::zoom_range_signal) — carry *state*, not
2078    /// events ([`current_pan_axes`](Self::current_pan_axes) and friends read
2079    /// them back), so queueing their writes would make the scene contradict
2080    /// itself inside an open write scope. They still fan out synchronously
2081    /// under the borrow: an observer on one of those four must not re-enter
2082    /// the `SceneModel`.
2083    pub fn item_change_signal(&self) -> Signal<SceneChange> {
2084        self.pending.item_signal()
2085    }
2086
2087    /// Reactive notification for logical-AT-structure mutations
2088    /// (`add_a11y_group` / `remove_a11y_group` / `set_a11y_parent` /
2089    /// `add_a11y_relation` / `set_a11y_live` / `set_a11y_landmark` /
2090    /// `set_a11y_categories`). A monotonic counter bumped after each such
2091    /// mutation. `SceneView` observes this to re-walk the AccessKit tree —
2092    /// these changes don't flow through [`item_change_signal`](Self::item_change_signal)
2093    /// because they aren't item geometry, and the AT tree is separate from the
2094    /// visual scene.
2095    pub fn a11y_change_signal(&self) -> Signal<u64> {
2096        self.pending.a11y_signal()
2097    }
2098
2099    /// Bump the logical-AT-structure change counter. Called at the end of every
2100    /// a11y-structure mutator so observers re-walk AccessKit. Also advances the
2101    /// unified [`mutation_version`](Self::mutation_version) so a logical-AT
2102    /// mutation (which never fires `item_change_signal`) still un-gates the
2103    /// SceneView's AT re-walk.
2104    ///
2105    /// While a **write scope** is open the bump is queued and fans out once the
2106    /// scope's owner has released its `RefCell<Scene>` borrow, exactly as
2107    /// `emit_item_change` does — the two channels share one FIFO so their
2108    /// relative order survives.
2109    ///
2110    /// Note the bump/notify order: `bump_mutation` runs **first**, so an
2111    /// observer always sees a [`mutation_version`](Self::mutation_version)
2112    /// that already accounts for the change it is being told about. That
2113    /// matches `emit_item_change`; the two channels used to disagree.
2114    ///
2115    /// `subject` is the node the mutation was about. It never reaches an
2116    /// observer — the signal is a bare counter — but the runaway detector
2117    /// charges the delivery to it, so every a11y mutator has to name one. That
2118    /// is what keeps an app maintaining AT structure per item off a single
2119    /// scene-wide budget; see [`CascadeBudget`].
2120    fn bump_a11y_change(&self, subject: A11yNode) {
2121        self.bump_mutation();
2122        self.notify_or_queue(PendingNotification::A11y(subject));
2123    }
2124
2125    /// Fire an [`ItemChange`] through `item_change_signal` and advance the
2126    /// unified [`mutation_version`](Self::mutation_version). The single choke
2127    /// point every geometry / visibility / structure mutation routes through, so
2128    /// the version counts each one without per-site bookkeeping.
2129    ///
2130    /// While a **write scope** is open the change is appended to
2131    /// `pending_notifications` instead, and fans out once the scope's owner has
2132    /// released its `RefCell<Scene>` borrow — see
2133    /// [`SceneModel::flush_changes`](crate::SceneModel::flush_changes).
2134    /// `mutation_seq` advances either way: the version is the scene's own
2135    /// state, not a notification, and the scene is exclusively borrowed for the
2136    /// whole window in which the two are transiently out of step.
2137    fn emit_item_change(&self, change: ItemChange) {
2138        self.bump_mutation();
2139        self.item_change_seq
2140            .set(self.item_change_seq.get().wrapping_add(1));
2141        let (txn, source, history, ephemeral) = self.journal.stamp();
2142        // Two kinds of change stay out of the record.
2143        //
2144        // A removal's edit carries ownership the notification cannot, so the
2145        // removal path records its own `SceneEdit::Removed` with the salvage
2146        // attached. Recording it again here would double it.
2147        //
2148        // A *derived notification* — `VisibilityChanged` beside the
2149        // `FlagsChanged` that describes the same mutation, or the
2150        // `HandlersChanged` that `handlers_mut` fires without knowing what the
2151        // handlers became — is not an edit at all. Recording the first would
2152        // make one mutation two edits; recording the second would put an entry
2153        // nothing can invert into a record whose whole claim is that every
2154        // entry carries both sides.
2155        if change.is_edit() && !matches!(change, ItemChange::Removed { .. }) {
2156            self.journal.record(SceneEdit::Change(change.clone()));
2157        }
2158        self.notify_or_queue(PendingNotification::Item(SceneChange {
2159            txn,
2160            source,
2161            history,
2162            ephemeral,
2163            change,
2164        }));
2165    }
2166
2167    /// The edit journal, as a handle that outlives any borrow on this scene —
2168    /// the same shape as [`change_queue`](Self::change_queue), and for the same
2169    /// reason: a record is handed to the sink with the scene free.
2170    pub(crate) fn journal(&self) -> Rc<EditJournal> {
2171        Rc::clone(&self.journal)
2172    }
2173
2174    /// How many [`ItemChange`]s this scene has emitted, ever — the counter a
2175    /// consumer caching per-item data validates against. See the field's own
2176    /// documentation for why it is not [`mutation_version`](Self::mutation_version).
2177    pub(crate) fn item_change_version(&self) -> u64 {
2178        self.item_change_seq.get()
2179    }
2180
2181    /// Queue one notification, then deliver the queue if no write scope is
2182    /// open.
2183    ///
2184    /// The queue is appended to *unconditionally*, even at depth zero. That is
2185    /// deliberate: anything already queued was emitted **earlier**, and firing
2186    /// this change past it would deliver the scene's own history out of order —
2187    /// an item would appear to move backwards once the older change finally
2188    /// landed. FIFO is the one ordering guarantee this channel makes, and it
2189    /// has to hold across the seam between the deferred and the synchronous
2190    /// door too. (Reachable only after an observer panicked mid-drain and left
2191    /// a partly-delivered queue behind, which is precisely when an app is least
2192    /// able to reason about ordering for itself.)
2193    fn notify_or_queue(&self, notification: PendingNotification) {
2194        self.pending.push(notification);
2195        if self.defer_depth.get() > 0 {
2196            // A write scope owns the fan-out: it drains once its borrow drops.
2197            return;
2198        }
2199        self.pending.drain(self.cascade_budget.get());
2200    }
2201
2202    /// The runaway-detection budget for this scene's change fan-out.
2203    pub fn cascade_budget(&self) -> CascadeBudget {
2204        self.cascade_budget.get()
2205    }
2206
2207    /// Replace the runaway-detection budget for this scene's change fan-out.
2208    ///
2209    /// The default is tuned for the graph shapes this tier runs; see
2210    /// [`CascadeBudget`] for the rule and for when raising it is the right
2211    /// answer rather than a way to silence a real cycle.
2212    ///
2213    /// A budget of zero is clamped to the smallest usable one rather than
2214    /// stored: it would forbid the reactive write-back this channel exists for,
2215    /// and would trip with no cascade for the diagnostic to describe.
2216    ///
2217    /// Takes effect on the next drain — a budget raised from inside an observer
2218    /// does not enlarge the drain already running, which is why the budget is
2219    /// read once at its start.
2220    pub fn set_cascade_budget(&self, budget: CascadeBudget) {
2221        self.cascade_budget.set(CascadeBudget::new(budget.total));
2222    }
2223
2224    // -----------------------------------------------------------------
2225    // Write scope / deferred fan-out (drained by `SceneModel`)
2226    // -----------------------------------------------------------------
2227
2228    /// Open a write scope: every later notification queues rather than fanning
2229    /// out, until the matching [`exit_write_scope`](Self::exit_write_scope).
2230    ///
2231    /// Called by the owner of the `RefCell<Scene>` borrow, never by a `Scene`
2232    /// method — the point of the scope is to outlive the borrow's *creator*,
2233    /// which a `&mut self` method cannot do for its own `&mut self`.
2234    pub(crate) fn enter_write_scope(&self) {
2235        self.defer_depth.set(self.defer_depth.get() + 1);
2236        // A write scope is also the **implicit transaction boundary**: one
2237        // scope, one `TxnId`. That is what makes a subtree `remove` one
2238        // transaction with N edits, and a `SceneWriteGuard` block one
2239        // transaction, without a single mutator being wrapped by hand. An
2240        // explicit `SceneTransaction` has already opened a scope, so this one
2241        // joins it and its default stamp is ignored.
2242        self.journal
2243            .enter_scope(ChangeSource::default(), HistoryMode::default());
2244    }
2245
2246    /// Close a write scope opened by [`enter_write_scope`](Self::enter_write_scope).
2247    pub(crate) fn exit_write_scope(&self) {
2248        self.defer_depth
2249            .set(self.defer_depth.get().saturating_sub(1));
2250        self.journal.exit_scope();
2251    }
2252
2253    /// The notification queue, as a handle that outlives any borrow on this
2254    /// scene. [`SceneModel::flush_changes`](crate::SceneModel::flush_changes)
2255    /// takes one, releases its borrow, and only then drains — so observers run
2256    /// with the scene free.
2257    pub(crate) fn change_queue(&self) -> Rc<ChangeQueue> {
2258        Rc::clone(&self.pending)
2259    }
2260
2261    /// Whether anything is waiting to be delivered. Cheaper than
2262    /// [`change_queue`](Self::change_queue) for the "is there anything to do?"
2263    /// question a flush asks on every frame.
2264    pub(crate) fn has_pending_notifications(&self) -> bool {
2265        self.pending.has_work()
2266    }
2267
2268    /// How many transaction / write scopes are open on this scene.
2269    ///
2270    /// Zero between mutations. A non-zero value outside a mutator means a
2271    /// [`SceneTransaction`](crate::SceneTransaction) is being held, which is
2272    /// only correct within one synchronous scope — see
2273    /// [`SceneModel::transaction`](crate::SceneModel::transaction).
2274    pub fn open_transaction_depth(&self) -> u32 {
2275        self.journal.depth()
2276    }
2277
2278    /// Whether any committed transaction is waiting for the edit sink. Same
2279    /// shape and purpose as [`has_pending_notifications`](Self::has_pending_notifications).
2280    pub(crate) fn has_pending_records(&self) -> bool {
2281        self.journal.has_records()
2282    }
2283
2284    /// Whether the change fan-out is mid-drain — see
2285    /// [`ChangeQueue::is_draining`].
2286    pub(crate) fn changes_are_draining(&self) -> bool {
2287        self.pending.is_draining()
2288    }
2289
2290    /// Fires once per committed transaction, after the edit sink, with the
2291    /// scene unborrowed. See
2292    /// [`SceneModel::transaction_signal`](crate::SceneModel::transaction_signal).
2293    pub fn transaction_signal(&self) -> Signal<crate::journal::TxnId> {
2294        self.journal.txn_signal()
2295    }
2296
2297    /// Advance the unified model-mutation counter (wrapping). Shared by
2298    /// `emit_item_change` and `bump_a11y_change`; `&self` because both notify
2299    /// paths are `&self`.
2300    fn bump_mutation(&self) {
2301        self.mutation_seq
2302            .set(self.mutation_seq.get().wrapping_add(1));
2303    }
2304
2305    /// Monotonic counter of every model mutation applied so far — item geometry
2306    /// / visibility / structure (each [`ItemChange`]) **and** logical-AT
2307    /// structure (groups, parents, relations, live, landmarks, categories).
2308    ///
2309    /// Includes the per-frame churn of
2310    /// [`refresh_dynamic_bounds`](Self::refresh_dynamic_bounds); a consumer
2311    /// gating an expensive rebuild on "did anything *meaningful* change" wants
2312    /// [`structural_version`](Self::structural_version) instead. The counter
2313    /// wraps; compare for equality, not ordering.
2314    pub fn mutation_version(&self) -> u64 {
2315        self.mutation_seq.get()
2316    }
2317
2318    /// [`mutation_version`](Self::mutation_version) with the per-frame
2319    /// dynamic-bounds churn subtracted out: it advances on every mutation
2320    /// **except** the `LocalBoundsChanged` events
2321    /// [`refresh_dynamic_bounds`](Self::refresh_dynamic_bounds) emits.
2322    ///
2323    /// The version to gate an expensive rebuild on.
2324    /// [`SceneView`](crate::SceneView) snapshots it each `build()` and only
2325    /// re-walks the (separate, expensive) AccessKit tree when it has advanced
2326    /// since the previous walk — so an actively-animating
2327    /// [`add_item_dynamic`](Self::add_item_dynamic) item, which rebuilds every
2328    /// frame, does not issue an AT re-walk per frame for sub-pixel bounds drift
2329    /// a screen reader cannot use.
2330    ///
2331    /// Naming the exclusion is the point. The alternative — snapshotting
2332    /// `mutation_version` before `refresh_dynamic_bounds` and again after, and
2333    /// treating the difference as churn — is wrong now that the refresh fans
2334    /// its changes out: an observer may legally mutate the scene from that
2335    /// fan-out, and its mutation lands inside the bracket where it is
2336    /// indistinguishable from churn. Folded into the baseline, an AT-structural
2337    /// change made there would never un-gate a re-walk, in that build or any
2338    /// later one. The counter wraps; compare for equality, not ordering.
2339    pub fn structural_version(&self) -> u64 {
2340        self.mutation_seq.get().wrapping_sub(self.dynamic_seq.get())
2341    }
2342
2343    // -----------------------------------------------------------------
2344    // Geometry — local
2345    // -----------------------------------------------------------------
2346
2347    /// Read an item's `local_pos` (its anchor in parent coords).
2348    pub fn local_pos(&self, id: ItemId) -> Option<Point> {
2349        let pos = *self.entry_index.get(&id)?;
2350        Some(self.entries[pos].local_pos)
2351    }
2352
2353    /// Move an item to a new `local_pos` in its parent's coordinate
2354    /// frame. Re-buckets the item *and* every descendant in the
2355    /// spatial index since the descendants' scene-AABBs shift along.
2356    /// No-op if the id is unknown.
2357    pub fn set_local_pos(&mut self, id: ItemId, local_pos: Point) {
2358        if let Some(&pos) = self.entry_index.get(&id) {
2359            let old = self.entries[pos].local_pos;
2360            if old == local_pos {
2361                return;
2362            }
2363            self.entries[pos].local_pos = local_pos;
2364            self.rebucket_subtree(id);
2365            self.emit_item_change(ItemChange::LocalPosChanged {
2366                id,
2367                old,
2368                new: local_pos,
2369            });
2370        }
2371    }
2372
2373    /// Read an item's `local_bounds` (its AABB in local coords).
2374    pub fn local_bounds(&self, id: ItemId) -> Option<Rect> {
2375        let pos = *self.entry_index.get(&id)?;
2376        Some(self.entries[pos].local_bounds)
2377    }
2378
2379    /// Update an item's `local_bounds`. For lightweight items this
2380    /// also calls [`SceneItem::set_local_bounds`] on the item so its
2381    /// next `paint` reflects the new geometry. The spatial index is
2382    /// re-bucketed; only this item moves (descendants' local frames
2383    /// are unchanged). No-op if the id is unknown.
2384    ///
2385    /// # The item has the last word, and says it once
2386    ///
2387    /// An item whose box is **derived** from its own geometry — a
2388    /// [`PathItem`](crate::PathItem) — treats this as *"fit yourself to this
2389    /// rectangle"* rather than *"adopt this rectangle"*, and the box stored
2390    /// here is what it settled on, read back from the item. It lands on the
2391    /// request on every axis the geometry has extent on; on an axis it has
2392    /// none (a perfectly horizontal stroke has no height) the box stays the
2393    /// stroke's own thickness, because there is nothing to stretch.
2394    ///
2395    /// Either way the call is **idempotent**: asking twice for the same
2396    /// rectangle emits one [`ItemChange::LocalBoundsChanged`] and re-buckets
2397    /// the index once. An app driving this per frame — a resize handle, a
2398    /// layout pass — therefore goes quiet as soon as it stops moving, rather
2399    /// than emitting an endless series of nearly-identical changes.
2400    pub fn set_local_bounds(&mut self, id: ItemId, local_bounds: Rect) {
2401        if let Some(&pos) = self.entry_index.get(&id) {
2402            let old = self.entries[pos].local_bounds;
2403            if old == local_bounds {
2404                return;
2405            }
2406            // The entry mirrors whatever the item settled on, not what the
2407            // caller asked for. A geometry-bearing item may honour the request
2408            // only up to its own extent (a `PathItem` fits its path to the box
2409            // and then re-derives it from the moved geometry, band included),
2410            // and the entry's rectangle is what the spatial index buckets on —
2411            // so reading it back here is what makes "the item's box" and "the
2412            // index's box" one value rather than two that can drift.
2413            let effective = match &mut self.entries[pos].kind {
2414                SceneEntryKind::Item(item) => {
2415                    item.set_local_bounds(local_bounds);
2416                    item.local_bounds()
2417                }
2418                SceneEntryKind::Widget(_) => local_bounds,
2419            };
2420            self.entries[pos].local_bounds = effective;
2421            if old == effective {
2422                return;
2423            }
2424            // Bounds are local — only this entry's scene-AABB shifts;
2425            // descendants' local frames are unchanged.
2426            let aabb = self.compute_scene_aabb(id).unwrap_or(Rect::ZERO);
2427            self.index.insert(id, aabb);
2428            self.emit_item_change(ItemChange::LocalBoundsChanged {
2429                id,
2430                old,
2431                new: effective,
2432            });
2433        }
2434    }
2435
2436    /// Which axes of this entry's `local_bounds` its widget decides.
2437    /// [`SizePolicy::Fixed`] for an unknown id, and for every lightweight item.
2438    pub fn size_policy(&self, id: ItemId) -> SizePolicy {
2439        self.entry_index
2440            .get(&id)
2441            .map(|&pos| self.entries[pos].size_policy)
2442            .unwrap_or_default()
2443    }
2444
2445    /// Hand one or both axes of `id`'s box to its widget. See [`SizePolicy`].
2446    ///
2447    /// Returns `false` — and changes nothing — for an unknown id or a
2448    /// **lightweight** entry, which has no `layout_response` to ask. That is a
2449    /// refusal rather than a silent no-op because the two tiers already have
2450    /// different answers to "who decides my box"
2451    /// ([`add_item_dynamic`](Self::add_item_dynamic) is the lightweight one),
2452    /// and a policy that quietly did nothing on one of them would read as a
2453    /// bug in the view rather than a misuse of the model.
2454    ///
2455    /// Emits [`ItemChange::SizePolicyChanged`] when the value actually changes —
2456    /// an edit, carrying no geometry. The geometry it enables arrives separately
2457    /// as [`ItemChange::MeasuredSizeChanged`], which is not an edit.
2458    pub fn set_size_policy(&mut self, id: ItemId, policy: SizePolicy) -> bool {
2459        let Some(&pos) = self.entry_index.get(&id) else {
2460            return false;
2461        };
2462        if !matches!(self.entries[pos].kind, SceneEntryKind::Widget(_)) {
2463            return false;
2464        }
2465        let old = std::mem::replace(&mut self.entries[pos].size_policy, policy);
2466        if old != policy {
2467            self.emit_item_change(ItemChange::SizePolicyChanged {
2468                id,
2469                old,
2470                new: policy,
2471            });
2472        }
2473        true
2474    }
2475
2476    /// Write a **derived** size into the model: the same geometry update and
2477    /// index re-bucketing [`set_local_bounds`](Self::set_local_bounds) does,
2478    /// reported as [`ItemChange::MeasuredSizeChanged`] instead — not an edit,
2479    /// not structural, answered by a relayout rather than a rebuild.
2480    ///
2481    /// Returns whether anything moved. The origin of `local_bounds` is kept;
2482    /// only the size is written, because that is the whole of what a
2483    /// measurement can know.
2484    ///
2485    /// # When this is the right door, and when it is not
2486    ///
2487    /// Use it for a size that is a **pure function of content the app already
2488    /// owns**: the height a paragraph wraps to at a given width, the bounding
2489    /// box of an ink stroke after a segment dried, the extent of a generated
2490    /// diagram. Recording such a write as an edit is what makes a text reflow
2491    /// an undo step; bumping the structural version for it is what buys an
2492    /// AccessKit re-walk for a two-pixel line-height change. This door does
2493    /// neither, and the view that observes it relayouts instead of rebuilding.
2494    ///
2495    /// Use [`set_local_bounds`](Self::set_local_bounds) for anything the
2496    /// **user** did: a resize handle, an import, a paste. Those are edits, and
2497    /// a history above the scene must see them.
2498    ///
2499    /// The [`SizePolicy`] machinery drives this from inside the view; an app
2500    /// measuring something the framework cannot measure calls it directly.
2501    pub fn set_measured_size(&mut self, id: ItemId, size: Size) -> bool {
2502        let Some(&pos) = self.entry_index.get(&id) else {
2503            return false;
2504        };
2505        let old = self.entries[pos].local_bounds;
2506        let requested = Rect::new(old.x, old.y, size.width, size.height);
2507        if old == requested {
2508            return false;
2509        }
2510        // The item has the last word here exactly as it does in
2511        // `set_local_bounds` — a `PathItem` fits itself to the box and reports
2512        // what it settled on — so the entry and the index cannot drift apart
2513        // depending on which door wrote them.
2514        let effective = match &mut self.entries[pos].kind {
2515            SceneEntryKind::Item(item) => {
2516                item.set_local_bounds(requested);
2517                item.local_bounds()
2518            }
2519            SceneEntryKind::Widget(_) => requested,
2520        };
2521        self.entries[pos].local_bounds = effective;
2522        if old == effective {
2523            return false;
2524        }
2525        let aabb = self.compute_scene_aabb(id).unwrap_or(Rect::ZERO);
2526        self.index.insert(id, aabb);
2527        // Rendered, not recorded — the same pair of statements
2528        // `refresh_dynamic_bounds` makes about its own per-frame churn, and for
2529        // the same reason. `ephemeral` is what an app's change feed reads;
2530        // `dynamic_seq` is what `structural_version` subtracts.
2531        let journal = Rc::clone(&self.journal);
2532        let _ephemeral = EphemeralScope::new(&journal);
2533        let seq_before = self.mutation_seq.get();
2534        self.emit_item_change(ItemChange::MeasuredSizeChanged {
2535            id,
2536            old,
2537            new: effective,
2538        });
2539        let churn = self.mutation_seq.get().wrapping_sub(seq_before);
2540        self.dynamic_seq
2541            .set(self.dynamic_seq.get().wrapping_add(churn));
2542        true
2543    }
2544
2545    /// Read an item's local→parent transform (rotation/scale around
2546    /// the local origin). Identity by default.
2547    pub fn transform(&self, id: ItemId) -> Option<Transform2D> {
2548        let pos = *self.entry_index.get(&id)?;
2549        Some(self.entries[pos].transform)
2550    }
2551
2552    /// Set an item's local→parent transform. Re-buckets the item's
2553    /// subtree in the spatial index. No-op if the id is unknown.
2554    pub fn set_transform(&mut self, id: ItemId, transform: Transform2D) {
2555        if let Some(&pos) = self.entry_index.get(&id) {
2556            let old = std::mem::replace(&mut self.entries[pos].transform, transform);
2557            if old == transform {
2558                // An app driving a rotation from a `Signal<f32>` writes this
2559                // every frame; without the guard that is a per-frame change
2560                // stream for a scene that is not moving, and — once the change
2561                // is recorded — a per-frame transaction too.
2562                return;
2563            }
2564            self.rebucket_subtree(id);
2565            self.emit_item_change(ItemChange::TransformChanged {
2566                id,
2567                old,
2568                new: transform,
2569            });
2570        }
2571    }
2572
2573    // -----------------------------------------------------------------
2574    // Geometry — scene (computed via parent chain)
2575    // -----------------------------------------------------------------
2576
2577    /// The composed local→scene transform for this item, walking up
2578    /// the parent chain. Identity for an item that doesn't exist.
2579    pub fn scene_transform(&self, id: ItemId) -> Transform2D {
2580        let mut acc = Transform2D::identity();
2581        let mut cur = Some(id);
2582        let cap = self.entries.len();
2583        let mut hops = 0;
2584        while let Some(cid) = cur {
2585            let Some(&pos) = self.entry_index.get(&cid) else {
2586                break;
2587            };
2588            let entry = &self.entries[pos];
2589            let l2p = local_to_parent(entry.local_pos, &entry.transform);
2590            acc = acc.then(&l2p);
2591            cur = entry.parent;
2592            hops += 1;
2593            if hops > cap {
2594                break;
2595            }
2596        }
2597        acc
2598    }
2599
2600    /// The item's anchor in scene coords (its local origin
2601    /// transformed through the parent chain).
2602    pub fn scene_pos(&self, id: ItemId) -> Option<Point> {
2603        if !self.entry_index.contains_key(&id) {
2604            return None;
2605        }
2606        Some(self.scene_transform(id).apply_point(Point::ZERO))
2607    }
2608
2609    /// The AABB enclosing the item's `local_bounds` after composing
2610    /// through the parent chain — i.e. the rectangle the spatial
2611    /// index buckets on. `None` if the id is unknown.
2612    pub fn scene_rect(&self, id: ItemId) -> Option<Rect> {
2613        let local_bounds = self.local_bounds(id)?;
2614        Some(self.scene_transform(id).apply_rect(local_bounds))
2615    }
2616
2617    /// Map a point in the item's local frame to scene coords.
2618    pub fn map_to_scene(&self, id: ItemId, local_pt: Point) -> Option<Point> {
2619        if !self.entry_index.contains_key(&id) {
2620            return None;
2621        }
2622        Some(self.scene_transform(id).apply_point(local_pt))
2623    }
2624
2625    /// Map a point in scene coords to the item's local frame.
2626    /// Returns `None` if the item is unknown or its scene transform
2627    /// is degenerate (zero scale).
2628    pub fn map_from_scene(&self, id: ItemId, scene_pt: Point) -> Option<Point> {
2629        if !self.entry_index.contains_key(&id) {
2630            return None;
2631        }
2632        self.scene_transform(id)
2633            .inverse()
2634            .map(|inv| inv.apply_point(scene_pt))
2635    }
2636
2637    fn compute_scene_aabb(&self, id: ItemId) -> Option<Rect> {
2638        let pos = *self.entry_index.get(&id)?;
2639        let local_bounds = self.entries[pos].local_bounds;
2640        Some(self.scene_transform(id).apply_rect(local_bounds))
2641    }
2642
2643    /// Record `child` under `parent`'s `children` list. `None` parent is the
2644    /// scene root, which keeps no list (nothing walks down from it).
2645    fn link_child(&mut self, parent: Option<ItemId>, child: ItemId) {
2646        let Some(parent) = parent else { return };
2647        let Some(&pos) = self.entry_index.get(&parent) else {
2648            return;
2649        };
2650        let kids = &mut self.entries[pos].children;
2651        if !kids.contains(&child) {
2652            kids.push(child);
2653        }
2654    }
2655
2656    /// Drop `child` from `parent`'s `children` list. The inverse of
2657    /// [`link_child`](Self::link_child); every parent-pointer write pairs the
2658    /// two so the adjacency never drifts from the pointers.
2659    fn unlink_child(&mut self, parent: Option<ItemId>, child: ItemId) {
2660        let Some(parent) = parent else { return };
2661        let Some(&pos) = self.entry_index.get(&parent) else {
2662            return;
2663        };
2664        self.entries[pos].children.retain(|c| *c != child);
2665    }
2666
2667    /// Push `id`'s direct children onto `out`, in the order they became
2668    /// children. The one downward step every subtree walk in this file takes.
2669    fn push_children(&self, id: ItemId, out: &mut Vec<ItemId>) {
2670        if let Some(&pos) = self.entry_index.get(&id) {
2671            out.extend_from_slice(&self.entries[pos].children);
2672        }
2673    }
2674
2675    fn rebucket_subtree(&mut self, root: ItemId) {
2676        // Re-bucket `root` and every descendant whose scene-AABB depends on the
2677        // root's frame. The walk reads the kept `SceneEntry::children`
2678        // adjacency, so it costs the moved subtree — not the model. (It used to
2679        // rebuild a scene-wide parent→children `HashMap` on every call, which
2680        // put `O(entries)` on each pointer sample of a drag.)
2681        //
2682        // Cycle guard: the parent-pointer walkers (`scene_transform` etc.)
2683        // bound their *upward* walk with a hop cap; this *downward* walk can
2684        // loop forever if the parent graph ever contains a cycle (e.g. from a
2685        // future de-serialization bug), so we track visited nodes. A
2686        // well-formed tree never revisits a node, so this is also a redundant-
2687        // work guard. It is sized to the subtree, not the scene: a scene of
2688        // 50 000 items moving one leaf allocates a one-element set.
2689        let mut visited: HashSet<ItemId> = HashSet::new();
2690        let mut stack: Vec<ItemId> = vec![root];
2691        while let Some(id) = stack.pop() {
2692            if !visited.insert(id) {
2693                continue;
2694            }
2695            if let Some(aabb) = self.compute_scene_aabb(id) {
2696                self.index.insert(id, aabb);
2697            }
2698            self.push_children(id, &mut stack);
2699        }
2700    }
2701
2702    // -----------------------------------------------------------------
2703    // Flags, visibility, opacity (per item)
2704    // -----------------------------------------------------------------
2705
2706    /// Read an item's [`ItemFlags`] bitset.
2707    pub fn flags(&self, id: ItemId) -> Option<ItemFlags> {
2708        let pos = *self.entry_index.get(&id)?;
2709        Some(self.entries[pos].flags)
2710    }
2711
2712    /// Replace an item's flags wholesale. No-op if unknown.
2713    ///
2714    /// Announces exactly what [`set_flag`](Self::set_flag) announces for the
2715    /// same net change: the derived [`ItemChange::VisibilityChanged`] when
2716    /// `IS_VISIBLE` flipped, then the [`ItemChange::FlagsChanged`] that
2717    /// describes the mutation.
2718    pub fn set_flags(&mut self, id: ItemId, flags: ItemFlags) {
2719        if let Some(&pos) = self.entry_index.get(&id) {
2720            let old = self.entries[pos].flags;
2721            if old == flags {
2722                return;
2723            }
2724            self.entries[pos].flags = flags;
2725            self.announce_flags(id, old, flags);
2726        }
2727    }
2728
2729    /// Set or clear a single flag on an item. No-op if unknown.
2730    pub fn set_flag(&mut self, id: ItemId, flag: ItemFlags, on: bool) {
2731        if let Some(&pos) = self.entry_index.get(&id) {
2732            let old = self.entries[pos].flags;
2733            self.entries[pos].flags.set(flag, on);
2734            let new = self.entries[pos].flags;
2735            if old != new {
2736                self.announce_flags(id, old, new);
2737            }
2738        }
2739    }
2740
2741    /// The one announcement both flag doors make, so a hidden card is the same
2742    /// pair of notifications and the same *single* recorded edit whichever door
2743    /// hid it.
2744    ///
2745    /// [`ItemChange::VisibilityChanged`] first when `IS_VISIBLE` flipped — a
2746    /// derived convenience that stays out of the transaction record
2747    /// ([`ItemChange::is_edit`]) — then the [`ItemChange::FlagsChanged`] that
2748    /// describes the mutation. `set_flag` used to emit the pair and `set_flags`
2749    /// only the second, so one mutation had two record shapes depending on the
2750    /// door, and an app counting the edits in a transaction reported 2 for
2751    /// hiding a card and 1 for the same hide through `set_flags`.
2752    fn announce_flags(&mut self, id: ItemId, old: ItemFlags, new: ItemFlags) {
2753        if old.contains(ItemFlags::IS_VISIBLE) != new.contains(ItemFlags::IS_VISIBLE) {
2754            self.emit_item_change(ItemChange::VisibilityChanged {
2755                id,
2756                visible: new.contains(ItemFlags::IS_VISIBLE),
2757            });
2758        }
2759        self.emit_item_change(ItemChange::FlagsChanged { id, old, new });
2760    }
2761
2762    /// Toggle the [`ItemFlags::IS_VISIBLE`] bit. Convenience for
2763    /// the common "hide this item" operation.
2764    pub fn set_visible(&mut self, id: ItemId, visible: bool) {
2765        self.set_flag(id, ItemFlags::IS_VISIBLE, visible);
2766    }
2767
2768    /// Whether the item is visible AND every ancestor in its chain
2769    /// is visible. Returns `true` when nothing in the chain has
2770    /// `IS_VISIBLE` cleared. `false` for unknown ids.
2771    pub fn is_effectively_visible(&self, id: ItemId) -> bool {
2772        let cap = self.entries.len();
2773        let mut hops = 0;
2774        let mut cur = Some(id);
2775        while let Some(cid) = cur {
2776            let Some(&pos) = self.entry_index.get(&cid) else {
2777                return false;
2778            };
2779            let entry = &self.entries[pos];
2780            if !entry.flags.contains(ItemFlags::IS_VISIBLE) {
2781                return false;
2782            }
2783            cur = entry.parent;
2784            hops += 1;
2785            if hops > cap {
2786                break;
2787            }
2788        }
2789        true
2790    }
2791
2792    /// Read an item's local opacity multiplier (`1.0` by default).
2793    pub fn opacity(&self, id: ItemId) -> Option<f32> {
2794        let pos = *self.entry_index.get(&id)?;
2795        Some(self.entries[pos].opacity)
2796    }
2797
2798    /// Set an item's local opacity, clamped to `[0.0, 1.0]`.
2799    pub fn set_opacity(&mut self, id: ItemId, opacity: f32) {
2800        if let Some(&pos) = self.entry_index.get(&id) {
2801            let new = opacity.clamp(0.0, 1.0);
2802            let old = self.entries[pos].opacity;
2803            if (old - new).abs() < f32::EPSILON {
2804                return;
2805            }
2806            self.entries[pos].opacity = new;
2807            self.emit_item_change(ItemChange::OpacityChanged { id, old, new });
2808        }
2809    }
2810
2811    /// Replace a lightweight item's fill colour live, emitting
2812    /// [`ItemChange::AppearanceChanged`] — **always repaint-only**, never a
2813    /// relayout, rebuild, or AccessKit re-walk. The colour is a [`ColorProp`],
2814    /// so it accepts a plain [`Color`](teksilo_tokens::Color), a theme role, a
2815    /// `Signal<Color>`, or a `Signal<Role>`. No-op for item kinds without a fill
2816    /// (e.g. `ImageItem`).
2817    ///
2818    /// # Reactivity contract
2819    ///
2820    /// A colour becomes **continuously** reactive by being registered at build
2821    /// time (`SceneItem::register_bindings`). So:
2822    ///
2823    /// - **Construct** the item with a `Signal`/role colour (`.fill(my_signal)`)
2824    ///   for a colour that tracks its signal forever. This is the recommended
2825    ///   path and needs no mutator at all.
2826    /// - **This mutator** installs a *snapshot*: it repaints immediately, which
2827    ///   is all a static colour ever needs. If you pass a `Signal`/dynamic role
2828    ///   here, it paints the signal's current value now and starts tracking it
2829    ///   continuously from the owning view's next rebuild (whenever some other
2830    ///   structural change re-runs `register_bindings`). Deliberately *not*
2831    ///   forced: a colour change must never cost a rebuild + AT re-walk.
2832    pub fn set_item_fill(&mut self, id: ItemId, fill: impl Into<ColorProp>) {
2833        let prop = fill.into();
2834        let Some(&pos) = self.entry_index.get(&id) else {
2835            return;
2836        };
2837        let write = match &mut self.entries[pos].kind {
2838            SceneEntryKind::Item(item) => item.set_fill(Some(prop.clone())),
2839            _ => AppearanceWrite::Refused,
2840        };
2841        if let Some(old) = write.into_previous() {
2842            self.emit_item_change(ItemChange::AppearanceChanged {
2843                id,
2844                change: AppearanceChange::Fill {
2845                    old,
2846                    new: Some(prop),
2847                },
2848            });
2849        }
2850    }
2851
2852    /// Clear a lightweight item's fill (Rect/Path/Group become fill-less),
2853    /// emitting [`ItemChange::AppearanceChanged`] (repaint-only). No-op for items
2854    /// whose fill can't be cleared (e.g. `TextItem`, which always has a
2855    /// foreground colour).
2856    pub fn clear_item_fill(&mut self, id: ItemId) {
2857        let Some(&pos) = self.entry_index.get(&id) else {
2858            return;
2859        };
2860        let write = match &mut self.entries[pos].kind {
2861            SceneEntryKind::Item(item) => item.set_fill(None),
2862            _ => AppearanceWrite::Refused,
2863        };
2864        if let Some(old) = write.into_previous() {
2865            self.emit_item_change(ItemChange::AppearanceChanged {
2866                id,
2867                change: AppearanceChange::Fill { old, new: None },
2868            });
2869        }
2870    }
2871
2872    /// Replace a lightweight item's stroke (colour + [`StrokeStyle`]) live,
2873    /// emitting [`ItemChange::AppearanceChanged`] (repaint-only). No-op for item
2874    /// kinds without a stroke slot (`TextItem` / `ImageItem`). See
2875    /// [`set_item_fill`](Self::set_item_fill) for the reactivity contract.
2876    pub fn set_item_stroke(&mut self, id: ItemId, color: impl Into<ColorProp>, style: StrokeStyle) {
2877        let prop = color.into();
2878        let Some(&pos) = self.entry_index.get(&id) else {
2879            return;
2880        };
2881        let write = match &mut self.entries[pos].kind {
2882            SceneEntryKind::Item(item) => item.set_stroke(Some((prop.clone(), style.clone()))),
2883            _ => AppearanceWrite::Refused,
2884        };
2885        if let Some(old) = write.into_previous() {
2886            self.emit_item_change(ItemChange::AppearanceChanged {
2887                id,
2888                change: AppearanceChange::Stroke {
2889                    old,
2890                    new: Some((prop, style)),
2891                },
2892            });
2893        }
2894    }
2895
2896    /// Clear a lightweight item's stroke, emitting
2897    /// [`ItemChange::AppearanceChanged`] (repaint-only). No-op for item kinds
2898    /// without a stroke.
2899    pub fn clear_item_stroke(&mut self, id: ItemId) {
2900        let Some(&pos) = self.entry_index.get(&id) else {
2901            return;
2902        };
2903        let write = match &mut self.entries[pos].kind {
2904            SceneEntryKind::Item(item) => item.set_stroke(None),
2905            _ => AppearanceWrite::Refused,
2906        };
2907        if let Some(old) = write.into_previous() {
2908            self.emit_item_change(ItemChange::AppearanceChanged {
2909                id,
2910                change: AppearanceChange::Stroke { old, new: None },
2911            });
2912        }
2913    }
2914
2915    /// Insert an already-boxed lightweight item at `local_pos`, returning its
2916    /// id. The boxed-`dyn` counterpart of [`add_item`](Self::add_item) — used by
2917    /// [`SceneListAdapter`](crate::SceneListAdapter) whose delegate yields
2918    /// `Box<dyn SceneItem>`.
2919    pub fn add_boxed_item(&mut self, item: Box<dyn SceneItem>, local_pos: Point) -> ItemId {
2920        self.insert_boxed(item, local_pos, false)
2921    }
2922
2923    /// Replace an item's handler set. Pass `None` to clear.
2924    ///
2925    /// Fires [`ItemChange::HandlersChanged`] carrying **both sides**
2926    /// ([`HandlerReplacement`]), like every other mutator on this type: a
2927    /// consumer that caches handlers has no other way to learn of it, and a
2928    /// data layer inverting a transaction has no other way to put them back.
2929    /// This is the handler door that produces a reversible edit; see
2930    /// [`handlers_mut`](Self::handlers_mut) for the one that cannot.
2931    pub fn set_item_handlers(&mut self, id: ItemId, handlers: Option<SceneItemHandlerSet>) {
2932        if let Some(&pos) = self.entry_index.get(&id) {
2933            let old = self.entries[pos].handlers.as_deref().cloned();
2934            self.entries[pos].handlers = handlers.clone().map(Box::new);
2935            self.emit_item_change(ItemChange::HandlersChanged {
2936                id,
2937                replaced: Some(Box::new(HandlerReplacement { old, new: handlers })),
2938            });
2939        }
2940    }
2941
2942    /// Mutably borrow an item's handler set, lazily creating an
2943    /// empty one if none exists. Returns `None` for unknown ids.
2944    /// Allows fluent chains: `scene.handlers_mut(id).unwrap().on_tap(…).cursor(…);`.
2945    ///
2946    /// Fires [`ItemChange::HandlersChanged`] *before* handing the set out, with
2947    /// no [`HandlerReplacement`] — `&mut` cannot report back what the caller
2948    /// does with it, so the notification means "no longer what you last read"
2949    /// and nothing finer. A caller that takes the borrow and changes nothing
2950    /// therefore costs one spurious invalidation, which is the right way round:
2951    /// the alternative is a consumer serving stale handlers.
2952    ///
2953    /// Because it describes nothing, it is **not** recorded as an edit
2954    /// ([`ItemChange::is_edit`]) — a transaction record whose every entry
2955    /// carries both sides of what it replaced must not carry one that carries
2956    /// neither. Make a handler change the history should be able to reverse
2957    /// through [`set_item_handlers`](Self::set_item_handlers), which knows both
2958    /// sides.
2959    pub fn handlers_mut(&mut self, id: ItemId) -> Option<&mut SceneItemHandlerSet> {
2960        let pos = *self.entry_index.get(&id)?;
2961        self.emit_item_change(ItemChange::HandlersChanged { id, replaced: None });
2962        let entry = self.entries.get_mut(pos)?;
2963        if entry.handlers.is_none() {
2964            entry.handlers = Some(Box::new(SceneItemHandlerSet::new()));
2965        }
2966        entry.handlers.as_deref_mut()
2967    }
2968
2969    /// Read-only access to an item's handler set, if one is set.
2970    pub fn handlers(&self, id: ItemId) -> Option<&SceneItemHandlerSet> {
2971        let pos = *self.entry_index.get(&id)?;
2972        self.entries[pos].handlers.as_deref()
2973    }
2974
2975    /// Effective opacity composed up the parent chain — the product
2976    /// of every ancestor's opacity and this item's. `1.0` for an
2977    /// unknown id (so callers don't end up multiplying by a stale
2978    /// value).
2979    pub fn effective_opacity(&self, id: ItemId) -> f32 {
2980        let cap = self.entries.len();
2981        let mut hops = 0;
2982        let mut cur = Some(id);
2983        let mut acc = 1.0_f32;
2984        while let Some(cid) = cur {
2985            let Some(&pos) = self.entry_index.get(&cid) else {
2986                return acc;
2987            };
2988            let entry = &self.entries[pos];
2989            acc *= entry.opacity;
2990            cur = entry.parent;
2991            hops += 1;
2992            if hops > cap {
2993                break;
2994            }
2995        }
2996        acc
2997    }
2998
2999    // -----------------------------------------------------------------
3000    // Scene rect (Qt setSceneRect) + pan/zoom policy
3001    // -----------------------------------------------------------------
3002
3003    /// Declare the scene's logical extent. `None` (the default)
3004    /// means "auto-compute from items each query"; `Some(rect)`
3005    /// fixes the extent regardless of item placement. Used by
3006    /// `SceneView` for pan clamping and `fit_to_content`.
3007    pub fn set_scene_rect(&mut self, rect: Option<Rect>) {
3008        self.user_scene_rect = rect;
3009    }
3010
3011    /// The resolved scene extent — user-declared via
3012    /// [`Scene::set_scene_rect`] if set, otherwise the AABB
3013    /// enclosing every item's scene rect. `None` when neither is
3014    /// available (the user didn't declare and the scene is empty).
3015    pub fn scene_rect_extent(&self) -> Option<Rect> {
3016        if let Some(r) = self.user_scene_rect {
3017            return Some(r);
3018        }
3019        let ids = self.ids();
3020        let mut acc: Option<Rect> = None;
3021        for id in ids {
3022            let Some(r) = self.scene_rect(id) else {
3023                continue;
3024            };
3025            acc = Some(match acc {
3026                None => r,
3027                Some(a) => union_two_rects(a, r),
3028            });
3029        }
3030        acc
3031    }
3032
3033    /// Set the axes the view may pan along. Default
3034    /// [`PanAxes::Both`]. Writes to the reactive signal; gesture
3035    /// closures pick the change up on the next event.
3036    pub fn pan_axes(&mut self, axes: PanAxes) {
3037        self.constraints.pan_axes.set(axes);
3038    }
3039
3040    /// The currently-declared pan axes. Live read of the signal.
3041    pub fn current_pan_axes(&self) -> PanAxes {
3042        self.constraints.pan_axes.get()
3043    }
3044
3045    /// Set whether the view honors zoom gestures. Default `true`.
3046    /// Writes to the reactive signal.
3047    pub fn zoomable(&mut self, on: bool) {
3048        self.constraints.zoomable.set(on);
3049    }
3050
3051    /// Whether the scene currently allows zoom. Live read.
3052    pub fn is_zoomable(&self) -> bool {
3053        self.constraints.zoomable.get()
3054    }
3055
3056    /// Clamp the visible viewport to this scene-coord rect. `None`
3057    /// (default) leaves pan unconstrained. When `Some(r)`, the
3058    /// [`SceneView`](crate::SceneView)'s pan is clamped so the
3059    /// visible scene region overlaps `r`. When `r` is smaller than
3060    /// the visible viewport, the rect is centered.
3061    ///
3062    /// Distinct from [`set_scene_rect`](Self::set_scene_rect):
3063    /// `scene_rect` declares the scene's logical extent (used by
3064    /// `adopt_scene_size`); `pan_bounds` controls what region the
3065    /// user can scroll to. A doc-style app typically sets both to
3066    /// the same rect.
3067    pub fn set_pan_bounds(&mut self, bounds: Option<Rect>) {
3068        self.constraints.pan_bounds.set(bounds);
3069    }
3070
3071    /// The currently-declared pan-bounds rect. Live read.
3072    pub fn current_pan_bounds(&self) -> Option<Rect> {
3073        self.constraints.pan_bounds.get()
3074    }
3075
3076    /// Inclusive `[min, max]` zoom-factor clamp. `None` (default)
3077    /// is unconstrained from the `Scene` side — the `SceneView`
3078    /// may still impose its own override.
3079    ///
3080    /// The effective range applied by the `SceneView` is the
3081    /// intersection of `Scene` + view-level override, so apps
3082    /// cannot loosen a `Scene`-declared range by setting a wider
3083    /// override on the view.
3084    pub fn set_zoom_range(&mut self, range: Option<std::ops::RangeInclusive<f32>>) {
3085        self.constraints.zoom_range.set(range);
3086    }
3087
3088    /// The currently-declared zoom range. Live read.
3089    pub fn current_zoom_range(&self) -> Option<std::ops::RangeInclusive<f32>> {
3090        self.constraints.zoom_range.get()
3091    }
3092
3093    /// Reactive accessors for live observation.
3094    pub fn pan_axes_signal(&self) -> Signal<PanAxes> {
3095        self.constraints.pan_axes_signal()
3096    }
3097    /// Reactive pan-bounds signal.
3098    pub fn pan_bounds_signal(&self) -> Signal<Option<Rect>> {
3099        self.constraints.pan_bounds_signal()
3100    }
3101    /// Reactive zoom-range signal.
3102    pub fn zoom_range_signal(&self) -> Signal<Option<std::ops::RangeInclusive<f32>>> {
3103        self.constraints.zoom_range_signal()
3104    }
3105    /// Reactive zoomable on/off signal.
3106    pub fn zoomable_signal(&self) -> Signal<bool> {
3107        self.constraints.zoomable_signal()
3108    }
3109
3110    /// Read-only view of the full constraint bundle. Useful when
3111    /// passing all four signals to a custom view implementation.
3112    pub fn constraints(&self) -> &SceneConstraints {
3113        &self.constraints
3114    }
3115
3116    // -----------------------------------------------------------------
3117    // Z-order and parenting
3118    // -----------------------------------------------------------------
3119
3120    /// Set paint z-order for an entry. Higher z paints later (on top);
3121    /// equal-z falls back to insertion order. Default 0.0.
3122    ///
3123    /// Works for **both** tiers: lightweight items re-sort within their
3124    /// band on the next paint, and heavyweight widget entries restack the
3125    /// arena children on the next rebuild (the SceneView reorders
3126    /// `node.children` by z without recreating the widgets, so focus /
3127    /// text-edit / animation state survives the restack). No-op for
3128    /// unknown ids.
3129    ///
3130    /// # The no-op test is exact
3131    ///
3132    /// A write is ignored only when the entry already holds **that** value.
3133    /// The guard used to be an absolute `|old - z| < f32::EPSILON`, which is
3134    /// the wrong metric in both directions: `f32::EPSILON` is the spacing of
3135    /// the representable numbers at 1.0, so out at `z = 1e6` (where the real
3136    /// spacing is ~0.06) no two distinct floats are ever within it and the
3137    /// guard never fires, while near zero (where the spacing is ~1e-45) it
3138    /// swallows millions of distinct values.
3139    ///
3140    /// That second half was not theoretical: [`z_between`](Self::z_between)
3141    /// bisects **relatively** and existed precisely so a caller is never told
3142    /// "there is room" and then gets a silent no-op — and near zero the two
3143    /// disagreed, so `z_between` returned `Some(6.25e-8)` and `set_z` dropped
3144    /// it, emitting no [`ItemChange::ZChanged`] and leaving the order wrong
3145    /// with nothing to observe. One metric now, and it is the one `z_between`
3146    /// already used: two `z`s are the same iff they are the same float.
3147    pub fn set_z(&mut self, id: ItemId, z: f32) {
3148        if let Some(&pos) = self.entry_index.get(&id) {
3149            let old = self.entries[pos].z;
3150            if old == z {
3151                return;
3152            }
3153            self.entries[pos].z = z;
3154            self.emit_item_change(ItemChange::ZChanged { id, old, new: z });
3155        }
3156    }
3157
3158    /// Raise an entry above all current entries by giving it a z one
3159    /// greater than the current maximum. The drag-to-front primitive —
3160    /// call it on drag-start so the grabbed card (and its text) renders
3161    /// over the others. Works for both tiers (see [`set_z`](Self::set_z)).
3162    pub fn bring_to_front(&mut self, id: ItemId) {
3163        let Some(&pos) = self.entry_index.get(&id) else {
3164            return;
3165        };
3166        let own_z = self.entries[pos].z;
3167        let max_other = self
3168            .entries
3169            .iter()
3170            .filter(|e| e.id != id)
3171            .map(|e| e.z)
3172            .fold(f32::NEG_INFINITY, f32::max);
3173        // Already strictly on top: nothing to do, and saying so matters. A
3174        // click-to-front card is asked this on **every** tap, and an
3175        // unconditional `max + 1` made each one a `ZChanged`, a reconcile pass
3176        // and a rebuild — plus an unbounded march up the float range, which
3177        // eventually stops separating entries at all. *Strictly* on top, so two
3178        // entries tied at the maximum can still be separated.
3179        if own_z > max_other {
3180            return;
3181        }
3182        let target = if max_other.is_finite() {
3183            max_other + 1.0
3184        } else {
3185            1.0
3186        };
3187        self.set_z(id, target);
3188    }
3189
3190    /// Lower an entry below all current entries by giving it a z one less
3191    /// than the current minimum. Works for both tiers (see
3192    /// [`set_z`](Self::set_z)).
3193    pub fn send_to_back(&mut self, id: ItemId) {
3194        let Some(&pos) = self.entry_index.get(&id) else {
3195            return;
3196        };
3197        let own_z = self.entries[pos].z;
3198        let min_z = self
3199            .entries
3200            .iter()
3201            .filter(|e| e.id != id)
3202            .map(|e| e.z)
3203            .fold(f32::INFINITY, f32::min);
3204        // The mirror of `bring_to_front`'s guard, for the same reasons.
3205        if own_z < min_z {
3206            return;
3207        }
3208        let target = if min_z.is_finite() { min_z - 1.0 } else { -1.0 };
3209        self.set_z(id, target);
3210    }
3211
3212    /// Read an entry's z-order.
3213    pub fn z(&self, id: ItemId) -> Option<f32> {
3214        let pos = *self.entry_index.get(&id)?;
3215        Some(self.entries[pos].z)
3216    }
3217
3218    /// Set the paint band for a lightweight entry — see [`SceneLayer`] for
3219    /// what each one means and what [`Interleaved`](SceneLayer::Interleaved)
3220    /// costs. Within a band, [`set_z`](Self::set_z) still orders items among
3221    /// themselves. No-op for unknown ids.
3222    pub fn set_layer(&mut self, id: ItemId, layer: SceneLayer) {
3223        if let Some(&pos) = self.entry_index.get(&id) {
3224            let old = self.entries[pos].layer;
3225            if old == layer {
3226                return;
3227            }
3228            self.entries[pos].layer = layer;
3229            self.emit_item_change(ItemChange::LayerChanged {
3230                id,
3231                old,
3232                new: layer,
3233            });
3234        }
3235    }
3236
3237    /// Read an entry's paint band. `None` for unknown ids.
3238    pub fn layer(&self, id: ItemId) -> Option<SceneLayer> {
3239        let pos = *self.entry_index.get(&id)?;
3240        Some(self.entries[pos].layer)
3241    }
3242
3243    /// Whether any entry is in the [`SceneLayer::Over`] band. The
3244    /// SceneView consults this in `wants_post_paint` to skip the
3245    /// foreground pass entirely when nothing is raised above the cards.
3246    /// Linear in entry count, called once per frame.
3247    pub(crate) fn has_over_layer_items(&self) -> bool {
3248        self.entries.iter().any(|e| e.layer == SceneLayer::Over)
3249    }
3250
3251    /// Every lightweight entry in the [`SceneLayer::Interleaved`] band, in
3252    /// insertion order.
3253    ///
3254    /// The SceneView materialises one paint node per entry here and sorts it
3255    /// into the child list by [`paint_key`](Self::paint_key).
3256    ///
3257    /// Linear in entry count, unlike
3258    /// [`heavyweight_ids`](Self::heavyweight_ids), which clones a list the
3259    /// scene maintains. Deliberate: the heavyweight list is maintained because
3260    /// *every* scene has one, while `Interleaved` is a band most scenes never
3261    /// use, and a second always-maintained vector would cost every insertion to
3262    /// spare a scan the scenes that use it pay once per **build** — not per
3263    /// frame. A scene that leans on the band heavily wants a
3264    /// [`GroupItem`](crate::items::GroupItem) per layer rather than a banded
3265    /// entry per stroke anyway; see `docs/ink.md`.
3266    pub(crate) fn interleaved_ids(&self) -> Vec<ItemId> {
3267        self.entries
3268            .iter()
3269            .filter(|e| {
3270                e.layer == SceneLayer::Interleaved && matches!(e.kind, SceneEntryKind::Item(_))
3271            })
3272            .map(|e| e.id)
3273            .collect()
3274    }
3275
3276    /// Declare a parent/child relationship. `child`'s `local_pos`
3277    /// and `transform` are reinterpreted as relative to the new
3278    /// parent's local frame — the visual position changes unless
3279    /// the caller compensates. Re-buckets `child`'s subtree.
3280    ///
3281    /// Pass `parent = None` to detach (child's local frame becomes
3282    /// scene-rooted again).
3283    ///
3284    /// **Cycle guard:** if the proposed parent is `child` itself
3285    /// or a descendant of `child`, the call is a no-op (no parent
3286    /// change, no rebucket, no signal fire). Without this guard
3287    /// the downstream `rebucket_subtree` walk loops indefinitely.
3288    pub fn set_item_parent(&mut self, child: ItemId, parent: Option<ItemId>) {
3289        if let Some(&pos) = self.entry_index.get(&child) {
3290            let old = self.entries[pos].parent;
3291            if old == parent {
3292                return;
3293            }
3294            // Reject self-parent and any parent in the child's
3295            // subtree (would create a cycle).
3296            if let Some(p) = parent
3297                && (p == child || self.is_descendant_of(p, child))
3298            {
3299                return;
3300            }
3301            self.entries[pos].parent = parent;
3302            self.unlink_child(old, child);
3303            self.link_child(parent, child);
3304            self.rebucket_subtree(child);
3305            self.emit_item_change(ItemChange::ParentChanged {
3306                id: child,
3307                old,
3308                new: parent,
3309            });
3310        }
3311    }
3312
3313    /// Parent of `id`, if any.
3314    pub fn parent_of(&self, id: ItemId) -> Option<ItemId> {
3315        let pos = *self.entry_index.get(&id)?;
3316        self.entries[pos].parent
3317    }
3318
3319    /// Whether `id`'s ancestor chain contains `ancestor`.
3320    pub fn is_descendant_of(&self, id: ItemId, ancestor: ItemId) -> bool {
3321        let mut cur = self.parent_of(id);
3322        let cap = self.entries.len();
3323        let mut hops = 0;
3324        while let Some(p) = cur {
3325            if p == ancestor {
3326                return true;
3327            }
3328            cur = self.parent_of(p);
3329            hops += 1;
3330            if hops > cap {
3331                break;
3332            }
3333        }
3334        false
3335    }
3336
3337    /// Append every direct + transitive descendant of `id` into `out`, each
3338    /// parent before its own children. The id itself is **not** included.
3339    ///
3340    /// Costs the subtree, not the scene: the walk steps through the kept
3341    /// `SceneEntry::children` adjacency rather than rescanning every entry per
3342    /// visited node. A cycle in the parent graph is bounded by the visited set
3343    /// rather than looping forever.
3344    pub fn collect_descendants(&self, id: ItemId, out: &mut Vec<ItemId>) {
3345        let mut visited: HashSet<ItemId> = HashSet::new();
3346        visited.insert(id);
3347        let mut frontier: Vec<ItemId> = vec![id];
3348        let mut kids: Vec<ItemId> = Vec::new();
3349        while let Some(parent) = frontier.pop() {
3350            kids.clear();
3351            self.push_children(parent, &mut kids);
3352            for child in kids.iter().copied() {
3353                if !visited.insert(child) {
3354                    continue;
3355                }
3356                out.push(child);
3357                frontier.push(child);
3358            }
3359        }
3360    }
3361
3362    // -----------------------------------------------------------------
3363    // Geometry constraint
3364    // -----------------------------------------------------------------
3365
3366    /// Install the document's standing geometry rule — snap-to-grid, axis lock,
3367    /// page-bounds clamp — consulted before a **user-driven** gesture applies
3368    /// anything.
3369    ///
3370    /// Replaces any previous constraint; there is one per scene, because a
3371    /// document has one geometry. See [`ProposedChange`](crate::ProposedChange)
3372    /// for what the closure is handed, and
3373    /// [`SceneModel::set_geometry_constraint`](crate::SceneModel::set_geometry_constraint)
3374    /// for the door an app normally uses.
3375    ///
3376    /// ```
3377    /// use teksilo_canvas::{Point, Rect};
3378    /// use teksilo_scene::{ChangeVerdict, RectItem, Scene};
3379    ///
3380    /// let mut scene = Scene::new();
3381    /// scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 40.0, 40.0)), Point::ZERO);
3382    /// // A 25-unit grid, snapping the moved box's own top-leading corner.
3383    /// scene.set_geometry_constraint(|c| {
3384    ///     let mut f = c.proposed;
3385    ///     f.rect.x = (f.rect.x / 25.0).round() * 25.0;
3386    ///     f.rect.y = (f.rect.y / 25.0).round() * 25.0;
3387    ///     ChangeVerdict::Adjust(f)
3388    /// });
3389    /// assert!(scene.has_geometry_constraint());
3390    /// ```
3391    pub fn set_geometry_constraint(
3392        &mut self,
3393        f: impl Fn(&crate::constrain::ProposedChange<'_>) -> crate::constrain::ChangeVerdict + 'static,
3394    ) {
3395        self.geometry_constraint = Some(Rc::new(f));
3396    }
3397
3398    /// Remove the geometry constraint. Gestures then apply their raw proposal.
3399    pub fn clear_geometry_constraint(&mut self) {
3400        self.geometry_constraint = None;
3401    }
3402
3403    /// Whether a geometry constraint is installed.
3404    pub fn has_geometry_constraint(&self) -> bool {
3405        self.geometry_constraint.is_some()
3406    }
3407
3408    /// Whether a geometry constraint is running **right now** on this thread.
3409    ///
3410    /// The probe [`SceneModel::write_guard`](crate::SceneModel::write_guard)
3411    /// uses to turn "a constraint tried to write the scene" into a diagnostic
3412    /// that names the hook.
3413    pub fn in_geometry_constraint(&self) -> bool {
3414        self.constraint_running.get()
3415    }
3416
3417    /// The installed constraint, cloned. `None` when there is none — which is
3418    /// the whole cost an unconstrained scene pays per gesture sample.
3419    pub(crate) fn geometry_constraint(&self) -> Option<crate::constrain::GeometryConstraint> {
3420        self.geometry_constraint.clone()
3421    }
3422
3423    /// The running flag, for `ConstraintGuard`.
3424    pub(crate) fn constraint_running_cell(&self) -> &Cell<bool> {
3425        &self.constraint_running
3426    }
3427
3428    // -----------------------------------------------------------------
3429    // Selection transforms
3430    // -----------------------------------------------------------------
3431
3432    /// `ids` pruned to its **roots**: any item whose ancestor is also in `ids`
3433    /// is dropped.
3434    ///
3435    /// Moving an ancestor already moves its descendants — their `local_pos` is
3436    /// parent-relative and is not touched — so transforming both would apply
3437    /// the change twice. This is the mutation-side twin of the filter the paint
3438    /// preview already runs over the same set.
3439    ///
3440    /// Order is preserved. **O(n·depth)**: the set is hashed once and each id
3441    /// then walks its own ancestor chain, asking the set rather than asking
3442    /// every other id whether it is an ancestor. The hop cap is the one
3443    /// [`is_descendant_of`](Self::is_descendant_of) uses, so a malformed parent
3444    /// cycle bounds rather than hangs, and an id that is its own ancestor is
3445    /// kept (a cycle has no root to prefer).
3446    ///
3447    /// The complexity is load-bearing, not incidental: every selection-transform
3448    /// recompute runs this once (plus one
3449    /// [`transformable_roots`](Self::transformable_roots) per operation), and so
3450    /// does the item-drag group and the keyboard nudge — so a quadratic form
3451    /// here is a frozen window on a large selection rather than a slow one.
3452    /// `selection_roots_is_linear_in_the_selection`, in
3453    /// `tests/selection_roots_scaling_probe.rs`, pins it: 7.03 ms against
3454    /// 36.3 µs for a 1 000-item selection, measured.
3455    pub fn selection_roots(&self, ids: &[ItemId]) -> Vec<ItemId> {
3456        // One id cannot be pruned by itself, so the whole question is moot
3457        // below two — and a selection of one is the common case.
3458        if ids.len() < 2 {
3459            return ids.to_vec();
3460        }
3461        let set: HashSet<ItemId> = ids.iter().copied().collect();
3462        let cap = self.entries.len();
3463        ids.iter()
3464            .copied()
3465            .filter(|id| {
3466                let mut cur = self.parent_of(*id);
3467                let mut hops = 0usize;
3468                while let Some(p) = cur {
3469                    if p == *id {
3470                        // Its own ancestor: a cycle, not a nesting. Matches
3471                        // the old form, which never compared an id to itself.
3472                        break;
3473                    }
3474                    if set.contains(&p) {
3475                        return false;
3476                    }
3477                    cur = self.parent_of(p);
3478                    hops += 1;
3479                    if hops > cap {
3480                        break;
3481                    }
3482                }
3483                true
3484            })
3485            .collect()
3486    }
3487
3488    /// The roots of `ids` that may take part in `op`.
3489    ///
3490    /// Three filters, in this order. The item must carry the operation's flag
3491    /// ([`TransformOp::required_flag`](crate::TransformOp::required_flag)).
3492    /// For [`TransformOp::Rotate`](crate::TransformOp::Rotate) it must be a
3493    /// lightweight entry, because a heavyweight card is sized from the AABB of
3494    /// its transformed bounds and a rotation there inflates its layout box
3495    /// without turning anything. For
3496    /// [`TransformOp::Resize`](crate::TransformOp::Resize) it must own at least
3497    /// one axis of its own box: an entry on [`SizePolicy::Intrinsic`] has
3498    /// handed **both** to its widget, so every number a resize could write is
3499    /// one the next layout pass measures back over — a handle that produces a
3500    /// reversible step and no visible change is worse than no handle.
3501    /// [`SizePolicy::HeightForWidth`] still owns its width and is offered the
3502    /// full set; the height half of a corner drag is neutralised at the model
3503    /// door, in [`apply_transform_delta`](Self::apply_transform_delta).
3504    ///
3505    /// The flag is checked **before** the descendant pruning, so a selected
3506    /// child of a selected-but-locked parent still takes part on its own.
3507    pub fn transformable_roots(
3508        &self,
3509        ids: &[ItemId],
3510        op: crate::transform_session::TransformOp,
3511    ) -> Vec<ItemId> {
3512        use crate::transform_session::TransformOp;
3513        let flag = op.required_flag();
3514        let eligible: Vec<ItemId> = ids
3515            .iter()
3516            .copied()
3517            .filter(|id| {
3518                self.flags(*id).is_some_and(|f| f.contains(flag))
3519                    && (op != TransformOp::Rotate || self.item(*id).is_some())
3520                    && (op != TransformOp::Resize || self.size_policy(*id) != SizePolicy::Intrinsic)
3521            })
3522            .collect();
3523        self.selection_roots(&eligible)
3524    }
3525
3526    /// The item's own rotation in scene space, in radians — the angle of its
3527    /// composed `local → scene` basis. `None` for an unknown id.
3528    pub fn scene_rotation(&self, id: ItemId) -> Option<f32> {
3529        if !self.entry_index.contains_key(&id) {
3530            return None;
3531        }
3532        let m = self.scene_transform(id).m;
3533        Some(m[1].atan2(m[0]))
3534    }
3535
3536    /// The selection frame enclosing `roots` — the box a transform controller
3537    /// draws its handles on. `None` when `roots` is empty or none of them
3538    /// resolve.
3539    ///
3540    /// A **single** root's frame takes that item's own rotation, so resizing a
3541    /// rotated item happens along its own axes and is exact. A multi-item
3542    /// frame is axis-aligned, because the union of differently-rotated boxes
3543    /// has no well-defined angle — Konva does the same.
3544    pub fn transform_frame(
3545        &self,
3546        roots: &[ItemId],
3547    ) -> Option<crate::transform_session::TransformFrame> {
3548        use crate::transform_session::TransformFrame;
3549        let rotation = if roots.len() == 1 {
3550            self.scene_rotation(roots[0]).unwrap_or(0.0)
3551        } else {
3552            0.0
3553        };
3554        let to_frame = Transform2D::rotate(-rotation);
3555        let mut acc: Option<(f32, f32, f32, f32)> = None;
3556        let mut count = 0usize;
3557        for id in roots.iter().copied() {
3558            let Some(local) = self.local_bounds(id) else {
3559                continue;
3560            };
3561            let to_scene = self.scene_transform(id);
3562            count += 1;
3563            for corner in [
3564                Point::new(local.x, local.y),
3565                Point::new(local.right(), local.y),
3566                Point::new(local.right(), local.bottom()),
3567                Point::new(local.x, local.bottom()),
3568            ] {
3569                let p = to_frame.apply_point(to_scene.apply_point(corner));
3570                acc = Some(match acc {
3571                    None => (p.x, p.y, p.x, p.y),
3572                    Some((x0, y0, x1, y1)) => (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)),
3573                });
3574            }
3575        }
3576        let (x0, y0, x1, y1) = acc?;
3577        Some(TransformFrame {
3578            rect: Rect::new(x0, y0, x1 - x0, y1 - y0),
3579            rotation,
3580            count,
3581        })
3582    }
3583
3584    /// Apply `delta` to `roots` as a **single** operation, and the only place a
3585    /// transform controller writes the model.
3586    ///
3587    /// Returns how many entry fields actually changed.
3588    ///
3589    /// Three writes per root at most, and each is the existing setter, so the
3590    /// change stream, the spatial index and every observer see nothing new:
3591    ///
3592    /// * **position** — the item's scene anchor is mapped through the delta and
3593    ///   restated in its parent's frame, so a scale about a pivot moves it and a
3594    ///   rotation orbits it.
3595    /// * **extent** — `local_bounds` is scaled, which makes the item *reflow*
3596    ///   into the new box rather than be drawn at a stretched scale. The scale
3597    ///   is resolved along the item's **own** axes, so a rotated item stays a
3598    ///   rotated rectangle instead of shearing into a parallelogram; where the
3599    ///   scale is non-uniform and the item is rotated relative to the frame,
3600    ///   that is an approximation of the true (unrepresentable) result, and it
3601    ///   is exact whenever the item is aligned with the frame — which includes
3602    ///   every single-item selection. **An axis the entry's
3603    ///   [`SizePolicy`] hands to its widget is not scaled**, and is not scaled
3604    ///   for the anchor either, so a card whose height its content owns neither
3605    ///   resizes nor *moves* when someone drags its top edge; see the comment at
3606    ///   the loop head.
3607    /// * **orientation** — the item's own `Transform2D` is post-rotated. Skipped
3608    ///   for a heavyweight entry, whose layout box is the AABB of its
3609    ///   transformed bounds: a rotation there would inflate the box and turn
3610    ///   nothing.
3611    ///
3612    /// The transaction boundary is this call. One gesture is one call, so an
3613    /// app-level reversible-edit layer has exactly one thing to record — and
3614    /// this crate ships no history of its own.
3615    pub fn apply_transform_delta(
3616        &mut self,
3617        roots: &[ItemId],
3618        delta: &crate::transform_session::TransformDelta,
3619    ) -> usize {
3620        if delta.is_identity() {
3621            return 0;
3622        }
3623        let group_delta = delta.to_scene_transform();
3624        let rotating = delta.rotation.abs() > 1e-6;
3625        let mut changed = 0usize;
3626        for id in roots.iter().copied() {
3627            let Some(&pos) = self.entry_index.get(&id) else {
3628                continue;
3629            };
3630            let is_lightweight = matches!(self.entries[pos].kind, SceneEntryKind::Item(_));
3631            // **A resize does not reach an axis whose size this entry's
3632            // [`SizePolicy`] has handed to its content.** Scaling such an axis
3633            // writes a number the next layout pass measures straight back over,
3634            // so the gesture's whole contribution to the change stream is one
3635            // reversible step that undoes nothing.
3636            //
3637            // Neutralised *here*, in the scale, rather than only skipped at the
3638            // bounds write below, because the anchor is derived from the same
3639            // transform: a card whose height its content owns must not **move**
3640            // because someone dragged its top edge. The other half of the rule
3641            // lives in [`transformable_roots`](Self::transformable_roots),
3642            // which stops a controller offering handles that would do nothing.
3643            let scale = match self.entries[pos].size_policy {
3644                SizePolicy::Fixed => delta.scale,
3645                SizePolicy::HeightForWidth => Vec2::new(delta.scale.x, 1.0),
3646                SizePolicy::Intrinsic => Vec2::new(1.0, 1.0),
3647            };
3648            let scaling = (scale.x - 1.0).abs() > 1e-6 || (scale.y - 1.0).abs() > 1e-6;
3649            let scene_delta = if scale == delta.scale {
3650                group_delta
3651            } else {
3652                crate::transform_session::TransformDelta { scale, ..*delta }.to_scene_transform()
3653            };
3654            let parent = self.entries[pos].parent;
3655            let parent_xform = match parent {
3656                Some(p) => self.scene_transform(p),
3657                None => Transform2D::identity(),
3658            };
3659            let Some(parent_inv) = parent_xform.inverse() else {
3660                continue;
3661            };
3662            let old_anchor = self.scene_transform(id).apply_point(Point::ZERO);
3663            let new_anchor = scene_delta.apply_point(old_anchor);
3664
3665            // Orientation first: the item's own transform decides where its
3666            // local origin sits inside its `local → parent` map, and the new
3667            // position is derived against the *new* one.
3668            let old_transform = self.entries[pos].transform;
3669            let new_transform = if rotating && is_lightweight {
3670                old_transform.then(&Transform2D::rotate(delta.rotation))
3671            } else {
3672                old_transform
3673            };
3674            let rotated = new_transform != old_transform;
3675            if rotated {
3676                self.entries[pos].transform = new_transform;
3677                changed += 1;
3678                // Announced here, not only on the no-move path below: a
3679                // rotation that also moved the item used to emit nothing but
3680                // `LocalPosChanged`, so a consumer reconstructing the edit from
3681                // the change stream lost the rotation entirely.
3682                self.rebucket_subtree(id);
3683                self.emit_item_change(ItemChange::TransformChanged {
3684                    id,
3685                    old: old_transform,
3686                    new: new_transform,
3687                });
3688            }
3689            let origin_offset = new_transform.apply_point(Point::ZERO);
3690            let target_local = parent_inv.apply_point(new_anchor);
3691            let new_local_pos = Point::new(
3692                target_local.x - origin_offset.x,
3693                target_local.y - origin_offset.y,
3694            );
3695
3696            if scaling {
3697                // The scale arrives in the frame's basis; resolve it onto the
3698                // item's own axes. `|cos| · sx + |sin| · sy` is exact at 0° and
3699                // at 90° (where the axes simply swap) and interpolates
3700                // continuously between them.
3701                let phi = self.scene_rotation(id).unwrap_or(0.0) - delta.basis;
3702                let (sin, cos) = phi.sin_cos();
3703                let (ac, as_) = (cos.abs(), sin.abs());
3704                let sx = ac * scale.x + as_ * scale.y;
3705                let sy = as_ * scale.x + ac * scale.y;
3706                let b = self.entries[pos].local_bounds;
3707                let scaled = Rect::new(b.x * sx, b.y * sy, b.width * sx, b.height * sy);
3708                if scaled != b {
3709                    self.set_local_bounds(id, scaled);
3710                    changed += 1;
3711                }
3712            }
3713
3714            let old_pos = self.entries[pos].local_pos;
3715            if new_local_pos != old_pos {
3716                self.set_local_pos(id, new_local_pos);
3717                changed += 1;
3718            }
3719        }
3720        changed
3721    }
3722
3723    // -----------------------------------------------------------------
3724    // Lookup
3725    // -----------------------------------------------------------------
3726
3727    /// Borrow a lightweight [`SceneItem`] by id. `None` for unknown
3728    /// ids and for heavyweight widget entries.
3729    pub fn item(&self, id: ItemId) -> Option<&dyn SceneItem> {
3730        let pos = *self.entry_index.get(&id)?;
3731        match &self.entries[pos].kind {
3732            SceneEntryKind::Item(item) => Some(item.as_ref()),
3733            SceneEntryKind::Widget(_) => None,
3734        }
3735    }
3736
3737    /// Where `id` sits in this scene's single paint order — the value every
3738    /// picker in the crate compares. `None` for unknown ids.
3739    ///
3740    /// Defined for **both tiers**: a lightweight entry's rank is its
3741    /// [`SceneLayer`] band, and a heavyweight widget entry's is
3742    /// [`RANK_WIDGET`](crate::pick::RANK_WIDGET) — which is exactly where the
3743    /// arena's child walk paints it, between the `Under` and `Over` bands. An
3744    /// [`Interleaved`](SceneLayer::Interleaved) item shares that rank, so it
3745    /// sorts against the cards by `z`. See [`PaintKey`] for the ordering and
3746    /// for the equal-`z` tie-break.
3747    pub fn paint_key(&self, id: ItemId) -> Option<PaintKey> {
3748        let pos = *self.entry_index.get(&id)?;
3749        let entry = &self.entries[pos];
3750        let rank = match entry.kind {
3751            SceneEntryKind::Widget(_) => crate::pick::RANK_WIDGET,
3752            SceneEntryKind::Item(_) => match entry.layer {
3753                SceneLayer::Under => crate::pick::RANK_UNDER,
3754                // The same rank a card gets, so the two are ordered against
3755                // each other by `z` — which is the whole point of the band.
3756                SceneLayer::Interleaved => crate::pick::RANK_WIDGET,
3757                SceneLayer::Over => crate::pick::RANK_OVER,
3758            },
3759        };
3760        Some(PaintKey::new(rank, entry.z, id.as_u64()))
3761    }
3762
3763    /// Sort `ids` into paint order — bottom-most first, so a later element
3764    /// paints on top. Stable and total (see [`PaintKey`]).
3765    ///
3766    /// Crate-private helper for `SceneView::paint_band`. An unknown id sorts
3767    /// with the lowest possible key rather than being dropped, so a sort can
3768    /// never lose an entry.
3769    pub(crate) fn sort_by_paint_key(&self, ids: &mut [ItemId]) {
3770        ids.sort_by_key(|id| {
3771            self.paint_key(*id)
3772                .unwrap_or_else(|| PaintKey::new(crate::pick::RANK_UNDER, 0.0, id.as_u64()))
3773        });
3774    }
3775
3776    /// [`Scene::sort_by_paint_key`] reversed — **topmost first**, the order
3777    /// every hit test walks.
3778    pub(crate) fn sort_by_paint_key_desc(&self, ids: &mut [ItemId]) {
3779        self.sort_by_paint_key(ids);
3780        ids.reverse();
3781    }
3782
3783    // -----------------------------------------------------------------
3784    // Removal
3785    // -----------------------------------------------------------------
3786
3787    /// Remove an item by id, recursively dropping every descendant.
3788    ///
3789    /// Mirrors Qt's `QGraphicsScene::removeItem` semantics: deleting
3790    /// a parent deletes its children too. No-op if `id` is unknown.
3791    /// Fires one [`ItemChange::Removed`] per id, descendants first
3792    /// then the named parent — observers see a consistent
3793    /// "leaves-then-root" order.
3794    ///
3795    /// To remove `id` without deleting its children, call
3796    /// [`Scene::orphan`] first to promote them to root-level, then
3797    /// `remove(id)`.
3798    ///
3799    /// # What happens to what it destroyed
3800    ///
3801    /// This is [`Scene::take`] with the salvage routed to the edit sink
3802    /// instead of to the caller — identical work, identical events. With a
3803    /// sink installed
3804    /// ([`SceneModel::set_edit_sink`](crate::SceneModel::set_edit_sink)), each
3805    /// removed entry arrives as a
3806    /// [`SceneEdit::Removed`] carrying its
3807    /// [`RemovedItem`], which [`Scene::restore`] puts back. With none, the
3808    /// salvage is dropped, which is what a removal has always done.
3809    pub fn remove(&mut self, id: ItemId) {
3810        let salvage = self.take_inner(id);
3811        if salvage.is_empty() {
3812            return;
3813        }
3814        if self.journal.recording() {
3815            for item in salvage {
3816                let id = item.id();
3817                self.journal.record(SceneEdit::Removed {
3818                    id,
3819                    salvage: Salvage::Owned(Box::new(item)),
3820                });
3821            }
3822        }
3823    }
3824
3825    /// Remove `id` and every descendant, **handing the salvage back** instead
3826    /// of dropping it — item box, handlers, magnets and logical-AT decorations
3827    /// intact, each still carrying its original [`ItemId`].
3828    ///
3829    /// The same work and the same events as [`Scene::remove`]; the difference
3830    /// is who ends up owning what was removed. Feed a result to
3831    /// [`Scene::restore_all`] to put the subtree back.
3832    ///
3833    /// # Order
3834    ///
3835    /// Deepest descendants first, the named root last — the order `remove`
3836    /// announces in. [`Scene::restore_all`] reverses it; restoring by hand
3837    /// means roots first.
3838    ///
3839    /// ```
3840    /// use teksilo_canvas::{Point, Rect};
3841    /// use teksilo_scene::{RectItem, Scene};
3842    ///
3843    /// let mut scene = Scene::new();
3844    /// let card = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 40.0, 40.0)), Point::ZERO);
3845    /// let salvage = scene.take(card);
3846    /// assert_eq!(scene.len(), 0);
3847    ///
3848    /// // Back at the same id, so selection, magnets and AT parenting still resolve.
3849    /// let restored = scene.restore_all(salvage).unwrap();
3850    /// assert_eq!(restored, vec![card]);
3851    /// assert_eq!(scene.len(), 1);
3852    /// ```
3853    #[must_use = "the salvage is the only copy of the removed items; dropping it \
3854                  makes the removal irreversible — call Scene::remove if that is \
3855                  what you meant"]
3856    pub fn take(&mut self, id: ItemId) -> Vec<RemovedItem> {
3857        let salvage = self.take_inner(id);
3858        if self.journal.recording() {
3859            for item in &salvage {
3860                self.journal.record(SceneEdit::Removed {
3861                    id: item.id(),
3862                    salvage: Salvage::TakenByCaller,
3863                });
3864            }
3865        }
3866        salvage
3867    }
3868
3869    /// The one removal path. Lifts `id`'s subtree out of the scene whole and
3870    /// returns it; [`Scene::remove`] and [`Scene::take`] differ only in where
3871    /// the result goes.
3872    ///
3873    /// Written as one function on purpose: a `remove` that re-implemented the
3874    /// teardown would be free to forget a side map, and the salvage would then
3875    /// be a faithful copy of everything except the thing that was forgotten.
3876    fn take_inner(&mut self, id: ItemId) -> Vec<RemovedItem> {
3877        if !self.entry_index.contains_key(&id) {
3878            return Vec::new();
3879        }
3880        // A subtree removal is one logical edit, and this is the one mutator
3881        // that emits many changes from a single call — so it opens its own
3882        // scope rather than relying on the caller's. Through a `SceneModel` it
3883        // simply nests inside the write scope already open; through a bare
3884        // `&mut Scene`, which opens none, it is what keeps the removal from
3885        // being N unrelated transactions. Costs nothing when nothing is
3886        // recording: a transaction with no edits is never queued.
3887        let scope = RemovalScope::new(&self.journal);
3888        // Descendants, deepest-first via collect_descendants's BFS
3889        // (the order is leaf-to-root because we push children as we
3890        // visit each parent). Append the named id last.
3891        let mut to_remove: Vec<ItemId> = Vec::new();
3892        self.collect_descendants(id, &mut to_remove);
3893        to_remove.reverse();
3894        to_remove.push(id);
3895        let removal_set: HashSet<ItemId> = to_remove.iter().copied().collect();
3896        // Detach the subtree from whatever survives above it, before the
3897        // entries go: the `parent` ⇄ `children` invariant has to hold for the
3898        // surviving parent, whose list would otherwise name a dead id and send
3899        // the next `rebucket_subtree` walking into an entry that no longer
3900        // exists. Descendants are removed together with their parents, so only
3901        // the named root can have a surviving parent — the loop is written
3902        // against every removed id anyway, so a partial removal could never
3903        // leave a stale link.
3904        //
3905        // The removed root keeps its own `parent` field, because that is what a
3906        // restore needs in order to put it back where it was.
3907        for removed_id in &to_remove {
3908            let parent = self
3909                .entry_index
3910                .get(removed_id)
3911                .map(|&pos| self.entries[pos].parent)
3912                .unwrap_or(None);
3913            if let Some(parent) = parent
3914                && !removal_set.contains(&parent)
3915            {
3916                self.unlink_child(Some(parent), *removed_id);
3917            }
3918        }
3919
3920        // Declaration order, read before the entries move. This is the order
3921        // the accessibility walk publishes siblings in — the order a screen
3922        // reader reads the scene in — so a restore puts the entry back at its
3923        // index rather than silently appending it to the end of the reading
3924        // order.
3925        let mut orders: HashMap<ItemId, usize> = HashMap::with_capacity(removal_set.len());
3926        for removed_id in &removal_set {
3927            if let Some(&pos) = self.entry_index.get(removed_id) {
3928                orders.insert(*removed_id, pos);
3929            }
3930        }
3931
3932        let mut decorations = self.harvest_a11y(&removal_set);
3933
3934        let mut magnets: HashMap<ItemId, Vec<(MagnetId, Magnet)>> = HashMap::new();
3935        for removed_id in &removal_set {
3936            // Magnets are local to the item, so a removed item takes them with
3937            // it — ids included, which is what lets a consumer keying
3938            // connections on `MagnetId` survive a restore.
3939            if let Some(attached) = self.magnets.remove(removed_id) {
3940                for (mid, _) in &attached {
3941                    self.magnet_owner.remove(mid);
3942                }
3943                magnets.insert(*removed_id, attached);
3944            }
3945        }
3946
3947        // Partition rather than `retain`: the entries are moved out, not
3948        // dropped. That single change is what makes every removal salvageable,
3949        // and it costs the same walk.
3950        let mut taken: HashMap<ItemId, SceneEntry> = HashMap::with_capacity(removal_set.len());
3951        let mut kept: Vec<SceneEntry> = Vec::with_capacity(self.entries.len() - removal_set.len());
3952        for entry in self.entries.drain(..) {
3953            if removal_set.contains(&entry.id) {
3954                taken.insert(entry.id, entry);
3955            } else {
3956                kept.push(entry);
3957            }
3958        }
3959        self.entries = kept;
3960        self.heavyweight.retain(|id| !removal_set.contains(id));
3961        self.dynamic.retain(|id| !removal_set.contains(id));
3962        self.entry_index.clear();
3963        for (pos, entry) in self.entries.iter().enumerate() {
3964            self.entry_index.insert(entry.id, pos);
3965        }
3966
3967        let mut salvage = Vec::with_capacity(to_remove.len());
3968        for removed_id in to_remove {
3969            self.index.remove(removed_id);
3970            if let Some(entry) = taken.remove(&removed_id) {
3971                salvage.push(RemovedItem {
3972                    entry,
3973                    entry_order: orders.get(&removed_id).copied().unwrap_or(usize::MAX),
3974                    magnets: magnets.remove(&removed_id).unwrap_or_default(),
3975                    a11y: decorations.remove(&removed_id).unwrap_or_default(),
3976                });
3977            }
3978            self.emit_item_change(ItemChange::Removed { id: removed_id });
3979        }
3980        drop(scope);
3981        salvage
3982    }
3983
3984    /// Lift every logical-AT decoration that a removal of `removal_set` would
3985    /// destroy out of the scene's maps, partitioned by the removed item it
3986    /// belongs to.
3987    ///
3988    /// **Both directions of every edge.** A removal cuts an item's own AT
3989    /// parent (`removed → parent`) *and* every surviving node that was
3990    /// AT-parented under it (`survivor → removed`) — the second re-roots those
3991    /// survivors at the view root, which is correct while the item is gone and
3992    /// has to be undone when it comes back. Recording only the first would make
3993    /// a restore silently fail to re-adopt them, i.e. fail at exactly the case
3994    /// this salvage exists for. The same applies to relations, which a removal
3995    /// drops on either endpoint.
3996    ///
3997    /// A relation between two removed items is recorded once, on the `from`
3998    /// endpoint's salvage, so restoring the pair does not duplicate it.
3999    fn harvest_a11y(
4000        &mut self,
4001        removal_set: &HashSet<ItemId>,
4002    ) -> HashMap<ItemId, ItemA11yDecorations> {
4003        let mut out: HashMap<ItemId, ItemA11yDecorations> = HashMap::new();
4004        let removed_item = |n: &A11yNode| match n {
4005            A11yNode::Item(i) if removal_set.contains(i) => Some(*i),
4006            _ => None,
4007        };
4008
4009        if !self.a11y_parents.is_empty() {
4010            let mut cut: Vec<A11yNode> = Vec::new();
4011            for (child, parent) in self.a11y_parents.iter() {
4012                if let Some(cid) = removed_item(child) {
4013                    out.entry(cid).or_default().parent = Some(*parent);
4014                    cut.push(*child);
4015                } else if let Some(pid) = removed_item(parent) {
4016                    out.entry(pid).or_default().adopted.push(*child);
4017                    cut.push(*child);
4018                }
4019            }
4020            for child in cut {
4021                self.a11y_parents.remove(&child);
4022            }
4023        }
4024
4025        if !self.a11y_relations.is_empty() {
4026            let all = std::mem::take(&mut self.a11y_relations);
4027            let mut survivors = Vec::with_capacity(all.len());
4028            for (from, relation, to) in all {
4029                match removed_item(&from).or_else(|| removed_item(&to)) {
4030                    Some(owner) => out
4031                        .entry(owner)
4032                        .or_default()
4033                        .relations
4034                        .push((from, relation, to)),
4035                    None => survivors.push((from, relation, to)),
4036                }
4037            }
4038            self.a11y_relations = survivors;
4039        }
4040
4041        for removed_id in removal_set {
4042            let node = A11yNode::Item(*removed_id);
4043            let live = self.a11y_live.remove(&node);
4044            let landmark = self.a11y_landmarks.remove(&node);
4045            let categories = self.a11y_categories.remove(&node);
4046            if live.is_none() && landmark.is_none() && categories.is_none() {
4047                continue;
4048            }
4049            let slot = out.entry(*removed_id).or_default();
4050            slot.live = live;
4051            slot.landmark = landmark;
4052            slot.categories = categories.unwrap_or_default();
4053        }
4054        out
4055    }
4056
4057    /// Re-insert a salvaged entry **at its original [`ItemId`]**, with its
4058    /// magnets and its logical-AT decorations.
4059    ///
4060    /// # Identity is the point
4061    ///
4062    /// [`SceneSelection`](crate::SceneSelection) is keyed by `ItemId`,
4063    /// `MagnetId → ItemId` is keyed by it, the whole logical AT tree is keyed
4064    /// by `A11yNode::Item(ItemId)`, and — because a [`SceneItem`] has no
4065    /// downcast — an app's own side map is the *only* way to reach item-specific
4066    /// state, so it is keyed by it too. A restore that minted a fresh id would
4067    /// restore the pixels and lose every one of them.
4068    ///
4069    /// # Order
4070    ///
4071    /// Roots before children: a salvage naming a parent that is not in the
4072    /// scene fails with [`RestoreError::MissingParent`].
4073    /// [`Scene::restore_all`] orders a whole [`Scene::take`] result for you.
4074    ///
4075    /// # What comes back, and what does not
4076    ///
4077    /// Geometry, transform, z, layer, parent link, flags, opacity, handlers,
4078    /// magnets (ids included) and every logical-AT decoration whose other
4079    /// endpoint is still alive. The entry returns to its recorded position in
4080    /// declaration order when that index still exists, so the accessibility
4081    /// reading order is preserved rather than the item being appended last.
4082    ///
4083    /// An edge to a node that has since been removed is **not** re-attached:
4084    /// re-inserting it would name something the AT walker cannot resolve. Edges
4085    /// are therefore attached **after** the entry is in, and — under
4086    /// [`restore_all`](Self::restore_all) — after the whole batch is in, so an
4087    /// edge between two items of one removed subtree is not dropped merely
4088    /// because its far end had not arrived yet.
4089    ///
4090    /// A single-view heavyweight widget (`Scene::add_widget`) whose one
4091    /// `Box<dyn Widget>` a view already drained restores as an entry with no
4092    /// instance to materialise — unless the take and the restore happen inside
4093    /// one build cycle, in which case the view's orphan reap never runs and the
4094    /// arena instance survives. The restore is accepted either way, because
4095    /// refusing it would refuse the restores that work; see
4096    /// [`RemovedItem::widget_instance_present`]. Content that must survive an
4097    /// undo in every view goes in through
4098    /// [`SceneModel::add_widget_item`](crate::SceneModel::add_widget_item).
4099    ///
4100    /// # Restoring into a *different* scene is supported
4101    ///
4102    /// A [`RemovedItem`] taken from one scene may be restored into another,
4103    /// and that is the move-between-documents door: cut a card out of one
4104    /// board with [`take`](Self::take), restore it into a second, and it
4105    /// arrives whole — item box, geometry, flags, handlers and magnets, at the
4106    /// same [`ItemId`].
4107    ///
4108    /// Nothing special makes it work and nothing needs to refuse it.
4109    /// [`ItemId`] is minted from a process-global counter, so an id from
4110    /// another scene can never collide with one here; a salvage carries its
4111    /// whole entry rather than an index into the scene it came from; and the
4112    /// three consequences of crossing the boundary are exactly the rules that
4113    /// already apply within one scene:
4114    ///
4115    /// * **A parented salvage is refused** with
4116    ///   [`RestoreError::MissingParent`], because the parent it names is still
4117    ///   in the other scene. Move the parent first and the child follows — the
4118    ///   same roots-before-children rule, doing the same job — or call
4119    ///   [`RemovedItem::detach`] to bring the item over as a root.
4120    /// * **Logical-AT edges whose far end is not here are dropped**, by the
4121    ///   same test that drops an edge to a since-removed node.
4122    /// * **Declaration order is clamped**: the recorded index is where the
4123    ///   entry sat in the *source* scene's reading order, and a shorter target
4124    ///   takes it at the end.
4125    ///
4126    /// The salvage is consumed either way, so a refused move does not leave a
4127    /// second copy behind — but it does drop the item, so check the `Result`.
4128    pub fn restore(&mut self, salvage: RemovedItem) -> Result<ItemId, RestoreError> {
4129        let mut edges = Vec::new();
4130        let result = self.restore_entry(salvage, &mut edges);
4131        // Attached even on the error path: a failure to restore *this* salvage
4132        // must not strand edges an earlier one already deferred.
4133        self.attach_a11y_edges(edges);
4134        result
4135    }
4136
4137    /// Restore a whole [`Scene::take`] result, roots first.
4138    ///
4139    /// `take` hands back leaves-then-root; restoring in that order would fail
4140    /// the first child on [`RestoreError::MissingParent`], so this reverses it
4141    /// for you — which also puts each parent's `children` list back in its
4142    /// original order.
4143    ///
4144    /// Logical-AT edges are attached once the **whole batch** is in, so an edge
4145    /// between two items of the removed subtree survives whatever order the two
4146    /// ends land in.
4147    ///
4148    /// Stops at the first failure and returns it; everything restored before it
4149    /// **stays restored**, because a rollback here would be the framework
4150    /// applying an inverse, which is the data layer's job.
4151    pub fn restore_all(&mut self, salvage: Vec<RemovedItem>) -> Result<Vec<ItemId>, RestoreError> {
4152        let mut restored = Vec::with_capacity(salvage.len());
4153        let mut edges = Vec::new();
4154        let mut failure = None;
4155        for item in salvage.into_iter().rev() {
4156            match self.restore_entry(item, &mut edges) {
4157                Ok(id) => restored.push(id),
4158                Err(err) => {
4159                    failure = Some(err);
4160                    break;
4161                }
4162            }
4163        }
4164        self.attach_a11y_edges(edges);
4165        match failure {
4166            Some(err) => Err(err),
4167            None => Ok(restored),
4168        }
4169    }
4170
4171    /// Put one salvaged entry back and re-attach everything that depends on
4172    /// nothing else: magnets, live-region status, landmark role, categories.
4173    ///
4174    /// Its logical-AT **edges** are pushed onto `edges` instead, because an
4175    /// edge's far end may be another salvage in the same batch that has not
4176    /// been restored yet — and an edge dropped for that reason would be the
4177    /// silent a11y loss this whole door exists to prevent.
4178    fn restore_entry(
4179        &mut self,
4180        salvage: RemovedItem,
4181        edges: &mut Vec<(ItemId, ItemA11yDecorations)>,
4182    ) -> Result<ItemId, RestoreError> {
4183        let id = salvage.entry.id;
4184        if self.entry_index.contains_key(&id) {
4185            return Err(RestoreError::IdAlreadyLive(id));
4186        }
4187        if let Some(parent) = salvage.entry.parent
4188            && !self.entry_index.contains_key(&parent)
4189        {
4190            return Err(RestoreError::MissingParent { id, parent });
4191        }
4192
4193        let RemovedItem {
4194            mut entry,
4195            entry_order,
4196            magnets,
4197            mut a11y,
4198        } = salvage;
4199        // `link_child` rebuilds this list as each child is restored after its
4200        // parent, so keeping the salvaged copy would double every entry — and a
4201        // child that is never restored would leave a link to nothing.
4202        entry.children.clear();
4203        self.push_entry_at(entry, entry_order);
4204
4205        if !magnets.is_empty() {
4206            for (mid, _) in &magnets {
4207                self.magnet_owner.insert(*mid, id);
4208            }
4209            self.magnets.insert(id, magnets);
4210        }
4211
4212        let node = A11yNode::Item(id);
4213        let mut touched = false;
4214        if let Some(live) = a11y.live.take() {
4215            self.a11y_live.insert(node, live);
4216            touched = true;
4217        }
4218        if let Some(landmark) = a11y.landmark.take() {
4219            self.a11y_landmarks.insert(node, landmark);
4220            touched = true;
4221        }
4222        if !a11y.categories.is_empty() {
4223            self.a11y_categories
4224                .insert(node, std::mem::take(&mut a11y.categories));
4225            touched = true;
4226        }
4227        if touched {
4228            self.bump_a11y_change(node);
4229        }
4230        if a11y.parent.is_some() || !a11y.adopted.is_empty() || !a11y.relations.is_empty() {
4231            edges.push((id, a11y));
4232        }
4233        Ok(id)
4234    }
4235
4236    /// Re-attach the deferred logical-AT edges, skipping every one whose other
4237    /// endpoint is not in the scene.
4238    fn attach_a11y_edges(&mut self, edges: Vec<(ItemId, ItemA11yDecorations)>) {
4239        for (id, a11y) in edges {
4240            let node = A11yNode::Item(id);
4241            if let Some(parent) = a11y.parent
4242                && self.a11y_node_exists(parent)
4243            {
4244                self.a11y_parents.insert(node, parent);
4245            }
4246            for child in a11y.adopted {
4247                if self.a11y_node_exists(child) {
4248                    self.a11y_parents.insert(child, node);
4249                }
4250            }
4251            for (from, relation, to) in a11y.relations {
4252                if self.a11y_node_exists(from) && self.a11y_node_exists(to) {
4253                    self.a11y_relations.push((from, relation, to));
4254                }
4255            }
4256            self.bump_a11y_change(node);
4257        }
4258    }
4259
4260    /// Whether a logical-AT node is resolvable in this scene right now.
4261    ///
4262    /// A `Widget` node addresses the arena rather than the scene, so the scene
4263    /// cannot check it and does not pretend to — an edge to one is re-attached
4264    /// and the AT walker skips it if the widget is gone, exactly as it does for
4265    /// one the app declared directly.
4266    fn a11y_node_exists(&self, node: A11yNode) -> bool {
4267        match node {
4268            A11yNode::Item(id) => self.entry_index.contains_key(&id),
4269            A11yNode::Group(id) => self.a11y_group_index.contains_key(&id),
4270            A11yNode::Widget(_) => true,
4271        }
4272    }
4273
4274    /// Swap the lightweight item box at `id`, keeping the entry — and the
4275    /// [`ItemId`]. Returns the box that was there.
4276    ///
4277    /// Position, transform, z, layer, parent, flags, opacity, handlers, magnets
4278    /// and logical-AT decorations all survive, so a data row whose *content*
4279    /// changed does not lose its selection membership, its connections or its
4280    /// place in the accessibility tree. Emits one
4281    /// [`ItemChange::ItemReplaced`], not `Removed` + `Added`.
4282    ///
4283    /// # Bounds and the spatial index
4284    ///
4285    /// The entry's AABB is **derived from the item**, so the new item's
4286    /// `local_bounds` is read and the subtree re-bucketed. Skipping that would
4287    /// leave the old rectangle in the index and quietly break
4288    /// [`items_in_rect`](Self::items_in_rect), [`item_at`](Self::item_at), the
4289    /// cull path and every cached hit snapshot.
4290    ///
4291    /// # Flags
4292    ///
4293    /// The **entry's** flags are kept; the replacement's
4294    /// [`initial_flags`](SceneItem::initial_flags) are not consulted. They are
4295    /// *initial* flags — they apply where an item is inserted — and an app that
4296    /// has since called [`set_visible`](Self::set_visible) or
4297    /// [`set_flag`](Self::set_flag) would otherwise have that silently undone
4298    /// by a content refresh. Call [`set_flags`](Self::set_flags) afterwards to
4299    /// adopt the new item's instead.
4300    ///
4301    /// # Errors
4302    ///
4303    /// [`ReplaceItemError::UnknownItem`] for an id that is not in the scene,
4304    /// [`ReplaceItemError::NotLightweight`] for a heavyweight widget entry —
4305    /// swap a `Delegated` entry's *data* with
4306    /// [`SceneModel::set_payload`](crate::SceneModel::set_payload) instead.
4307    ///
4308    /// Either way the [`ReplaceRejected`] hands the item **back**: a refused
4309    /// write must not eat a value the caller cannot clone and may have paid to
4310    /// build.
4311    pub fn replace_item(
4312        &mut self,
4313        id: ItemId,
4314        item: Box<dyn SceneItem>,
4315    ) -> Result<Box<dyn SceneItem>, ReplaceRejected> {
4316        let Some(&pos) = self.entry_index.get(&id) else {
4317            return Err(ReplaceRejected {
4318                error: ReplaceItemError::UnknownItem(id),
4319                item,
4320            });
4321        };
4322        if !matches!(self.entries[pos].kind, SceneEntryKind::Item(_)) {
4323            return Err(ReplaceRejected {
4324                error: ReplaceItemError::NotLightweight(id),
4325                item,
4326            });
4327        }
4328        let new_bounds = item.local_bounds();
4329        let old_bounds = self.entries[pos].local_bounds;
4330        let SceneEntryKind::Item(slot) = &mut self.entries[pos].kind else {
4331            unreachable!("checked above")
4332        };
4333        let previous = std::mem::replace(slot, item);
4334        self.entries[pos].local_bounds = new_bounds;
4335        self.rebucket_subtree(id);
4336        self.emit_item_change(ItemChange::ItemReplaced {
4337            id,
4338            old_bounds,
4339            new_bounds,
4340        });
4341        Ok(previous)
4342    }
4343
4344    // -----------------------------------------------------------------
4345    // Placement (parent + z + position + transform, atomically)
4346    // -----------------------------------------------------------------
4347
4348    /// Where `id` sits, as one value. `None` for an unknown id.
4349    pub fn placement(&self, id: ItemId) -> Option<Placement> {
4350        let pos = *self.entry_index.get(&id)?;
4351        let entry = &self.entries[pos];
4352        Some(Placement {
4353            parent: entry.parent,
4354            z: entry.z,
4355            local_pos: entry.local_pos,
4356            transform: entry.transform,
4357        })
4358    }
4359
4360    /// Write parent, z, position and transform **together**, emitting one
4361    /// [`ItemChange::PlacementChanged`].
4362    ///
4363    /// The atomic alternative to four mutators and four events. No-op when the
4364    /// placement is unchanged, when `id` is unknown, and — as with
4365    /// [`set_item_parent`](Self::set_item_parent) — when the proposed parent is
4366    /// `id` itself or one of its own descendants, which would make a cycle.
4367    /// The whole write is refused in that last case rather than applied with
4368    /// the old parent, because a partly-applied atomic write is the thing this
4369    /// door exists to prevent.
4370    pub fn set_placement(&mut self, id: ItemId, placement: Placement) {
4371        let Some(&pos) = self.entry_index.get(&id) else {
4372            return;
4373        };
4374        let old = Placement {
4375            parent: self.entries[pos].parent,
4376            z: self.entries[pos].z,
4377            local_pos: self.entries[pos].local_pos,
4378            transform: self.entries[pos].transform,
4379        };
4380        if old == placement {
4381            return;
4382        }
4383        if let Some(p) = placement.parent
4384            && (p == id || self.is_descendant_of(p, id))
4385        {
4386            return;
4387        }
4388        if old.parent != placement.parent {
4389            self.entries[pos].parent = placement.parent;
4390            self.unlink_child(old.parent, id);
4391            self.link_child(placement.parent, id);
4392        }
4393        self.entries[pos].z = placement.z;
4394        self.entries[pos].local_pos = placement.local_pos;
4395        self.entries[pos].transform = placement.transform;
4396        self.rebucket_subtree(id);
4397        self.emit_item_change(ItemChange::PlacementChanged {
4398            id,
4399            old,
4400            new: placement,
4401        });
4402    }
4403
4404    /// Reparent `id` while holding it **visually still** — "drag this card into
4405    /// that group", correctly.
4406    ///
4407    /// [`set_item_parent`](Self::set_item_parent) reinterprets `local_pos` and
4408    /// `transform` in the new parent's frame, so the item jumps unless the
4409    /// caller compensates. This derives the local frame that leaves the item's
4410    /// scene transform where it is, and applies parent and frame as **one**
4411    /// [`set_placement`](Self::set_placement) write.
4412    ///
4413    /// The derived frame is normalised: the rotation/scale go in `transform`
4414    /// (around the local origin, which is what that field means) and all of the
4415    /// translation in `local_pos`. An item whose `transform` carried a
4416    /// translation of its own comes back with the same visual result and that
4417    /// translation folded into its position.
4418    ///
4419    /// No-op for an unknown id, for a parent that is already the current one,
4420    /// for a cycle, and for a new parent whose scene transform is degenerate
4421    /// (a zero scale somewhere in its chain) — there is no frame under it to
4422    /// land in.
4423    pub fn reparent_keeping_scene_pos(&mut self, id: ItemId, parent: Option<ItemId>) {
4424        let Some(&pos) = self.entry_index.get(&id) else {
4425            return;
4426        };
4427        if self.entries[pos].parent == parent {
4428            return;
4429        }
4430        if let Some(p) = parent
4431            && (p == id || self.is_descendant_of(p, id) || !self.entry_index.contains_key(&p))
4432        {
4433            return;
4434        }
4435        let old_scene = self.scene_transform(id);
4436        let parent_scene = match parent {
4437            Some(p) => self.scene_transform(p),
4438            None => Transform2D::identity(),
4439        };
4440        let Some(parent_inverse) = parent_scene.inverse() else {
4441            return;
4442        };
4443        // `t.then(u)` is "apply t, then u", i.e. `u * t`. We want the local
4444        // frame `l` with `l.then(parent_scene) == old_scene`, so
4445        // `l = parent_scene⁻¹ * old_scene = old_scene.then(parent_scene⁻¹)`.
4446        let local = old_scene.then(&parent_inverse);
4447        let [a, b, c, d, tx, ty] = local.m;
4448        self.set_placement(
4449            id,
4450            Placement {
4451                parent,
4452                z: self.entries[pos].z,
4453                local_pos: Point::new(tx, ty),
4454                transform: Transform2D {
4455                    m: [a, b, c, d, 0.0, 0.0],
4456                },
4457            },
4458        );
4459    }
4460
4461    /// A `z` strictly between two items' — the fractional insert that puts one
4462    /// item between two others without renumbering a single sibling.
4463    ///
4464    /// `None` when there is no such value: the two are at the same `z`, either
4465    /// id is unknown, or `f32` precision is exhausted at that locus. An `f32`
4466    /// carries ~24 mantissa bits, so about two dozen bisections at one point in
4467    /// the order run out — reachable in any card-shuffling UI.
4468    ///
4469    /// Asking first is what makes that visible. A caller that computed an
4470    /// exhausted midpoint itself would get `mid == lo`, hand it to
4471    /// [`set_z`](Self::set_z), and be given a **silent no-op**: the card simply
4472    /// would not move, with no error and no event. The answer to `None` is to
4473    /// renumber the band (a pass of evenly-spaced `z` values) and bisect again.
4474    ///
4475    /// The two agree on one metric, and it is this one. `set_z` ignores a write
4476    /// only when the entry already holds that exact float, so **every `Some`
4477    /// this returns is a value `set_z` will apply** — which is the whole
4478    /// contract. (An absolute epsilon there used to break it near zero: with
4479    /// `lo = 0.0` and `hi = 1.25e-7` this answers `Some(6.25e-8)` and the write
4480    /// was dropped.)
4481    ///
4482    /// ```
4483    /// use teksilo_canvas::{Point, Rect};
4484    /// use teksilo_scene::{RectItem, Scene};
4485    ///
4486    /// let mut scene = Scene::new();
4487    /// let r = || RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0));
4488    /// let lower = scene.add_item(r(), Point::ZERO);
4489    /// let upper = scene.add_item(r(), Point::ZERO);
4490    /// scene.set_z(lower, 1.0);
4491    /// scene.set_z(upper, 2.0);
4492    ///
4493    /// let middle = scene.add_item(r(), Point::ZERO);
4494    /// let z = scene.z_between(lower, upper).expect("room between 1.0 and 2.0");
4495    /// scene.set_z(middle, z);
4496    /// assert!(scene.z(middle).unwrap() > 1.0 && scene.z(middle).unwrap() < 2.0);
4497    ///
4498    /// // No room between an item and itself.
4499    /// assert_eq!(scene.z_between(lower, lower), None);
4500    /// ```
4501    pub fn z_between(&self, below: ItemId, above: ItemId) -> Option<f32> {
4502        let a = self.z(below)?;
4503        let b = self.z(above)?;
4504        let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
4505        if !lo.is_finite() || !hi.is_finite() {
4506            return None;
4507        }
4508        let mid = lo + (hi - lo) / 2.0;
4509        (mid > lo && mid < hi).then_some(mid)
4510    }
4511
4512    /// Promote `id`'s direct children to root-level (clear their
4513    /// `parent` field). Used when an app wants to remove `id` without
4514    /// dropping its children — call `orphan(id)` then `remove(id)`.
4515    /// No-op when `id` is unknown or has no children.
4516    ///
4517    /// Fires one [`ItemChange::ParentChanged`] per detached child and
4518    /// re-buckets every detached subtree in the spatial index — the
4519    /// children's `scene_transform` shifts (no longer composes
4520    /// `id`'s) so their scene-space AABBs change. Without re-bucketing
4521    /// the index, [`items_in_rect`](Self::items_in_rect) and
4522    /// [`item_at`](Self::item_at) would return stale results.
4523    ///
4524    /// Apps wanting *visual* stability across the orphan call should
4525    /// first bake `id`'s `scene_transform` into each child's
4526    /// `local_pos` + `transform`; otherwise children visibly jump.
4527    pub fn orphan(&mut self, id: ItemId) {
4528        if !self.entry_index.contains_key(&id) {
4529            return;
4530        }
4531        let children: Vec<ItemId> = {
4532            let pos = self.entry_index[&id];
4533            self.entries[pos].children.clone()
4534        };
4535        for child in children {
4536            if let Some(&pos) = self.entry_index.get(&child) {
4537                self.entries[pos].parent = None;
4538                self.unlink_child(Some(id), child);
4539                // Re-bucket the entire detached subtree: each child's
4540                // scene_transform changed (no longer composes `id`'s),
4541                // so spatial-index AABBs are stale. Subtree-walk
4542                // because grandchildren depend on the chain too.
4543                self.rebucket_subtree(child);
4544                self.emit_item_change(ItemChange::ParentChanged {
4545                    id: child,
4546                    old: Some(id),
4547                    new: None,
4548                });
4549            }
4550        }
4551    }
4552
4553    // -----------------------------------------------------------------
4554    // Queries
4555    // -----------------------------------------------------------------
4556
4557    /// Where `id` sits in `entries`, the scene's declaration order.
4558    ///
4559    /// The accessibility walk publishes siblings in this order — it is the
4560    /// order a screen reader reads the scene in — so a consumer that narrows
4561    /// the walk to a subset (the items inside a region, say) has to restore
4562    /// it, and a `HashSet` of ids does not carry it.
4563    pub(crate) fn entry_order(&self, id: ItemId) -> Option<usize> {
4564        self.entry_index.get(&id).copied()
4565    }
4566
4567    /// All items whose scene-AABB intersects `scene_rect`.
4568    ///
4569    /// Broad phase: the spatial index returns every id bucketed in
4570    /// any cell touched by `scene_rect`. Narrow phase: each candidate
4571    /// goes through [`scene_rect`](Self::scene_rect), which itself
4572    /// dispatches via `entry_index` (an `HashMap<ItemId, usize>`),
4573    /// so the per-candidate cost is O(parent-chain-depth) — not
4574    /// O(N). Total query is O(visible × chain) instead of O(N).
4575    pub fn items_in_rect(&self, scene_rect: Rect) -> Vec<ItemId> {
4576        self.index
4577            .query(scene_rect)
4578            .into_iter()
4579            .filter(|id| {
4580                self.scene_rect(*id)
4581                    .map(|r| rects_intersect(r, scene_rect))
4582                    .unwrap_or(false)
4583            })
4584            .collect()
4585    }
4586
4587    /// Snapshot every visible item — **both tiers** — as a `(scene_rect,
4588    /// color)` pair suitable for a minimap thumbnail. Filters out items with
4589    /// `HAS_NO_CONTENTS` (logical-only) and items hidden by `IS_VISIBLE` / a
4590    /// hidden ancestor — the visible-effective set matches what the SceneView's
4591    /// paint walk renders.
4592    ///
4593    /// Ordered by insertion (low z first). A lightweight item's color comes
4594    /// from [`SceneItem::thumbnail_color`] (its fill / stroke / a neutral grey);
4595    /// a heavyweight widget entry has no `SceneItem`, so it's shown in a neutral
4596    /// tint — a minimap that omitted the heavyweight tier would misrepresent a
4597    /// widget-heavy scene (cards, nodes), so both tiers are included.
4598    pub fn item_thumbnails(&self) -> Vec<(Rect, teksilo_tokens::Color)> {
4599        let mut out = Vec::new();
4600        for entry in &self.entries {
4601            // Skip invisible / logical-only items (either tier).
4602            if !self.is_effectively_visible(entry.id) {
4603                continue;
4604            }
4605            if entry.flags.contains(ItemFlags::HAS_NO_CONTENTS) {
4606                continue;
4607            }
4608            let Some(rect) = self.scene_rect(entry.id) else {
4609                continue;
4610            };
4611            let color = match &entry.kind {
4612                SceneEntryKind::Item(item) => item.thumbnail_color(),
4613                // Heavyweight widget: no `thumbnail_color`, so use a neutral tint.
4614                SceneEntryKind::Widget(_) => teksilo_tokens::Color::new(0.45, 0.52, 0.65, 0.85),
4615            };
4616            out.push((rect, color));
4617        }
4618        out
4619    }
4620
4621    /// The shape of any entry, in its **local** coordinates.
4622    ///
4623    /// For a lightweight item this is [`SceneItem::shape`]. A **heavyweight**
4624    /// widget entry has no `SceneItem` at all — [`Scene::item`] returns `None`
4625    /// for it — so its shape is defined to be
4626    /// [`ItemShape::bounds`] of its `local_bounds`. That is not a placeholder:
4627    /// a widget's silhouette is its layout box, the arena hit-tests it as one,
4628    /// and the marquee has always selected heavyweight entries through the
4629    /// same index query as lightweight ones. Stating it here is what keeps a
4630    /// `…ItemShape` selection mode meaningful for a scene whose primary
4631    /// objects are cards.
4632    ///
4633    /// `None` for an unknown id.
4634    pub fn item_shape(&self, id: ItemId) -> Option<ItemShape> {
4635        let pos = *self.entry_index.get(&id)?;
4636        Some(match &self.entries[pos].kind {
4637            SceneEntryKind::Item(item) => item.shape(),
4638            SceneEntryKind::Widget(_) => ItemShape::bounds(self.entries[pos].local_bounds),
4639        })
4640    }
4641
4642    /// Whether `id`'s shape contains `scene_pt`.
4643    ///
4644    /// Works for both tiers (see [`Scene::item_shape`]). `view_scale` is the
4645    /// live view zoom, consulted only by a cosmetic stroke band; pass `1.0`
4646    /// when there is no view.
4647    pub fn item_contains(&self, id: ItemId, scene_pt: Point, view_scale: f32) -> bool {
4648        let Some(shape) = self.item_shape(id) else {
4649            return false;
4650        };
4651        let Some(local_pt) = self.map_from_scene(id, scene_pt) else {
4652            return false;
4653        };
4654        shape.contains(local_pt, view_scale)
4655    }
4656
4657    /// The item's own shape re-published as a **scene**-space region — what a
4658    /// collision query asks the rest of the scene about. `None` for an unknown
4659    /// id, a shape of [`ItemShape::none`], or a screen-anchored
4660    /// ([`IGNORES_TRANSFORMATIONS`](crate::flags::ItemFlags::IGNORES_TRANSFORMATIONS))
4661    /// item, whose silhouette is not in scene space at all.
4662    pub fn item_region(&self, id: ItemId) -> Option<SceneRegion> {
4663        // A screen-anchored item has no scene-space silhouette to publish:
4664        // its `local_bounds` is in screen coordinates and its scene transform
4665        // places only its anchor. Same reason `items_in_region` skips them.
4666        if self.is_screen_anchored(id) {
4667            return None;
4668        }
4669        let shape = self.item_shape(id)?;
4670        if shape.is_none() {
4671            return None;
4672        }
4673        let xform = self.scene_transform(id);
4674        Some(shape.to_scene_region(&xform))
4675    }
4676
4677    /// Items matching `region` under `mode` — **both tiers**, exactly like
4678    /// [`Scene::items_in_rect`], which this generalises.
4679    ///
4680    /// Broad-phased by the spatial index on `region.bounding_rect()`, then
4681    /// narrow-phased per `mode`: the `…ItemBoundingRect` modes compare the
4682    /// item's **scene** AABB against the region in scene space; the
4683    /// `…ItemShape` modes map the region into the item's **local** frame and
4684    /// compare it against [`Scene::item_shape`]. For an item with a
4685    /// non-identity transform those are different tests — see
4686    /// [`ItemSelectionMode`].
4687    ///
4688    /// `view_scale` is the live view zoom, consulted only by a cosmetic stroke
4689    /// band; pass `1.0` when there is no view. Visibility and selectability
4690    /// are **not** filtered here — this is a pure geometry query, and the
4691    /// caller (e.g. [`SceneSelection::commit_marquee`](crate::SceneSelection::commit_marquee))
4692    /// applies its own flag policy.
4693    ///
4694    /// Items flagged
4695    /// [`IGNORES_TRANSFORMATIONS`](crate::flags::ItemFlags::IGNORES_TRANSFORMATIONS)
4696    /// **are skipped**, for the reason [`Scene::item_at`] skips them: they are
4697    /// anchored in screen space, so their `local_bounds` is a screen rectangle
4698    /// and the scene AABB the index holds for them is a fiction. Comparing a
4699    /// scene-space region against that fiction is a guess, and the two query
4700    /// families used to disagree about whether to make it — the point queries
4701    /// declined and the marquee did not. Reaching screen-pinned chrome needs a
4702    /// region that arrives in *screen* space, which is what
4703    /// [`Scene::item_at_in_view`] does for a point; there is no region twin of
4704    /// it yet, so a marquee cannot select pinned chrome at all.
4705    pub fn items_in_region(
4706        &self,
4707        region: &SceneRegion,
4708        mode: ItemSelectionMode,
4709        view_scale: f32,
4710    ) -> Vec<ItemId> {
4711        self.index
4712            .query(region.bounding_rect())
4713            .into_iter()
4714            .filter(|id| !self.is_screen_anchored(*id))
4715            .filter(|id| self.region_match(*id, region, mode, view_scale))
4716            .collect()
4717    }
4718
4719    /// Whether `id` is pinned in screen space
4720    /// ([`IGNORES_TRANSFORMATIONS`](crate::flags::ItemFlags::IGNORES_TRANSFORMATIONS)),
4721    /// and so cannot be placed by a scene-space query at all. `false` for an
4722    /// unknown id.
4723    fn is_screen_anchored(&self, id: ItemId) -> bool {
4724        self.flags(id)
4725            .is_some_and(|f| f.contains(ItemFlags::IGNORES_TRANSFORMATIONS))
4726    }
4727
4728    fn region_match(
4729        &self,
4730        id: ItemId,
4731        region: &SceneRegion,
4732        mode: ItemSelectionMode,
4733        view_scale: f32,
4734    ) -> bool {
4735        if mode.uses_shape() {
4736            let Some(shape) = self.item_shape(id) else {
4737                return false;
4738            };
4739            // The shape is local and the region is scene-space; which frame
4740            // the two meet in is a decision, not a detail, and `ItemShape`
4741            // owns it — see `ItemShape::contained_by_scene_region`.
4742            let xform = self.scene_transform(id);
4743            if mode.requires_containment() {
4744                shape.contained_by_scene_region(region, &xform, view_scale)
4745            } else {
4746                shape.intersects_scene_region(region, &xform, view_scale)
4747            }
4748        } else {
4749            let Some(rect) = self.scene_rect(id) else {
4750                return false;
4751            };
4752            let shape = ItemShape::bounds(rect);
4753            if mode.requires_containment() {
4754                shape.contained_by_region(region, 1.0)
4755            } else {
4756                shape.intersects_region(region, 1.0)
4757            }
4758        }
4759    }
4760
4761    /// Topmost **lightweight** item whose shape contains `scene_pt`, at unit
4762    /// view scale. See [`Scene::item_at_scaled`] for the zoom-aware form and
4763    /// [`Scene::item_at_in_view`] for the one that also places screen-anchored
4764    /// items.
4765    ///
4766    /// Heavyweight widget entries are skipped: their hit-testing is the
4767    /// arena's job, and a scene-space answer would contradict it.
4768    ///
4769    /// Items flagged
4770    /// [`IGNORES_TRANSFORMATIONS`](crate::flags::ItemFlags::IGNORES_TRANSFORMATIONS)
4771    /// are **also skipped**, deterministically. They are anchored in screen
4772    /// space, so a scene-space point cannot place them at all; answering with
4773    /// a coin-flip (which is what comparing them against a scene AABB amounts
4774    /// to) is worse than not answering. Use [`Scene::item_at_in_view`], or
4775    /// `SceneView` dispatch, when the query needs them.
4776    ///
4777    /// "Topmost" is [`Scene::paint_key`] order, so an
4778    /// [`Over`](SceneLayer::Over)-band item beats a higher-`z`
4779    /// [`Under`](SceneLayer::Under) one, and two equal-`z` items resolve to the
4780    /// later-inserted one — the same answer `SceneView` dispatch gives.
4781    ///
4782    /// Hidden and disabled entries are excluded: this is a *hit* test, and
4783    /// [`ItemFlags::IS_VISIBLE`] and [`ItemFlags::IS_ENABLED`] both say so. See
4784    /// [`Scene::is_hit_testable`]. For a pure geometry query that ignores flags,
4785    /// use [`Scene::items_in_region`] or [`Scene::item_contains`].
4786    pub fn item_at(&self, scene_pt: Point) -> Option<ItemId> {
4787        self.item_at_scaled(scene_pt, 1.0)
4788    }
4789
4790    /// [`Scene::item_at`] at an explicit view zoom.
4791    ///
4792    /// The zoom reaches exactly one thing: a **cosmetic** stroke band, whose
4793    /// width is in device pixels and therefore covers fewer scene units the
4794    /// further you zoom in. Passing the live scale is what makes this agree
4795    /// with `SceneView`'s own dispatch, which has always had it.
4796    pub fn item_at_scaled(&self, scene_pt: Point, view_scale: f32) -> Option<ItemId> {
4797        self.hit_candidates(scene_pt)
4798            .into_iter()
4799            .find(|id| self.lightweight_scene_hit(*id, scene_pt, view_scale))
4800    }
4801
4802    /// Topmost entry of **either** tier whose geometry contains `scene_pt`, in
4803    /// this scene's one paint order.
4804    ///
4805    /// The deliberate counterpart to [`Scene::item_at_scaled`], which skips
4806    /// heavyweight entries because the arena owns their hit-testing. This one
4807    /// answers the different question a *selection* asks — "which entry is on
4808    /// top here?" — and answers it for a card with the card's scene rectangle,
4809    /// which is exactly the rectangle `SceneView` laid the card out at. It is
4810    /// not a substitute for arena dispatch and must not be used to route an
4811    /// event to a widget; it is for deciding whether a press that already
4812    /// reached the view landed on something the view is holding.
4813    ///
4814    /// Screen-anchored entries are skipped for the same reason `item_at` skips
4815    /// them: a scene-space point cannot place them.
4816    pub fn entry_at(&self, scene_pt: Point, view_scale: f32) -> Option<ItemId> {
4817        self.hit_candidates(scene_pt).into_iter().find(|id| {
4818            if self.item(*id).is_some() {
4819                self.lightweight_scene_hit(*id, scene_pt, view_scale)
4820            } else {
4821                self.scene_rect(*id).is_some_and(|r| r.contains(scene_pt))
4822            }
4823        })
4824    }
4825
4826    /// All lightweight items whose shape contains `scene_pt`, topmost-first by
4827    /// z. Same tier and `IGNORES_TRANSFORMATIONS` rules as [`Scene::item_at`].
4828    pub fn items_at(&self, scene_pt: Point) -> Vec<ItemId> {
4829        self.items_at_scaled(scene_pt, 1.0)
4830    }
4831
4832    /// [`Scene::items_at`] at an explicit view zoom.
4833    pub fn items_at_scaled(&self, scene_pt: Point, view_scale: f32) -> Vec<ItemId> {
4834        self.hit_candidates(scene_pt)
4835            .into_iter()
4836            .filter(|id| self.lightweight_scene_hit(*id, scene_pt, view_scale))
4837            .collect()
4838    }
4839
4840    /// Topmost lightweight item under a **screen** point, resolving *both*
4841    /// hit spaces the way `SceneView` dispatch does.
4842    ///
4843    /// A normal item is tested in scene space, at the view transform's zoom; a
4844    /// screen-anchored
4845    /// ([`IGNORES_TRANSFORMATIONS`](crate::flags::ItemFlags::IGNORES_TRANSFORMATIONS))
4846    /// item is tested against its `local_bounds` rooted at its projected
4847    /// anchor, at unit scale — its local coordinates *are* screen
4848    /// coordinates, so it has no zoom to convert. This is the query to use
4849    /// when a scene may contain screen-pinned chrome; [`Scene::item_at`]
4850    /// deliberately declines to guess.
4851    pub fn item_at_in_view(&self, screen_pt: Point, view_transform: Transform2D) -> Option<ItemId> {
4852        let view_scale = view_transform.geometric_scale();
4853        let scene_pt = view_transform.inverse()?.apply_point(screen_pt);
4854        // Screen-anchored items live outside the spatial index's scene-space
4855        // buckets, so they are scanned separately and tested first: they are
4856        // chrome pinned over the content.
4857        let mut pinned: Vec<ItemId> = self
4858            .entries
4859            .iter()
4860            .filter(|e| {
4861                matches!(e.kind, SceneEntryKind::Item(_))
4862                    && e.flags.contains(ItemFlags::IGNORES_TRANSFORMATIONS)
4863            })
4864            .map(|e| e.id)
4865            .filter(|id| self.is_hit_testable(*id))
4866            .collect();
4867        self.sort_by_paint_key_desc(&mut pinned);
4868        for id in pinned {
4869            let anchor =
4870                view_transform.apply_point(self.scene_transform(id).apply_point(Point::ZERO));
4871            let local_pt = Point::new(screen_pt.x - anchor.x, screen_pt.y - anchor.y);
4872            let Some(shape) = self.item_shape(id) else {
4873                continue;
4874            };
4875            if shape.contains(local_pt, 1.0) {
4876                return Some(id);
4877            }
4878        }
4879        self.item_at_scaled(scene_pt, view_scale)
4880    }
4881
4882    /// Topmost-first candidate ids for a point query, screen-anchored items
4883    /// excluded (see [`Scene::item_at`]) and non-hit-testable entries dropped
4884    /// (see [`crate::pick::hit_testable`]).
4885    fn hit_candidates(&self, scene_pt: Point) -> Vec<ItemId> {
4886        let probe = Rect::new(scene_pt.x, scene_pt.y, 0.0, 0.0);
4887        let mut candidates: Vec<ItemId> = self
4888            .items_in_rect(probe)
4889            .into_iter()
4890            .filter(|id| self.is_hit_testable(*id))
4891            .filter(|id| !self.is_screen_anchored(*id))
4892            .collect();
4893        self.sort_by_paint_key_desc(&mut candidates);
4894        candidates
4895    }
4896
4897    /// Whether `id` takes part in pointer hit-testing — visible along its whole
4898    /// ancestor chain AND enabled.
4899    ///
4900    /// This is [`crate::pick::hit_testable`] resolved against the scene, and it
4901    /// is the one place the two flag contracts in [`ItemFlags`] are honoured:
4902    /// [`IS_VISIBLE`](ItemFlags::IS_VISIBLE) ("neither painted nor hit-tested")
4903    /// and [`IS_ENABLED`](ItemFlags::IS_ENABLED) ("pass clicks through to items
4904    /// beneath"). `false` for unknown ids.
4905    pub fn is_hit_testable(&self, id: ItemId) -> bool {
4906        let Some(flags) = self.flags(id) else {
4907            return false;
4908        };
4909        crate::pick::hit_testable(self.is_effectively_visible(id), flags)
4910    }
4911
4912    fn lightweight_scene_hit(&self, id: ItemId, scene_pt: Point, view_scale: f32) -> bool {
4913        let Some(item) = self.item(id) else {
4914            return false;
4915        };
4916        let Some(local_pt) = self.map_from_scene(id, scene_pt) else {
4917            return false;
4918        };
4919        item.shape().contains(local_pt, view_scale)
4920    }
4921
4922    /// Items overlapping `id`'s **shape**, excluding `id` itself.
4923    ///
4924    /// Apps use this for "which other items overlap this card?" — graph
4925    /// editors checking node-on-node overlap, CAD canvases finding adjacent
4926    /// geometry. Backed by the spatial index, so the cost is `O(visible)` not
4927    /// `O(N)`.
4928    ///
4929    /// Defaults to [`ItemSelectionMode::IntersectsItemShape`], so a
4930    /// stroke-only connector collides along its line rather than across its
4931    /// bounding box. Pass
4932    /// [`IntersectsItemBoundingRect`](ItemSelectionMode::IntersectsItemBoundingRect)
4933    /// to [`Scene::colliding_items_with`] for the cheaper box test.
4934    ///
4935    /// Screen-anchored items neither collide nor are collided with — see
4936    /// [`Scene::items_in_region`], which this is built on, and
4937    /// [`Scene::item_region`], which declines to publish one for them.
4938    pub fn colliding_items(&self, id: ItemId) -> Vec<ItemId> {
4939        self.colliding_items_with(id, ItemSelectionMode::default())
4940    }
4941
4942    /// [`Scene::colliding_items`] under an explicit [`ItemSelectionMode`].
4943    pub fn colliding_items_with(&self, id: ItemId, mode: ItemSelectionMode) -> Vec<ItemId> {
4944        let Some(region) = self.item_region(id) else {
4945            return Vec::new();
4946        };
4947        self.items_in_region(&region, mode, 1.0)
4948            .into_iter()
4949            .filter(|other| *other != id)
4950            .collect()
4951    }
4952
4953    /// Items lying along `path` — a real region query, not the AABB-of-the-path
4954    /// approximation this used to be.
4955    ///
4956    /// The path is treated as a zero-width closed region: an item is picked
4957    /// when the path crosses it or encloses it. For a *connector* — a line
4958    /// with a width — pass that width to [`Scene::items_along_path_with`], so
4959    /// the query asks about the band the user can see.
4960    pub fn items_along_path(&self, path: &Path) -> Vec<ItemId> {
4961        self.items_along_path_with(path, 0.0, ItemSelectionMode::default())
4962    }
4963
4964    /// [`Scene::items_along_path`] with an explicit stroke width and
4965    /// [`ItemSelectionMode`].
4966    ///
4967    /// `stroke_width` greater than zero makes the region the path's **band**
4968    /// rather than its interior: "what does this 4 dp connector touch?".
4969    pub fn items_along_path_with(
4970        &self,
4971        path: &Path,
4972        stroke_width: f32,
4973        mode: ItemSelectionMode,
4974    ) -> Vec<ItemId> {
4975        let region = if stroke_width > 0.0 {
4976            SceneRegion::stroke(path.clone(), stroke_width)
4977        } else {
4978            SceneRegion::lasso(path.clone())
4979        };
4980        self.items_in_region(&region, mode, 1.0)
4981    }
4982
4983    // -----------------------------------------------------------------
4984    // Metadata
4985    // -----------------------------------------------------------------
4986
4987    /// Number of entries in the scene.
4988    pub fn len(&self) -> usize {
4989        self.entries.len()
4990    }
4991
4992    /// Whether the scene is empty.
4993    pub fn is_empty(&self) -> bool {
4994        self.entries.is_empty()
4995    }
4996
4997    /// All ids in insertion order.
4998    pub fn ids(&self) -> Vec<ItemId> {
4999        self.entries.iter().map(|e| e.id).collect()
5000    }
5001
5002    /// Borrow the spatial index (diagnostics / tests).
5003    pub fn index(&self) -> &dyn SpatialIndex {
5004        &*self.index
5005    }
5006
5007    // -----------------------------------------------------------------
5008    // Magnetism
5009    // -----------------------------------------------------------------
5010
5011    /// Attach a [`Magnet`] to `item` and return its [`MagnetId`].
5012    ///
5013    /// Magnets are local to their item (their `local_pos` is in the
5014    /// item's frame), so they follow the item under any move / rotate /
5015    /// scale via the same `scene_transform` the item uses. No-op
5016    /// returning a fresh-but-unowned id if `item` is unknown — callers
5017    /// add magnets to items they just created.
5018    ///
5019    /// Bumps the AT-structure change counter (magnets are AT structure)
5020    /// so a `SceneView` with magnetism enabled re-walks its synthetic
5021    /// magnet nodes.
5022    pub fn add_magnet(&mut self, item: ItemId, magnet: Magnet) -> MagnetId {
5023        let id = MagnetId::next();
5024        if !self.entry_index.contains_key(&item) {
5025            return id;
5026        }
5027        self.magnets.entry(item).or_default().push((id, magnet));
5028        self.magnet_owner.insert(id, item);
5029        self.bump_a11y_change(A11yNode::Item(item));
5030        id
5031    }
5032
5033    /// Remove a magnet by id. No-op if the id is unknown.
5034    pub fn remove_magnet(&mut self, magnet: MagnetId) {
5035        let Some(owner) = self.magnet_owner.remove(&magnet) else {
5036            return;
5037        };
5038        if let Some(list) = self.magnets.get_mut(&owner) {
5039            list.retain(|(mid, _)| *mid != magnet);
5040            if list.is_empty() {
5041                self.magnets.remove(&owner);
5042            }
5043        }
5044        self.bump_a11y_change(A11yNode::Item(owner));
5045    }
5046
5047    /// Remove every magnet attached to `item`. No-op if none.
5048    pub fn clear_magnets(&mut self, item: ItemId) {
5049        if let Some(list) = self.magnets.remove(&item) {
5050            for (mid, _) in list {
5051                self.magnet_owner.remove(&mid);
5052            }
5053            self.bump_a11y_change(A11yNode::Item(item));
5054        }
5055    }
5056
5057    /// Move a magnet to a new position in its owning item's local
5058    /// frame. No-op if the id is unknown.
5059    pub fn set_magnet_local_pos(&mut self, magnet: MagnetId, local_pos: Point) {
5060        let Some(&owner) = self.magnet_owner.get(&magnet) else {
5061            return;
5062        };
5063        if let Some(list) = self.magnets.get_mut(&owner)
5064            && let Some((_, m)) = list.iter_mut().find(|(mid, _)| *mid == magnet)
5065        {
5066            m.local_pos = local_pos;
5067            self.bump_a11y_change(A11yNode::Item(owner));
5068        }
5069    }
5070
5071    /// Enable or disable a magnet. Disabled magnets are skipped by
5072    /// broad-phase, feedback, the keyboard cycle, and AT emission.
5073    /// No-op if the id is unknown.
5074    pub fn set_magnet_enabled(&mut self, magnet: MagnetId, enabled: bool) {
5075        let Some(&owner) = self.magnet_owner.get(&magnet) else {
5076            return;
5077        };
5078        if let Some(list) = self.magnets.get_mut(&owner)
5079            && let Some((_, m)) = list.iter_mut().find(|(mid, _)| *mid == magnet)
5080            && m.enabled != enabled
5081        {
5082            m.enabled = enabled;
5083            self.bump_a11y_change(A11yNode::Item(owner));
5084        }
5085    }
5086
5087    /// The ids of every magnet attached to `item`, in insertion order
5088    /// (enabled and disabled alike). Empty if `item` is unknown or has
5089    /// no magnets.
5090    pub fn magnet_ids_of(&self, item: ItemId) -> Vec<MagnetId> {
5091        self.magnets
5092            .get(&item)
5093            .map(|list| list.iter().map(|(mid, _)| *mid).collect())
5094            .unwrap_or_default()
5095    }
5096
5097    /// The owning item of a magnet, or `None` if the id is unknown.
5098    pub fn magnet_owner(&self, magnet: MagnetId) -> Option<ItemId> {
5099        self.magnet_owner.get(&magnet).copied()
5100    }
5101
5102    /// The label set on a magnet (for the AT walker). `None` if unset
5103    /// or the id is unknown.
5104    pub(crate) fn magnet_label(&self, magnet: MagnetId) -> Option<teksilo_i18n::LocalizedString> {
5105        let owner = self.magnet_owner.get(&magnet)?;
5106        let list = self.magnets.get(owner)?;
5107        list.iter()
5108            .find(|(mid, _)| *mid == magnet)
5109            .and_then(|(_, m)| m.label.clone())
5110    }
5111
5112    /// Whether a magnet is enabled. `false` for an unknown id.
5113    pub fn magnet_enabled(&self, magnet: MagnetId) -> bool {
5114        let Some(owner) = self.magnet_owner.get(&magnet) else {
5115            return false;
5116        };
5117        self.magnets
5118            .get(owner)
5119            .and_then(|list| list.iter().find(|(mid, _)| *mid == magnet))
5120            .map(|(_, m)| m.enabled)
5121            .unwrap_or(false)
5122    }
5123
5124    /// A magnet's position in scene coordinates (its local position
5125    /// projected through its owning item's `scene_transform`). `None`
5126    /// for an unknown id or a degenerate item transform.
5127    pub fn magnet_scene_pos(&self, magnet: MagnetId) -> Option<Point> {
5128        let &owner = self.magnet_owner.get(&magnet)?;
5129        let list = self.magnets.get(&owner)?;
5130        let (_, m) = list.iter().find(|(mid, _)| *mid == magnet)?;
5131        self.map_to_scene(owner, m.local_pos)
5132    }
5133
5134    /// Resolve a magnet to a borrow-free [`MagnetRef`] snapshot (id,
5135    /// owning item, role, payload clone, current scene position).
5136    /// `None` for an unknown id or a degenerate item transform.
5137    pub fn magnet(&self, magnet: MagnetId) -> Option<MagnetRef> {
5138        let &owner = self.magnet_owner.get(&magnet)?;
5139        let list = self.magnets.get(&owner)?;
5140        let (_, m) = list.iter().find(|(mid, _)| *mid == magnet)?;
5141        let scene_pos = self.map_to_scene(owner, m.local_pos)?;
5142        Some(MagnetRef {
5143            id: magnet,
5144            item: owner,
5145            role: m.role,
5146            payload: m.payload.clone(),
5147            scene_pos,
5148        })
5149    }
5150
5151    /// Collect every enabled magnet whose scene position lies inside
5152    /// `scene_rect`, as borrow-free [`MagnetRef`] snapshots, excluding
5153    /// any magnet on `exclude_item`. Broad-phase over the spatial index
5154    /// (`items_in_rect`) so the cost is `O(visible × magnets/item)`.
5155    ///
5156    /// This is the shared narrow-phase input for both snap helpers: the
5157    /// candidates are materialised as owned snapshots, so the predicate
5158    /// that runs over them touches no scene state. The predicate may read
5159    /// the model (a shared borrow is re-entrant) but must not mutate it;
5160    /// mutation belongs in the consumer's `on_connect`, which fires after
5161    /// the snap call returns and every borrow is dropped.
5162    fn collect_candidate_magnets(
5163        &self,
5164        scene_rect: Rect,
5165        exclude_item: Option<ItemId>,
5166    ) -> Vec<MagnetRef> {
5167        let mut out = Vec::new();
5168        for item in self.items_in_rect(scene_rect) {
5169            if Some(item) == exclude_item {
5170                continue;
5171            }
5172            let Some(list) = self.magnets.get(&item) else {
5173                continue;
5174            };
5175            let xform = self.scene_transform(item);
5176            for (mid, m) in list {
5177                if !m.enabled {
5178                    continue;
5179                }
5180                let scene_pos = xform.apply_point(m.local_pos);
5181                if !scene_rect.contains(scene_pos) {
5182                    continue;
5183                }
5184                out.push(MagnetRef {
5185                    id: *mid,
5186                    item,
5187                    role: m.role,
5188                    payload: m.payload.clone(),
5189                    scene_pos,
5190                });
5191            }
5192        }
5193        out
5194    }
5195
5196    /// Square-rect of half-extent `radius` centred on `center`.
5197    fn capture_rect(center: Point, radius: f32) -> Rect {
5198        Rect::new(
5199            center.x - radius,
5200            center.y - radius,
5201            radius * 2.0,
5202            radius * 2.0,
5203        )
5204    }
5205
5206    /// Compute the best item-drag snap: the dragged item is visually
5207    /// offset by `drag_delta`, and each of its enabled magnets seeks the
5208    /// nearest *accepting* magnet on another item within `capture_radius`
5209    /// (in scene units). Returns the globally closest accepting pair, or
5210    /// `None` if nothing accepts within range.
5211    ///
5212    /// Pure mechanism: it collects candidates under a brief read, then
5213    /// runs the consumer `predicate` with no scene borrow held, so the
5214    /// predicate may inspect payloads freely. `snap_vector` added to
5215    /// `drag_delta` aligns the dragged magnet onto its target.
5216    pub fn compute_item_snap(
5217        &self,
5218        dragged: ItemId,
5219        drag_delta: Vec2,
5220        capture_radius: f32,
5221        predicate: &dyn Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict,
5222    ) -> Option<MagnetSnap> {
5223        if capture_radius <= 0.0 {
5224            return None;
5225        }
5226        let dragged_list = self.magnets.get(&dragged)?;
5227        if dragged_list.is_empty() {
5228            return None;
5229        }
5230        // Visual scene positions of the dragged item's enabled magnets:
5231        // committed scene pos + the live drag delta.
5232        let xform = self.scene_transform(dragged);
5233        let dragged_magnets: Vec<MagnetRef> = dragged_list
5234            .iter()
5235            .filter(|(_, m)| m.enabled)
5236            .map(|(mid, m)| {
5237                let committed = xform.apply_point(m.local_pos);
5238                MagnetRef {
5239                    id: *mid,
5240                    item: dragged,
5241                    role: m.role,
5242                    payload: m.payload.clone(),
5243                    scene_pos: Point::new(committed.x + drag_delta.x, committed.y + drag_delta.y),
5244                }
5245            })
5246            .collect();
5247        if dragged_magnets.is_empty() {
5248            return None;
5249        }
5250
5251        let mut best: Option<MagnetSnap> = None;
5252        for from in &dragged_magnets {
5253            let rect = Self::capture_rect(from.scene_pos, capture_radius);
5254            let candidates = self.collect_candidate_magnets(rect, Some(dragged));
5255            for to in &candidates {
5256                let dx = to.scene_pos.x - from.scene_pos.x;
5257                let dy = to.scene_pos.y - from.scene_pos.y;
5258                let dist = (dx * dx + dy * dy).sqrt();
5259                if dist > capture_radius {
5260                    continue;
5261                }
5262                let MagnetVerdict::Accept(payload) = predicate(from, to) else {
5263                    continue;
5264                };
5265                let better = best.as_ref().map(|b| dist < b.distance).unwrap_or(true);
5266                if better {
5267                    best = Some(MagnetSnap {
5268                        from: from.id,
5269                        to: to.id,
5270                        snap_vector: Vec2::new(dx, dy),
5271                        payload,
5272                        distance: dist,
5273                    });
5274                }
5275            }
5276        }
5277        best
5278    }
5279
5280    /// Compute the best port-drag snap: a single `source` magnet is
5281    /// dragging a transient wire whose free end is at `cursor_scene`.
5282    /// Finds the nearest *accepting* target magnet within
5283    /// `capture_radius` (scene units), excluding the source's own
5284    /// magnet. Returns the target [`MagnetRef`] and the accepting
5285    /// verdict's payload, or `None`.
5286    pub fn compute_port_snap(
5287        &self,
5288        source: MagnetId,
5289        cursor_scene: Point,
5290        capture_radius: f32,
5291        predicate: &dyn Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict,
5292    ) -> Option<(MagnetRef, Option<Rc<dyn std::any::Any>>)> {
5293        if capture_radius <= 0.0 {
5294            return None;
5295        }
5296        let from = self.magnet(source)?;
5297        let rect = Self::capture_rect(cursor_scene, capture_radius);
5298        // Don't exclude the source's whole item — a node may legitimately
5299        // connect to another of its own ports in some graphs; only the
5300        // source magnet itself is excluded (below).
5301        let candidates = self.collect_candidate_magnets(rect, None);
5302        let mut best: Option<(MagnetRef, Option<Rc<dyn std::any::Any>>, f32)> = None;
5303        for to in candidates {
5304            if to.id == source {
5305                continue;
5306            }
5307            let dx = to.scene_pos.x - cursor_scene.x;
5308            let dy = to.scene_pos.y - cursor_scene.y;
5309            let dist = (dx * dx + dy * dy).sqrt();
5310            if dist > capture_radius {
5311                continue;
5312            }
5313            let MagnetVerdict::Accept(payload) = predicate(&from, &to) else {
5314                continue;
5315            };
5316            let better = best.as_ref().map(|b| dist < b.2).unwrap_or(true);
5317            if better {
5318                best = Some((to, payload, dist));
5319            }
5320        }
5321        best.map(|(to, payload, _)| (to, payload))
5322    }
5323
5324    /// The nearest enabled magnet to `scene_pt` within `radius` (scene
5325    /// units), or `None`. Used by the view to start a port-drag from a
5326    /// grabbed magnet handle (the handle's grab area is a screen-pixel
5327    /// disc, converted to scene units by the caller).
5328    pub fn nearest_magnet(&self, scene_pt: Point, radius: f32) -> Option<MagnetId> {
5329        if radius <= 0.0 {
5330            return None;
5331        }
5332        let rect = Self::capture_rect(scene_pt, radius);
5333        let mut best: Option<(MagnetId, f32)> = None;
5334        for c in self.collect_candidate_magnets(rect, None) {
5335            let dx = c.scene_pos.x - scene_pt.x;
5336            let dy = c.scene_pos.y - scene_pt.y;
5337            let dist = (dx * dx + dy * dy).sqrt();
5338            if dist > radius {
5339                continue;
5340            }
5341            let better = best.map(|b| dist < b.1).unwrap_or(true);
5342            if better {
5343                best = Some((c.id, dist));
5344            }
5345        }
5346        best.map(|(id, _)| id)
5347    }
5348
5349    // -----------------------------------------------------------------
5350    // Logical AT structure (kept verbatim from R0)
5351    // -----------------------------------------------------------------
5352
5353    /// Declare a virtual AT group. The group has no visual
5354    /// counterpart — it exists so the AT walker can emit an AT node
5355    /// under which items / other groups / widgets can be reparented.
5356    pub fn add_a11y_group(&mut self, builder: A11yGroupBuilder) -> A11yGroupId {
5357        let id = A11yGroupId::next();
5358        let group = A11yGroup {
5359            id,
5360            label: builder.label,
5361            role: builder.role,
5362        };
5363        let pos = self.a11y_groups.len();
5364        self.a11y_groups.push(group);
5365        self.a11y_group_index.insert(id, pos);
5366        self.bump_a11y_change(A11yNode::Group(id));
5367        id
5368    }
5369
5370    /// Remove a logical group; orphaned references fall back to
5371    /// SceneView root. Relations / live / landmarks / categories
5372    /// targeting this group are cleaned up too.
5373    pub fn remove_a11y_group(&mut self, id: A11yGroupId) {
5374        let prev = self.a11y_groups.len();
5375        self.a11y_groups.retain(|g| g.id != id);
5376        if self.a11y_groups.len() != prev {
5377            self.a11y_group_index.clear();
5378            for (pos, group) in self.a11y_groups.iter().enumerate() {
5379                self.a11y_group_index.insert(group.id, pos);
5380            }
5381        }
5382        let target = A11yNode::Group(id);
5383        self.a11y_parents
5384            .retain(|child, parent| *child != target && *parent != target);
5385        self.a11y_relations
5386            .retain(|(from, _, to)| *from != target && *to != target);
5387        self.a11y_live.remove(&target);
5388        self.a11y_landmarks.remove(&target);
5389        self.a11y_categories.remove(&target);
5390        self.bump_a11y_change(target);
5391    }
5392
5393    /// Borrow a logical group by id.
5394    pub fn a11y_group(&self, id: A11yGroupId) -> Option<&A11yGroup> {
5395        let pos = *self.a11y_group_index.get(&id)?;
5396        self.a11y_groups.get(pos)
5397    }
5398
5399    /// Declare a logical-parent relationship for AT (independent of
5400    /// visual placement).
5401    pub fn set_a11y_parent(&mut self, child: A11yNode, parent: Option<A11yNode>) {
5402        match parent {
5403            Some(p) => {
5404                self.a11y_parents.insert(child, p);
5405            }
5406            None => {
5407                self.a11y_parents.remove(&child);
5408            }
5409        }
5410        self.bump_a11y_change(child);
5411    }
5412
5413    /// The currently-declared logical parent of a node.
5414    pub fn a11y_parent_of(&self, child: A11yNode) -> Option<A11yNode> {
5415        self.a11y_parents.get(&child).copied()
5416    }
5417
5418    /// Declare an AT relationship between two nodes.
5419    pub fn add_a11y_relation(&mut self, from: A11yNode, kind: A11yRelation, to: A11yNode) {
5420        self.a11y_relations.push((from, kind, to));
5421        self.bump_a11y_change(from);
5422    }
5423
5424    /// All declared AT relations.
5425    pub fn a11y_relations(&self) -> &[(A11yNode, A11yRelation, A11yNode)] {
5426        &self.a11y_relations
5427    }
5428
5429    /// Mark a node as a live region. Pass `Live::Off` to clear.
5430    pub fn set_a11y_live(&mut self, node: A11yNode, live: accesskit::Live) {
5431        if matches!(live, accesskit::Live::Off) {
5432            self.a11y_live.remove(&node);
5433        } else {
5434            self.a11y_live.insert(node, live);
5435        }
5436        self.bump_a11y_change(node);
5437    }
5438
5439    /// Mark a node as a landmark by overriding its role. Pass
5440    /// `Role::Unknown` to clear.
5441    pub fn set_a11y_landmark(&mut self, node: A11yNode, role: accesskit::Role) {
5442        if matches!(role, accesskit::Role::Unknown) {
5443            self.a11y_landmarks.remove(&node);
5444        } else {
5445            self.a11y_landmarks.insert(node, role);
5446        }
5447        self.bump_a11y_change(node);
5448    }
5449
5450    /// Tag a node with rotor / quick-nav categories.
5451    pub fn set_a11y_categories(&mut self, node: A11yNode, categories: &[A11yCategory]) {
5452        if categories.is_empty() {
5453            self.a11y_categories.remove(&node);
5454        } else {
5455            self.a11y_categories.insert(node, categories.to_vec());
5456        }
5457        self.bump_a11y_change(node);
5458    }
5459
5460    /// Read declared categories for a node.
5461    pub fn a11y_categories_of(&self, node: A11yNode) -> Option<&[A11yCategory]> {
5462        self.a11y_categories.get(&node).map(|v| v.as_slice())
5463    }
5464
5465    /// Read a node's declared live-region politeness. `None` when it is not a
5466    /// live region.
5467    ///
5468    /// The read half of [`set_a11y_live`](Self::set_a11y_live), added because a
5469    /// consumer restoring a [`RemovedItem`] has to be able to check that the
5470    /// semantics came back — and because the other four decorations already
5471    /// had one.
5472    pub fn a11y_live_of(&self, node: A11yNode) -> Option<accesskit::Live> {
5473        self.a11y_live.get(&node).copied()
5474    }
5475
5476    /// Read a node's declared landmark role. `None` when it is not a landmark.
5477    pub fn a11y_landmark_of(&self, node: A11yNode) -> Option<accesskit::Role> {
5478        self.a11y_landmarks.get(&node).copied()
5479    }
5480}
5481
5482impl Default for Scene {
5483    fn default() -> Self {
5484        Self::new()
5485    }
5486}
5487
5488impl std::fmt::Debug for Scene {
5489    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5490        f.debug_struct("Scene")
5491            .field("len", &self.entries.len())
5492            .field("index", &self.index)
5493            .field("constrained", &self.geometry_constraint.is_some())
5494            .finish_non_exhaustive()
5495    }
5496}
5497
5498/// Half-open AABB intersection: two rects intersect iff their
5499/// projections overlap on both axes.
5500pub(crate) fn rects_intersect(a: Rect, b: Rect) -> bool {
5501    a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
5502}
5503
5504/// AABB of the union of two rectangles.
5505pub(crate) fn union_two_rects(a: Rect, b: Rect) -> Rect {
5506    let x = a.x.min(b.x);
5507    let y = a.y.min(b.y);
5508    let r = a.right().max(b.right());
5509    let bot = a.bottom().max(b.bottom());
5510    Rect::new(x, y, r - x, bot - y)
5511}
5512
5513#[cfg(test)]
5514mod tests {
5515    use super::*;
5516    use crate::items::RectItem;
5517    use teksilo_canvas::{Size, SizeProposal};
5518    use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget};
5519    use teksilo_tokens::Color;
5520
5521    #[derive(Debug)]
5522    struct FillWidget;
5523
5524    impl FillWidget {
5525        fn new() -> Self {
5526            Self
5527        }
5528    }
5529
5530    impl Widget for FillWidget {
5531        fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
5532            Size::new(0.0, 0.0).into()
5533        }
5534    }
5535
5536    #[test]
5537    fn add_widget_round_trip() {
5538        let mut scene = Scene::new();
5539        let r = Rect::new(10.0, 20.0, 100.0, 50.0);
5540        let id = scene.add_widget(FillWidget::new(), r);
5541        assert_eq!(scene.len(), 1);
5542        // scene_rect is computed from local_pos + local_bounds.
5543        assert_eq!(scene.scene_rect(id), Some(r));
5544        assert_eq!(scene.local_pos(id), Some(Point::new(10.0, 20.0)));
5545        assert_eq!(
5546            scene.local_bounds(id),
5547            Some(Rect::new(0.0, 0.0, 100.0, 50.0))
5548        );
5549        assert_eq!(scene.ids(), vec![id]);
5550    }
5551
5552    #[test]
5553    fn add_item_at_local_pos() {
5554        let mut scene = Scene::new();
5555        let id = scene.add_item(
5556            RectItem::new(Rect::new(0.0, 0.0, 30.0, 40.0)).fill(Color::RED),
5557            Point::new(10.0, 20.0),
5558        );
5559        assert_eq!(
5560            scene.scene_rect(id),
5561            Some(Rect::new(10.0, 20.0, 30.0, 40.0))
5562        );
5563        assert_eq!(scene.scene_pos(id), Some(Point::new(10.0, 20.0)));
5564    }
5565
5566    #[test]
5567    fn set_local_pos_updates_scene_rect_and_index() {
5568        let mut scene = Scene::new();
5569        let id = scene.add_item(
5570            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5571            Point::new(0.0, 0.0),
5572        );
5573        scene.set_local_pos(id, Point::new(500.0, 500.0));
5574        assert_eq!(
5575            scene.scene_rect(id),
5576            Some(Rect::new(500.0, 500.0, 10.0, 10.0))
5577        );
5578        let near_origin = scene.items_in_rect(Rect::new(0.0, 0.0, 50.0, 50.0));
5579        assert!(!near_origin.contains(&id));
5580        let near_far = scene.items_in_rect(Rect::new(490.0, 490.0, 30.0, 30.0));
5581        assert!(near_far.contains(&id));
5582    }
5583
5584    #[test]
5585    fn parent_relative_position_composes_through_chain() {
5586        let mut scene = Scene::new();
5587        let parent = scene.add_item(
5588            RectItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)),
5589            Point::new(50.0, 50.0),
5590        );
5591        let child = scene.add_item(
5592            RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)),
5593            Point::new(10.0, 10.0),
5594        );
5595        scene.set_item_parent(child, Some(parent));
5596
5597        // Child's scene_pos = parent local_pos + child local_pos.
5598        assert_eq!(scene.scene_pos(child), Some(Point::new(60.0, 60.0)));
5599        // Move parent — child's scene_pos shifts in lockstep.
5600        scene.set_local_pos(parent, Point::new(150.0, 150.0));
5601        assert_eq!(scene.scene_pos(child), Some(Point::new(160.0, 160.0)));
5602    }
5603
5604    #[test]
5605    fn set_local_pos_propagates_to_descendants_scene_pos() {
5606        // Three-deep chain: grandparent → parent → child.
5607        let mut scene = Scene::new();
5608        let gp = scene.add_item(
5609            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5610            Point::new(0.0, 0.0),
5611        );
5612        let p = scene.add_item(
5613            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5614            Point::new(20.0, 0.0),
5615        );
5616        let c = scene.add_item(
5617            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5618            Point::new(5.0, 0.0),
5619        );
5620        scene.set_item_parent(p, Some(gp));
5621        scene.set_item_parent(c, Some(p));
5622
5623        assert_eq!(scene.scene_pos(c), Some(Point::new(25.0, 0.0)));
5624        scene.set_local_pos(gp, Point::new(100.0, 100.0));
5625        assert_eq!(scene.scene_pos(c), Some(Point::new(125.0, 100.0)));
5626    }
5627
5628    #[test]
5629    fn remove_drops_the_entry() {
5630        let mut scene = Scene::new();
5631        let a = scene.add_widget(FillWidget::new(), Rect::ZERO);
5632        let b = scene.add_widget(FillWidget::new(), Rect::ZERO);
5633        scene.remove(a);
5634        assert_eq!(scene.len(), 1);
5635        assert_eq!(scene.scene_rect(a), None);
5636        assert!(scene.scene_rect(b).is_some());
5637    }
5638
5639    #[test]
5640    fn items_in_rect_brute_force() {
5641        let mut scene = Scene::new();
5642        let a = scene.add_widget(FillWidget::new(), Rect::new(0.0, 0.0, 10.0, 10.0));
5643        let b = scene.add_widget(FillWidget::new(), Rect::new(100.0, 100.0, 10.0, 10.0));
5644        let c = scene.add_widget(FillWidget::new(), Rect::new(5.0, 5.0, 10.0, 10.0));
5645
5646        let near_origin = scene.items_in_rect(Rect::new(0.0, 0.0, 20.0, 20.0));
5647        assert!(near_origin.contains(&a));
5648        assert!(near_origin.contains(&c));
5649        assert!(!near_origin.contains(&b));
5650
5651        let far = scene.items_in_rect(Rect::new(95.0, 95.0, 20.0, 20.0));
5652        assert_eq!(far, vec![b]);
5653
5654        let empty = scene.items_in_rect(Rect::new(500.0, 500.0, 1.0, 1.0));
5655        assert!(empty.is_empty());
5656    }
5657
5658    #[test]
5659    fn item_at_picks_topmost() {
5660        let mut scene = Scene::new();
5661        let bottom = scene.add_item(
5662            RectItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)),
5663            Point::new(0.0, 0.0),
5664        );
5665        let top = scene.add_item(
5666            RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)),
5667            Point::new(25.0, 25.0),
5668        );
5669        scene.set_z(top, 1.0);
5670        scene.set_z(bottom, 0.0);
5671        // Click in the overlap region.
5672        assert_eq!(scene.item_at(Point::new(50.0, 50.0)), Some(top));
5673        // Click outside the top, inside the bottom.
5674        assert_eq!(scene.item_at(Point::new(10.0, 10.0)), Some(bottom));
5675        // Click outside everything.
5676        assert_eq!(scene.item_at(Point::new(500.0, 500.0)), None);
5677    }
5678
5679    #[test]
5680    fn item_accessor_returns_lightweight_only() {
5681        let mut scene = Scene::new();
5682        let widget_id = scene.add_widget(FillWidget::new(), Rect::ZERO);
5683        let item_id = scene.add_item(
5684            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5685            Point::new(0.0, 0.0),
5686        );
5687        assert!(scene.item(item_id).is_some());
5688        assert!(scene.item(widget_id).is_none());
5689    }
5690
5691    #[test]
5692    fn map_to_scene_round_trips() {
5693        let mut scene = Scene::new();
5694        let id = scene.add_item(
5695            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5696            Point::new(50.0, 50.0),
5697        );
5698        let local = Point::new(3.0, 4.0);
5699        let scene_pt = scene.map_to_scene(id, local).unwrap();
5700        let back = scene.map_from_scene(id, scene_pt).unwrap();
5701        assert!((back.x - local.x).abs() < 1e-5);
5702        assert!((back.y - local.y).abs() < 1e-5);
5703    }
5704
5705    #[test]
5706    fn rects_intersect_edge_touching_excluded() {
5707        let a = Rect::new(0.0, 0.0, 10.0, 10.0);
5708        let b = Rect::new(10.0, 0.0, 10.0, 10.0);
5709        assert!(!rects_intersect(a, b));
5710        assert!(!rects_intersect(b, a));
5711    }
5712
5713    #[test]
5714    fn flags_default_carries_visible_enabled_selectable() {
5715        let mut scene = Scene::new();
5716        let id = scene.add_item(
5717            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5718            Point::new(0.0, 0.0),
5719        );
5720        let f = scene.flags(id).unwrap();
5721        assert!(f.contains(ItemFlags::IS_VISIBLE));
5722        assert!(f.contains(ItemFlags::IS_ENABLED));
5723        assert!(f.contains(ItemFlags::IS_SELECTABLE));
5724        assert!(!f.contains(ItemFlags::IS_DRAGGABLE));
5725    }
5726
5727    #[test]
5728    fn draggable_builder_sets_is_draggable_flag() {
5729        let mut scene = Scene::new();
5730        let id = scene.add_item(
5731            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)).draggable(true),
5732            Point::ZERO,
5733        );
5734        assert!(scene.flags(id).unwrap().contains(ItemFlags::IS_DRAGGABLE));
5735    }
5736
5737    #[test]
5738    fn set_visible_flag_chains_through_parent() {
5739        let mut scene = Scene::new();
5740        let parent = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
5741        let child = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 5.0, 5.0)), Point::ZERO);
5742        scene.set_item_parent(child, Some(parent));
5743        assert!(scene.is_effectively_visible(child));
5744        scene.set_visible(parent, false);
5745        assert!(!scene.is_effectively_visible(child));
5746        assert!(!scene.is_effectively_visible(parent));
5747    }
5748
5749    #[test]
5750    fn effective_opacity_composes_through_chain() {
5751        let mut scene = Scene::new();
5752        let p = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
5753        let c = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 5.0, 5.0)), Point::ZERO);
5754        scene.set_item_parent(c, Some(p));
5755        scene.set_opacity(p, 0.5);
5756        scene.set_opacity(c, 0.5);
5757        assert!((scene.effective_opacity(c) - 0.25).abs() < 1e-5);
5758    }
5759
5760    #[test]
5761    fn opacity_clamps_to_unit_range() {
5762        let mut scene = Scene::new();
5763        let id = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
5764        scene.set_opacity(id, 1.5);
5765        assert_eq!(scene.opacity(id), Some(1.0));
5766        scene.set_opacity(id, -0.3);
5767        assert_eq!(scene.opacity(id), Some(0.0));
5768    }
5769
5770    #[test]
5771    fn scene_rect_extent_uses_user_set_when_present() {
5772        let mut scene = Scene::new();
5773        let user = Rect::new(0.0, 0.0, 1000.0, 1000.0);
5774        scene.set_scene_rect(Some(user));
5775        assert_eq!(scene.scene_rect_extent(), Some(user));
5776        scene.set_scene_rect(None);
5777        // No items, no auto-extent.
5778        assert_eq!(scene.scene_rect_extent(), None);
5779    }
5780
5781    #[test]
5782    fn scene_rect_extent_auto_unions_items_when_unset() {
5783        let mut scene = Scene::new();
5784        scene.add_item(
5785            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5786            Point::new(5.0, 5.0),
5787        );
5788        scene.add_item(
5789            RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)),
5790            Point::new(100.0, 100.0),
5791        );
5792        let extent = scene.scene_rect_extent().unwrap();
5793        // (5,5)-(15,15) ∪ (100,100)-(120,120) = (5,5)-(120,120).
5794        assert!((extent.x - 5.0).abs() < 1e-3);
5795        assert!((extent.y - 5.0).abs() < 1e-3);
5796        assert!((extent.width - 115.0).abs() < 1e-3);
5797        assert!((extent.height - 115.0).abs() < 1e-3);
5798    }
5799
5800    #[test]
5801    fn pan_axes_default_is_both() {
5802        let scene = Scene::new();
5803        assert_eq!(scene.current_pan_axes(), PanAxes::Both);
5804        assert!(scene.is_zoomable());
5805    }
5806
5807    #[test]
5808    fn pan_axes_set_round_trip() {
5809        let mut scene = Scene::new();
5810        scene.pan_axes(PanAxes::Horizontal);
5811        assert_eq!(scene.current_pan_axes(), PanAxes::Horizontal);
5812        scene.zoomable(false);
5813        assert!(!scene.is_zoomable());
5814    }
5815
5816    #[test]
5817    fn item_change_signal_fires_on_set_local_pos() {
5818        use std::rc::Rc;
5819        let mut scene = Scene::new();
5820        let id = scene.add_item(
5821            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5822            Point::new(0.0, 0.0),
5823        );
5824        let last = Rc::new(std::cell::RefCell::new(None::<ItemChange>));
5825        let last_clone = last.clone();
5826        let _h = scene.item_change_signal().observe(move |c| {
5827            *last_clone.borrow_mut() = Some(c.change.clone());
5828        });
5829        scene.set_local_pos(id, Point::new(50.0, 60.0));
5830        let taken = last.borrow().clone();
5831        match taken {
5832            Some(ItemChange::LocalPosChanged { new, .. }) => {
5833                assert_eq!(new, Point::new(50.0, 60.0));
5834            }
5835            other => panic!("expected LocalPosChanged, got {:?}", other),
5836        }
5837    }
5838
5839    #[test]
5840    fn item_change_signal_fires_on_set_visible() {
5841        use std::cell::Cell;
5842        use std::rc::Rc;
5843        let mut scene = Scene::new();
5844        let id = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
5845        let count = Rc::new(Cell::new(0_u32));
5846        let count_clone = count.clone();
5847        let _h = scene.item_change_signal().observe(move |c| {
5848            if matches!(c.change, ItemChange::VisibilityChanged { .. }) {
5849                count_clone.set(count_clone.get() + 1);
5850            }
5851        });
5852        scene.set_visible(id, false);
5853        scene.set_visible(id, true);
5854        // Same value twice: only one fire.
5855        scene.set_visible(id, true);
5856        assert_eq!(count.get(), 2);
5857    }
5858
5859    #[test]
5860    fn colliding_items_returns_overlapping_set_excluding_self() {
5861        let mut scene = Scene::new();
5862        let a = scene.add_item(
5863            RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)),
5864            Point::new(10.0, 10.0),
5865        );
5866        let b = scene.add_item(
5867            RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)),
5868            Point::new(40.0, 10.0),
5869        );
5870        let c = scene.add_item(
5871            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5872            Point::new(500.0, 500.0),
5873        );
5874        let collisions = scene.colliding_items(a);
5875        assert!(collisions.contains(&b));
5876        assert!(!collisions.contains(&a));
5877        assert!(!collisions.contains(&c));
5878    }
5879
5880    #[test]
5881    fn items_along_path_finds_items_the_path_actually_crosses() {
5882        let mut scene = Scene::new();
5883        let a = scene.add_item(
5884            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5885            Point::new(20.0, 20.0),
5886        );
5887        let b = scene.add_item(
5888            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5889            Point::new(200.0, 200.0),
5890        );
5891        let mut path = Path::new();
5892        path.move_to(Point::new(15.0, 15.0));
5893        path.line_to(Point::new(40.0, 40.0));
5894        let hits = scene.items_along_path(&path);
5895        assert!(hits.contains(&a));
5896        assert!(!hits.contains(&b));
5897    }
5898
5899    #[test]
5900    fn items_along_path_no_longer_picks_up_the_paths_whole_bounding_box() {
5901        // This is the row the old AABB-vs-AABB narrow phase got wrong: the
5902        // corner item sits inside the diagonal's bounding box and nowhere near
5903        // the diagonal itself.
5904        let mut scene = Scene::new();
5905        let off_the_line = scene.add_item(
5906            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5907            Point::new(2.0, 85.0),
5908        );
5909        let mut path = Path::new();
5910        path.move_to(Point::new(0.0, 0.0));
5911        path.line_to(Point::new(100.0, 100.0));
5912        assert!(!scene.items_along_path(&path).contains(&off_the_line));
5913        // A wide enough connector does reach it.
5914        assert!(
5915            scene
5916                .items_along_path_with(&path, 200.0, ItemSelectionMode::IntersectsItemShape)
5917                .contains(&off_the_line)
5918        );
5919    }
5920
5921    #[test]
5922    fn colliding_items_uses_the_shape_not_the_box() {
5923        // A stroke-only diagonal connector crosses `crossed` and merely
5924        // overlaps `beside` in the bounding-box sense.
5925        let mut scene = Scene::new();
5926        let mut path = Path::new();
5927        path.move_to(Point::new(0.0, 0.0));
5928        path.line_to(Point::new(100.0, 100.0));
5929        let wire = scene.add_item(
5930            crate::items::PathItem::new(path).stroke(teksilo_tokens::Color::BLACK, 2.0),
5931            Point::ZERO,
5932        );
5933        let crossed = scene.add_item(
5934            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5935            Point::new(45.0, 45.0),
5936        );
5937        let beside = scene.add_item(
5938            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
5939            Point::new(2.0, 85.0),
5940        );
5941        let hits = scene.colliding_items(wire);
5942        assert!(hits.contains(&crossed));
5943        assert!(
5944            !hits.contains(&beside),
5945            "the empty corner of its box is not it"
5946        );
5947
5948        // The mode is applied to the *other* item, exactly as Qt's
5949        // `collidesWithItem` applies it: asking what collides with `beside`,
5950        // the wire's own shape misses and the wire's bounding rect hits.
5951        assert!(
5952            !scene
5953                .colliding_items_with(beside, ItemSelectionMode::IntersectsItemShape)
5954                .contains(&wire),
5955            "the wire's stroke does not reach it"
5956        );
5957        assert!(
5958            scene
5959                .colliding_items_with(beside, ItemSelectionMode::IntersectsItemBoundingRect)
5960                .contains(&wire),
5961            "but the wire's bounding rect does — the cheap mode, by name"
5962        );
5963    }
5964
5965    #[test]
5966    fn an_entry_box_always_mirrors_the_items_own_box() {
5967        // M2 / study 1.9: the rectangle the spatial index buckets on is the
5968        // entry's `local_bounds`, and it is read back from the item after
5969        // every write. A `PathItem` fits its geometry to the request and
5970        // re-derives its box from the result, so the index can never bucket a
5971        // rectangle the geometry has not occupied.
5972        let mut scene = Scene::new();
5973        let mut path = Path::new();
5974        path.move_to(Point::new(0.0, 0.0));
5975        path.line_to(Point::new(100.0, 0.0));
5976        let id = scene.add_item(
5977            crate::items::PathItem::new(path).stroke(teksilo_tokens::Color::BLACK, 4.0),
5978            Point::ZERO,
5979        );
5980        for request in [
5981            Rect::new(0.0, 0.0, 200.0, 20.0),
5982            Rect::new(-50.0, -50.0, 10.0, 10.0),
5983            Rect::new(0.0, 0.0, 0.0, 0.0),
5984        ] {
5985            scene.set_local_bounds(id, request);
5986            let entry_box = scene.local_bounds(id).expect("known id");
5987            let item_box = scene.item(id).expect("lightweight").local_bounds();
5988            assert_eq!(entry_box, item_box, "after requesting {request:?}");
5989            let shape_box = scene.item_shape(id).expect("has a shape").bounding_rect();
5990            assert!(
5991                entry_box.x <= shape_box.x + 1e-3
5992                    && entry_box.y <= shape_box.y + 1e-3
5993                    && entry_box.right() >= shape_box.right() - 1e-3
5994                    && entry_box.bottom() >= shape_box.bottom() - 1e-3,
5995                "the index box {entry_box:?} must enclose the shape {shape_box:?}"
5996            );
5997        }
5998    }
5999
6000    #[test]
6001    fn a_derived_box_lands_on_the_request_and_stays_there() {
6002        // The setter used to aim the *box* at the request and then re-add the
6003        // band, overshooting by the band every time: three identical calls
6004        // gave three different boxes, none of them the one asked for, and each
6005        // emitted a change and re-bucketed the index. It aims at the geometry
6006        // now, so one call lands it and the next two are no-ops.
6007        use std::cell::RefCell;
6008        use std::rc::Rc;
6009        let mut scene = Scene::new();
6010        let mut path = Path::new();
6011        path.move_to(Point::ZERO).line_to(Point::new(100.0, 0.0));
6012        let id = scene.add_item(
6013            crate::items::PathItem::new(path).stroke(teksilo_tokens::Color::BLACK, 4.0),
6014            Point::ZERO,
6015        );
6016        assert_eq!(
6017            scene.local_bounds(id),
6018            Some(Rect::new(-4.0, -4.0, 108.0, 8.0))
6019        );
6020
6021        let seen: Rc<RefCell<Vec<Rect>>> = Rc::new(RefCell::new(Vec::new()));
6022        let sink = seen.clone();
6023        let _h = scene.item_change_signal().observe(move |c| {
6024            if let ItemChange::LocalBoundsChanged { new, .. } = &c.change {
6025                sink.borrow_mut().push(*new);
6026            }
6027        });
6028
6029        let request = Rect::new(0.0, 0.0, 200.0, 20.0);
6030        for attempt in 0..3 {
6031            scene.set_local_bounds(id, request);
6032            let got = scene.local_bounds(id).expect("known id");
6033            assert!(
6034                (got.x - request.x).abs() < 1e-3 && (got.width - request.width).abs() < 1e-3,
6035                "attempt {attempt}: x/width must be the request, got {got:?}"
6036            );
6037            // The path is perfectly horizontal, so its height is the band's
6038            // and no request can stretch it. Honest, and stated on the setter.
6039            assert!(
6040                (got.height - 8.0).abs() < 1e-3,
6041                "attempt {attempt}: {got:?}"
6042            );
6043        }
6044        assert_eq!(
6045            seen.borrow().len(),
6046            1,
6047            "three identical requests, one change: {:?}",
6048            seen.borrow()
6049        );
6050    }
6051
6052    #[test]
6053    fn a_derived_box_honours_both_axes_when_the_geometry_has_both() {
6054        let mut scene = Scene::new();
6055        let mut path = Path::new();
6056        path.move_to(Point::ZERO)
6057            .line_to(Point::new(100.0, 40.0))
6058            .line_to(Point::new(0.0, 40.0))
6059            .close();
6060        let id = scene.add_item(
6061            crate::items::PathItem::new(path).stroke(teksilo_tokens::Color::BLACK, 6.0),
6062            Point::ZERO,
6063        );
6064        let request = Rect::new(-20.0, 5.0, 300.0, 120.0);
6065        scene.set_local_bounds(id, request);
6066        let got = scene.local_bounds(id).expect("known id");
6067        assert!(
6068            (got.x - request.x).abs() < 1e-2
6069                && (got.y - request.y).abs() < 1e-2
6070                && (got.width - request.width).abs() < 1e-2
6071                && (got.height - request.height).abs() < 1e-2,
6072            "asked {request:?}, settled on {got:?}"
6073        );
6074        // And it is a fixed point.
6075        scene.set_local_bounds(id, request);
6076        assert_eq!(scene.local_bounds(id), Some(got));
6077    }
6078
6079    #[test]
6080    fn region_queries_decline_a_screen_anchored_item_exactly_as_point_queries_do() {
6081        // D5: the "declines to guess" rule applied to one query family only
6082        // was the inconsistency. A screen-pinned item's `local_bounds` is a
6083        // screen rectangle, so the scene AABB the index holds for it is a
6084        // fiction, and a scene-space band comparing itself against that
6085        // fiction is guessing exactly as `item_at` refused to.
6086        let mut scene = Scene::new();
6087        let pinned = scene.add_item(
6088            RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)),
6089            Point::new(50.0, 50.0),
6090        );
6091        let normal = scene.add_item(
6092            RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)),
6093            Point::new(50.0, 50.0),
6094        );
6095        let everything = SceneRegion::rect(Rect::new(-1000.0, -1000.0, 2000.0, 2000.0));
6096        assert!(
6097            scene
6098                .items_in_region(&everything, ItemSelectionMode::default(), 1.0)
6099                .contains(&pinned),
6100            "selectable while it is an ordinary item"
6101        );
6102        scene.set_flag(pinned, ItemFlags::IGNORES_TRANSFORMATIONS, true);
6103        for mode in [
6104            ItemSelectionMode::IntersectsItemShape,
6105            ItemSelectionMode::ContainsItemShape,
6106            ItemSelectionMode::IntersectsItemBoundingRect,
6107            ItemSelectionMode::ContainsItemBoundingRect,
6108        ] {
6109            let hits = scene.items_in_region(&everything, mode, 1.0);
6110            assert!(!hits.contains(&pinned), "{mode:?} guessed at a pinned item");
6111            assert!(hits.contains(&normal), "{mode:?} lost an ordinary one");
6112        }
6113        assert_eq!(scene.item_at(Point::new(55.0, 55.0)), Some(normal));
6114        // The collision family is built on the same query, from both ends.
6115        assert!(scene.item_region(pinned).is_none());
6116        assert!(scene.colliding_items(pinned).is_empty());
6117        assert!(!scene.colliding_items(normal).contains(&pinned));
6118    }
6119
6120    #[test]
6121    fn a_plain_items_box_is_still_exactly_what_was_asked_for() {
6122        // The read-back must not change anything for an item that stores its
6123        // bounds verbatim, which is every built-in but `PathItem`.
6124        let mut scene = Scene::new();
6125        let id = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
6126        scene.set_local_bounds(id, Rect::new(3.0, 4.0, 50.0, 60.0));
6127        assert_eq!(
6128            scene.local_bounds(id),
6129            Some(Rect::new(3.0, 4.0, 50.0, 60.0))
6130        );
6131    }
6132
6133    #[test]
6134    fn a_heavyweight_entry_has_a_defined_shape() {
6135        // `Scene::item` returns None for a widget entry, so every shape-mode
6136        // query would silently drop the tier the flagship corkboard's cards
6137        // live in unless the shape is defined for it. It is: its box.
6138        let mut scene = Scene::new();
6139        let card = scene.add_widget(FillWidget::new(), Rect::new(10.0, 10.0, 100.0, 60.0));
6140        assert!(scene.item(card).is_none());
6141        let shape = scene.item_shape(card).expect("a widget entry has a shape");
6142        assert_eq!(shape.bounding_rect(), Rect::new(0.0, 0.0, 100.0, 60.0));
6143        assert!(scene.item_contains(card, Point::new(50.0, 40.0), 1.0));
6144        assert!(!scene.item_contains(card, Point::new(500.0, 40.0), 1.0));
6145    }
6146
6147    #[test]
6148    fn a_rotated_heavyweight_card_selects_the_same_in_both_shape_and_box_modes() {
6149        // B1 + C1: the corkboard's primary objects are heavyweight, and this
6150        // pins that rotating one does not quietly change what a marquee
6151        // enclosing it picks up. (The two modes disagree only at the margin —
6152        // see `the_two_spaces_differ_for_a_rotated_item`.)
6153        let mut scene = Scene::new();
6154        let card = scene.add_widget(FillWidget::new(), Rect::new(100.0, 100.0, 80.0, 40.0));
6155        let region = SceneRegion::rect(Rect::new(0.0, 0.0, 400.0, 400.0));
6156        for mode in [
6157            ItemSelectionMode::IntersectsItemShape,
6158            ItemSelectionMode::IntersectsItemBoundingRect,
6159            ItemSelectionMode::ContainsItemShape,
6160            ItemSelectionMode::ContainsItemBoundingRect,
6161        ] {
6162            assert!(
6163                scene.items_in_region(&region, mode, 1.0).contains(&card),
6164                "before rotation, {mode:?}"
6165            );
6166        }
6167        scene.set_transform(card, Transform2D::rotate(0.4));
6168        for mode in [
6169            ItemSelectionMode::IntersectsItemShape,
6170            ItemSelectionMode::IntersectsItemBoundingRect,
6171            ItemSelectionMode::ContainsItemShape,
6172            ItemSelectionMode::ContainsItemBoundingRect,
6173        ] {
6174            assert!(
6175                scene.items_in_region(&region, mode, 1.0).contains(&card),
6176                "after rotation, {mode:?}"
6177            );
6178        }
6179    }
6180
6181    #[test]
6182    fn the_two_spaces_differ_for_a_rotated_item() {
6183        // C1, stated as a test rather than as a claim: a shape query maps the
6184        // region into the item's own frame, a bounding-rect query compares the
6185        // enlarged axis-aligned hull in scene space. A band that clips only
6186        // the hull's corner therefore picks the item in box mode and not in
6187        // shape mode.
6188        let mut scene = Scene::new();
6189        let id = scene.add_item(
6190            RectItem::new(Rect::new(-50.0, -10.0, 100.0, 20.0)),
6191            Point::new(100.0, 100.0),
6192        );
6193        scene.set_transform(id, Transform2D::rotate(std::f32::consts::FRAC_PI_4));
6194        let hull = scene.scene_rect(id).expect("has a scene rect");
6195        // A 4x4 band on the hull's top-left corner: inside the hull, outside
6196        // the rotated bar.
6197        let corner = SceneRegion::rect(Rect::new(hull.x, hull.y, 4.0, 4.0));
6198        assert!(
6199            scene
6200                .items_in_region(&corner, ItemSelectionMode::IntersectsItemBoundingRect, 1.0)
6201                .contains(&id),
6202            "the hull's corner is in the hull"
6203        );
6204        assert!(
6205            !scene
6206                .items_in_region(&corner, ItemSelectionMode::IntersectsItemShape, 1.0)
6207                .contains(&id),
6208            "but it is not on the bar"
6209        );
6210    }
6211
6212    #[test]
6213    fn item_at_declines_to_place_a_screen_anchored_item() {
6214        // Its local coordinates are screen coordinates, so a scene-space point
6215        // cannot place it. `item_at_in_view` can, and does.
6216        let mut scene = Scene::new();
6217        let pinned = scene.add_item(
6218            RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)),
6219            Point::new(50.0, 50.0),
6220        );
6221        scene.set_flag(pinned, ItemFlags::IGNORES_TRANSFORMATIONS, true);
6222        assert_eq!(scene.item_at(Point::new(55.0, 55.0)), None);
6223        // Under an identity view its anchor is (50, 50), so a screen point
6224        // 5 px in lands on it.
6225        assert_eq!(
6226            scene.item_at_in_view(Point::new(55.0, 55.0), Transform2D::identity()),
6227            Some(pinned)
6228        );
6229        // Pan the view: the pinned item follows its anchor's projection.
6230        let panned = Transform2D::translate(200.0, 0.0);
6231        assert_eq!(
6232            scene.item_at_in_view(Point::new(255.0, 55.0), panned),
6233            Some(pinned)
6234        );
6235    }
6236
6237    #[test]
6238    fn item_at_scaled_agrees_with_dispatch_about_a_cosmetic_stroke() {
6239        // The mismatch nobody had written down: `item_at` passed no view
6240        // scale while `SceneView` dispatch passed the live one, so the two
6241        // disagreed about a cosmetic band at any zoom but 1.
6242        let mut scene = Scene::new();
6243        let mut path = Path::new();
6244        path.move_to(Point::new(0.0, 0.0));
6245        path.line_to(Point::new(100.0, 0.0));
6246        let wire = scene.add_item(
6247            crate::items::PathItem::new(path).stroke_cosmetic(teksilo_tokens::Color::BLACK, 4.0),
6248            Point::ZERO,
6249        );
6250        let p = Point::new(50.0, 3.0);
6251        assert_eq!(scene.item_at_scaled(p, 1.0), Some(wire));
6252        assert_eq!(scene.item_at_scaled(p, 4.0), None);
6253        assert_eq!(scene.item_at(p), scene.item_at_scaled(p, 1.0));
6254    }
6255
6256    // -----------------------------------------------------------------
6257    // R6 — code-level fixes
6258    // -----------------------------------------------------------------
6259
6260    #[test]
6261    fn scene_remove_recursively_removes_descendants() {
6262        let mut scene = Scene::new();
6263        let parent = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)), Point::ZERO);
6264        let child = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)), Point::ZERO);
6265        let grandchild = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 5.0, 5.0)), Point::ZERO);
6266        scene.set_item_parent(child, Some(parent));
6267        scene.set_item_parent(grandchild, Some(child));
6268        assert_eq!(scene.entries.len(), 3);
6269        scene.remove(parent);
6270        // Parent + child + grandchild all gone.
6271        assert!(scene.scene_rect(parent).is_none());
6272        assert!(scene.scene_rect(child).is_none());
6273        assert!(scene.scene_rect(grandchild).is_none());
6274        assert_eq!(scene.entries.len(), 0);
6275    }
6276
6277    #[test]
6278    fn scene_orphan_promotes_children_to_root() {
6279        let mut scene = Scene::new();
6280        let parent = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)), Point::ZERO);
6281        let child = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0)), Point::ZERO);
6282        scene.set_item_parent(child, Some(parent));
6283        scene.orphan(parent);
6284        // Child's parent is now None.
6285        assert_eq!(scene.parent_of(child), None);
6286        // Both still present.
6287        scene.remove(parent);
6288        assert!(scene.scene_rect(child).is_some());
6289    }
6290
6291    #[test]
6292    fn scene_orphan_rebuckets_detached_children() {
6293        // After orphaning, the spatial index must reflect children's
6294        // new scene-AABBs. Move a parent off-origin, attach a child,
6295        // then orphan — items_in_rect at the child's *child-local*
6296        // origin must now return it (because its scene_transform no
6297        // longer composes the parent's offset).
6298        let mut scene = Scene::new();
6299        let parent = scene.add_item(
6300            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
6301            Point::new(500.0, 500.0),
6302        );
6303        let child = scene.add_item(
6304            RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)),
6305            Point::new(0.0, 0.0),
6306        );
6307        scene.set_item_parent(child, Some(parent));
6308        // Pre-orphan: child sits at scene (500, 500).
6309        assert!(
6310            scene
6311                .items_in_rect(Rect::new(495.0, 495.0, 20.0, 20.0))
6312                .contains(&child)
6313        );
6314        scene.orphan(parent);
6315        // Post-orphan: child sits at scene (0, 0); the index must
6316        // reflect that — query at the new origin must hit, query at
6317        // the old origin must miss.
6318        assert!(
6319            scene
6320                .items_in_rect(Rect::new(-5.0, -5.0, 20.0, 20.0))
6321                .contains(&child)
6322        );
6323        assert!(
6324            !scene
6325                .items_in_rect(Rect::new(495.0, 495.0, 20.0, 20.0))
6326                .contains(&child)
6327        );
6328    }
6329
6330    #[test]
6331    fn add_item_dynamic_re_reads_bounds_on_refresh() {
6332        // An item whose `local_bounds` reads from a Cell. Mutating
6333        // the cell + calling refresh_dynamic_bounds must update the
6334        // entry and re-bucket the spatial index.
6335        use crate::item::{SceneItem, SceneItemPaintContext};
6336        use std::cell::Cell;
6337        use std::rc::Rc;
6338
6339        #[derive(Debug)]
6340        struct DynRect {
6341            bounds: Rc<Cell<Rect>>,
6342        }
6343        impl SceneItem for DynRect {
6344            fn local_bounds(&self) -> Rect {
6345                self.bounds.get()
6346            }
6347            fn set_local_bounds(&mut self, b: Rect) {
6348                self.bounds.set(b);
6349            }
6350            fn paint(&self, _: &mut teksilo_canvas::Canvas, _: &SceneItemPaintContext<'_>) {}
6351        }
6352
6353        let bounds = Rc::new(Cell::new(Rect::new(0.0, 0.0, 10.0, 10.0)));
6354        let mut scene = Scene::new();
6355        let id = scene.add_item_dynamic(
6356            DynRect {
6357                bounds: bounds.clone(),
6358            },
6359            Point::ZERO,
6360        );
6361        // Initially items_in_rect over the small AABB hits.
6362        assert!(
6363            scene
6364                .items_in_rect(Rect::new(0.0, 0.0, 50.0, 50.0))
6365                .contains(&id)
6366        );
6367        // Grow the bounds via the Cell — Scene's cached entry/index
6368        // is stale until refresh_dynamic_bounds runs.
6369        bounds.set(Rect::new(0.0, 0.0, 500.0, 500.0));
6370        scene.refresh_dynamic_bounds();
6371        // After refresh, the spatial index sees the larger AABB.
6372        assert!(
6373            scene
6374                .items_in_rect(Rect::new(400.0, 400.0, 10.0, 10.0))
6375                .contains(&id)
6376        );
6377    }
6378
6379    #[test]
6380    fn add_item_static_does_not_track_signal_changes() {
6381        // Counterpart to the dynamic test: a static item's bounds
6382        // are snapshotted at insert time; refresh_dynamic_bounds
6383        // does not re-read them.
6384        use crate::item::{SceneItem, SceneItemPaintContext};
6385        use std::cell::Cell;
6386        use std::rc::Rc;
6387
6388        #[derive(Debug)]
6389        struct DynRect {
6390            bounds: Rc<Cell<Rect>>,
6391        }
6392        impl SceneItem for DynRect {
6393            fn local_bounds(&self) -> Rect {
6394                self.bounds.get()
6395            }
6396            fn set_local_bounds(&mut self, b: Rect) {
6397                self.bounds.set(b);
6398            }
6399            fn paint(&self, _: &mut teksilo_canvas::Canvas, _: &SceneItemPaintContext<'_>) {}
6400        }
6401
6402        let bounds = Rc::new(Cell::new(Rect::new(0.0, 0.0, 10.0, 10.0)));
6403        let mut scene = Scene::new();
6404        let id = scene.add_item(
6405            DynRect {
6406                bounds: bounds.clone(),
6407            },
6408            Point::ZERO,
6409        );
6410        bounds.set(Rect::new(0.0, 0.0, 500.0, 500.0));
6411        scene.refresh_dynamic_bounds();
6412        // Static entry's spatial index unchanged.
6413        assert!(
6414            !scene
6415                .items_in_rect(Rect::new(400.0, 400.0, 10.0, 10.0))
6416                .contains(&id)
6417        );
6418    }
6419
6420    // ------------------------------------------------- parent ⇄ children
6421
6422    /// The invariant the kept adjacency stands on. Every downward walk in this
6423    /// file — the index re-bucket, `collect_descendants`, and through them
6424    /// removal, drag-group moves and the view's snapshot patch — reads
6425    /// `children`, so a list that drifts from the parent pointers is not a
6426    /// slow scene, it is a wrong one.
6427    fn assert_parent_and_children_agree(scene: &Scene, what: &str) {
6428        for entry in &scene.entries {
6429            let mut seen = std::collections::HashSet::new();
6430            for child in &entry.children {
6431                assert!(
6432                    seen.insert(*child),
6433                    "{what}: {:?} lists {:?} twice",
6434                    entry.id,
6435                    child
6436                );
6437                assert_eq!(
6438                    scene.parent_of(*child),
6439                    Some(entry.id),
6440                    "{what}: {:?} lists {:?}, which does not point back",
6441                    entry.id,
6442                    child
6443                );
6444            }
6445        }
6446        for entry in &scene.entries {
6447            let Some(parent) = entry.parent else { continue };
6448            let pos = scene.entry_index[&parent];
6449            assert!(
6450                scene.entries[pos].children.contains(&entry.id),
6451                "{what}: {:?} points at {:?}, which does not list it",
6452                entry.id,
6453                parent
6454            );
6455        }
6456    }
6457
6458    fn unit(scene: &mut Scene, at: Point) -> ItemId {
6459        scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), at)
6460    }
6461
6462    #[test]
6463    fn parent_and_children_agree_through_every_door() {
6464        let mut scene = Scene::new();
6465        let a = unit(&mut scene, Point::ZERO);
6466        let b = unit(&mut scene, Point::new(10.0, 0.0));
6467        let c = unit(&mut scene, Point::new(20.0, 0.0));
6468        let d = unit(&mut scene, Point::new(30.0, 0.0));
6469        assert_parent_and_children_agree(&scene, "after insert");
6470
6471        scene.set_item_parent(b, Some(a));
6472        scene.set_item_parent(c, Some(b));
6473        scene.set_item_parent(d, Some(b));
6474        assert_parent_and_children_agree(&scene, "after parenting");
6475
6476        // Re-parent: the old list must lose it and the new one gain it.
6477        scene.set_item_parent(d, Some(a));
6478        assert_parent_and_children_agree(&scene, "after re-parenting");
6479        assert_eq!(scene.entries[scene.entry_index[&b]].children, vec![c]);
6480
6481        // Rejected by the cycle guard — and must leave the adjacency alone.
6482        scene.set_item_parent(a, Some(c));
6483        assert_parent_and_children_agree(&scene, "after a rejected re-parent");
6484        assert_eq!(scene.parent_of(a), None);
6485
6486        scene.orphan(a);
6487        assert_parent_and_children_agree(&scene, "after orphan");
6488        assert!(scene.entries[scene.entry_index[&a]].children.is_empty());
6489
6490        scene.set_item_parent(c, Some(b));
6491        scene.remove(b);
6492        assert_parent_and_children_agree(&scene, "after removing a subtree");
6493        assert_eq!(scene.scene_rect(c), None, "the subtree went with it");
6494    }
6495
6496    /// A removed child must not stay in its surviving parent's list: the next
6497    /// move would walk into an entry that no longer exists.
6498    #[test]
6499    fn removing_a_child_unlinks_it_from_a_surviving_parent() {
6500        let mut scene = Scene::new();
6501        let parent = unit(&mut scene, Point::ZERO);
6502        let keep = unit(&mut scene, Point::new(10.0, 0.0));
6503        let drop = unit(&mut scene, Point::new(20.0, 0.0));
6504        scene.set_item_parent(keep, Some(parent));
6505        scene.set_item_parent(drop, Some(parent));
6506
6507        scene.remove(drop);
6508        assert_parent_and_children_agree(&scene, "after removing one child");
6509        assert_eq!(
6510            scene.entries[scene.entry_index[&parent]].children,
6511            vec![keep]
6512        );
6513
6514        // The walk still reaches the survivor.
6515        scene.set_local_pos(parent, Point::new(100.0, 0.0));
6516        assert_eq!(scene.scene_pos(keep), Some(Point::new(110.0, 0.0)));
6517        // Starts past the parent's own box (100,0)–(110,10), so only the
6518        // survivor's re-bucketed rect (110,0)–(120,10) can answer.
6519        assert_eq!(
6520            scene.items_in_rect(Rect::new(112.0, -5.0, 20.0, 20.0)),
6521            vec![keep]
6522        );
6523    }
6524
6525    /// `orphan` bypasses `set_item_parent`, so it has to maintain the adjacency
6526    /// itself — and a detached child must stop following its old parent.
6527    #[test]
6528    fn an_orphaned_child_stops_following_its_old_parent() {
6529        let mut scene = Scene::new();
6530        let parent = unit(&mut scene, Point::ZERO);
6531        let child = unit(&mut scene, Point::new(10.0, 0.0));
6532        scene.set_item_parent(child, Some(parent));
6533        scene.orphan(parent);
6534
6535        scene.set_local_pos(parent, Point::new(500.0, 0.0));
6536        assert_eq!(
6537            scene.scene_pos(child),
6538            Some(Point::new(10.0, 0.0)),
6539            "an orphaned child keeps its own frame",
6540        );
6541        assert_eq!(
6542            scene.items_in_rect(Rect::new(5.0, -5.0, 20.0, 20.0)),
6543            vec![child],
6544            "…and the spatial index agrees",
6545        );
6546    }
6547
6548    /// The re-bucket walks down the kept adjacency, so a *re-parented*
6549    /// grandchild must still follow a move of the new root. A `children` list
6550    /// updated on one side only would leave the index — and therefore every
6551    /// rect query and the view's cull — pointing at the old place.
6552    #[test]
6553    fn a_reparented_subtree_still_rebuckets_on_a_move() {
6554        let mut scene = Scene::new();
6555        let a = unit(&mut scene, Point::ZERO);
6556        let b = unit(&mut scene, Point::new(10.0, 0.0));
6557        let leaf = unit(&mut scene, Point::new(20.0, 0.0));
6558        scene.set_item_parent(leaf, Some(a));
6559        // …and then moved across to `b`.
6560        scene.set_item_parent(leaf, Some(b));
6561
6562        scene.set_local_pos(b, Point::new(200.0, 0.0));
6563        assert_eq!(scene.scene_pos(leaf), Some(Point::new(220.0, 0.0)));
6564        assert_eq!(
6565            scene.items_in_rect(Rect::new(215.0, -5.0, 20.0, 20.0)),
6566            vec![leaf],
6567            "the index followed the re-parented leaf",
6568        );
6569        // And the old parent no longer drags it.
6570        scene.set_local_pos(a, Point::new(-500.0, 0.0));
6571        assert_eq!(scene.scene_pos(leaf), Some(Point::new(220.0, 0.0)));
6572    }
6573
6574    /// `collect_descendants` reports each parent before its own children, which
6575    /// is what makes `remove`'s reversal a leaves-first order.
6576    #[test]
6577    fn collect_descendants_reports_parents_before_children() {
6578        let mut scene = Scene::new();
6579        let root = unit(&mut scene, Point::ZERO);
6580        let mid = unit(&mut scene, Point::ZERO);
6581        let leaf = unit(&mut scene, Point::ZERO);
6582        let sibling = unit(&mut scene, Point::ZERO);
6583        scene.set_item_parent(mid, Some(root));
6584        scene.set_item_parent(leaf, Some(mid));
6585        scene.set_item_parent(sibling, Some(root));
6586
6587        let mut out = Vec::new();
6588        scene.collect_descendants(root, &mut out);
6589        assert_eq!(out.len(), 3);
6590        let pos = |id: ItemId| out.iter().position(|o| *o == id).expect("present");
6591        assert!(
6592            pos(mid) < pos(leaf),
6593            "a parent is reported before its child"
6594        );
6595        assert!(out.contains(&sibling));
6596        assert!(!out.contains(&root), "the id itself is not a descendant");
6597    }
6598
6599    /// The side lists `SceneView::build` reads are kept, so they must survive
6600    /// the mutators that change what is in them — in entry order, which the
6601    /// view relies on for child ordering.
6602    #[test]
6603    fn the_heavyweight_and_dynamic_side_lists_track_the_entries() {
6604        let mut scene = Scene::new();
6605        let w1 = scene.add_widget(FillWidget::new(), Rect::ZERO);
6606        let light = unit(&mut scene, Point::ZERO);
6607        let w2 = scene.add_widget(FillWidget::new(), Rect::ZERO);
6608        assert_eq!(scene.heavyweight_ids(), vec![w1, w2], "in entry order");
6609
6610        scene.remove(w1);
6611        assert_eq!(scene.heavyweight_ids(), vec![w2]);
6612        scene.remove(light);
6613        assert_eq!(scene.heavyweight_ids(), vec![w2]);
6614
6615        // A dynamic item is discovered by the refresh through the same route.
6616        let dynamic =
6617            scene.add_item_dynamic(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
6618        assert_eq!(scene.dynamic, vec![dynamic]);
6619        scene.remove(dynamic);
6620        assert!(scene.dynamic.is_empty());
6621        assert!(!scene.refresh_dynamic_bounds(), "nothing dynamic is left");
6622    }
6623}