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 } = ¬ification.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 ¬ification,
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(®ion, 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(®ion, 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(®ion, 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(®ion, 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}