Skip to main content

teksilo_core/
arena.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use slotmap::SlotMap;
5
6use crate::environment::ThemeOverride;
7use crate::event_handlers::EventHandlers;
8use crate::event_source::{SubscriptionHandle, SubscriptionId};
9use crate::gesture::MultiContact;
10use crate::pointer::hit_slop::{HitCandidate, HitContext};
11use crate::pointer::touch_action::{PanClaim, TouchAction};
12use crate::signal::{ObserverHandle, Prop, Signal};
13use crate::widget::{CursorIcon, Widget};
14use crate::widget_id::WidgetId;
15use teksilo_canvas::RenderFrame;
16
17/// Minimal placeholder widget used during composite rebuild and ID reservation.
18#[derive(Debug)]
19pub(crate) struct PlaceholderWidget;
20
21impl Widget for PlaceholderWidget {
22    fn layout_response(
23        &self,
24        _proposal: teksilo_canvas::SizeProposal,
25        _ctx: &crate::widget::LayoutContext,
26    ) -> crate::widget::LayoutResponse {
27        teksilo_canvas::Size::ZERO.into()
28    }
29}
30
31/// Activation state for a widget in the arena.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ActivationState {
34    Active,
35    Dormant,
36    Destroyed,
37}
38
39/// Where a `HandlerSet` should land on the node: handlers the widget
40/// attaches to itself (cleared on rebuild) vs handlers attached from
41/// outside (persist across rebuilds).
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub(crate) enum HandlerScope {
44    /// Handlers registered during the widget's own `build()` via
45    /// `BuildContext::apply_self_handlers`.
46    Own,
47    /// Handlers attached externally — at insertion time via
48    /// `WidgetBuilder::on_tap` et al., or by a composing parent's
49    /// `BuildContext::apply_handlers(child_id, ...)`.
50    External,
51}
52
53/// Dirty flags for a widget.
54#[derive(Debug, Clone, Copy, Default)]
55pub struct DirtyFlags {
56    pub needs_layout: bool,
57    pub needs_paint: bool,
58    /// When true, the widget's `build()` should be re-run to regenerate children.
59    /// Set by `BindingLevel::Rebuild` bindings (data-driven widgets).
60    pub needs_rebuild: bool,
61}
62
63/// One node's resolved hit-test geometry: which point tests its own bounds,
64/// which point its children receive, its bounds, and how much its own transform
65/// scales a local distance.
66///
67/// Shared by the exact pass, the outset pre-pass and the slop candidate walk so
68/// the three cannot disagree about where a transformed node actually is.
69struct HitSpace {
70    bounds_point: teksilo_canvas::Point,
71    child_point: teksilo_canvas::Point,
72    bounds: teksilo_canvas::Rect,
73    /// The minimum singular value of this node's own transform (`1.0` when it
74    /// has none) — the factor that turns a distance in its local space into one
75    /// in its parent's.
76    scale: f32,
77}
78
79/// `0.0` for a non-finite or negative inset, so a widget that computes an
80/// outset from a `NaN` measurement cannot inflate a rectangle into nonsense.
81fn finite(v: f32) -> f32 {
82    if v.is_finite() && v > 0.0 { v } else { 0.0 }
83}
84
85/// A node in the widget arena storing a widget and its metadata.
86pub struct WidgetNode {
87    pub widget: Box<dyn Widget>,
88    pub parent: Option<WidgetId>,
89    pub children: Vec<WidgetId>,
90    pub activation: ActivationState,
91    /// Whether this node is dormant **on its own account** — parked by a
92    /// direct [`WidgetArena::set_dormant`] rather than swept along by an
93    /// ancestor going dormant.
94    ///
95    /// This is the ungated twin of `visible_state`, and [`WidgetArena::activate`]
96    /// honours the two identically: a self-parked child is left asleep when an
97    /// ancestor wakes, because the ancestor's dormancy was never why it was
98    /// asleep. Cleared the moment a caller activates this node *by id*, which is
99    /// exactly how pre-registered overlay content is shown.
100    ///
101    /// Without it, every widget that pre-builds hidden content as a child with
102    /// `ctx.add(..)` + `ctx.set_dormant(..)` — `SplitButton`'s dropdown,
103    /// `MenuBar`'s menus, `Popover`, `Snackbar`, the date editors' calendars —
104    /// spilled that content onto the screen as soon as any ancestor completed a
105    /// dormancy cycle, laid out inline with no overlay behind it.
106    pub(crate) self_dormant: bool,
107    pub dirty: DirtyFlags,
108    pub bounds: teksilo_canvas::Rect,
109    pub(crate) theme_override: Option<ThemeOverride>,
110    pub(crate) visible_state: Option<Prop<bool>>,
111    pub(crate) enabled_state: Option<Prop<bool>>,
112    /// Reactive Tab-key participation. When bound and evaluates to
113    /// `false`, the widget is excluded from Tab / Shift+Tab traversal
114    /// (`cycle_focus`) — but remains reachable via `request_focus`
115    /// and arrow-key navigation that calls `request_focus`. This
116    /// implements the ARIA roving-tabindex pattern (HTML
117    /// `tabindex="-1"` semantics). `None` means "always a Tab stop
118    /// when focusable" — the default. The selected `TabHeader` is the
119    /// canonical user.
120    pub(crate) tab_stop: Option<Prop<bool>>,
121    /// A Tab gate over this node's whole subtree: while it reads `false`,
122    /// Tab traversal collects nothing from the subtree, whatever Tab-stop
123    /// props the nodes in it carry. Unlike `tab_stop`, which a nearer prop
124    /// overrides, this dominates. Nothing else is affected: the subtree stays
125    /// active, focusable by a request, and present to assistive technology.
126    /// `None` means no gate.
127    pub(crate) tab_gate: Option<Prop<bool>>,
128    /// What a data view's `Space` should do when the row containing this node
129    /// holds the keyboard cursor.
130    ///
131    /// A `ListView` / `TreeView` row is deliberately not focusable — the
132    /// container is — and the view takes the row subtree out of the Tab order,
133    /// because a listbox is one Tab stop and a per-row stop would make the Tab
134    /// order track the virtualization window. That leaves a checkbox inside a
135    /// row with no keyboard route, so the row publishes one here and the view
136    /// calls it. Carrying the *action* rather than the target's id keeps the
137    /// views from having to know what kind of control it is.
138    ///
139    /// `StandardListItem` / `StandardTreeItem` set it on the checkbox they
140    /// embed, so the common path needs no wiring; a hand-written delegate
141    /// calls `BuildContext::set_keyboard_toggle`.
142    #[allow(clippy::type_complexity)]
143    pub(crate) keyboard_toggle: Option<std::rc::Rc<dyn Fn(&mut crate::widget::EventContext)>>,
144    /// User-bound signal that the framework sets to `true` whenever
145    /// the focused widget is a strict descendant of this node, and
146    /// `false` otherwise. Used by `Panel` / `Card` / composite
147    /// widgets that want a unified focus halo without per-child
148    /// `on_focus` plumbing. See `WidgetBuilder::focus_within`.
149    pub(crate) focus_within_signal: Option<Signal<bool>>,
150    /// Framework-managed signal, lazily attached to a focusable node, set to
151    /// `true` whenever the focus is this node **or** a descendant (i.e. the node
152    /// is an *inclusive* ancestor of the focused widget). Unlike
153    /// `focus_within_signal` (strict descendants), this includes the node being
154    /// focused itself — so a data view that holds focus directly reads `true`.
155    /// Powers focus-aware selection (`BuildContext::view_focus_active`).
156    pub(crate) view_focus_signal: Option<Signal<bool>>,
157    /// User-bound signal that the framework sets to `true` whenever
158    /// the hovered widget is a strict descendant of this node.
159    /// Symmetric to `focus_within_signal`. See
160    /// `WidgetBuilder::hover_within`.
161    pub(crate) hover_within_signal: Option<Signal<bool>>,
162    /// User-bound signal that the framework sets to `true` while this
163    /// node is `ActivationState::Active` and `false` while it is
164    /// `Dormant`. Opted into via `BuildContext::activation_signal`.
165    /// Unlike every other widget — which is hidden automatically when
166    /// the paint pass skips a dormant subtree — a widget that owns a
167    /// resource living *outside* the wgpu pass (a native OS subview: a
168    /// `WebView` engine surface) has no other way to learn it was parked
169    /// dormant by a `Switcher` / `visible_when` gate, so it cannot hide
170    /// that resource. This signal is that notification. Set only on an
171    /// actual Active↔Dormant transition. See `set_dormant` / `activate`.
172    pub(crate) activation_signal: Option<Signal<bool>>,
173    /// Framework-written press visual: `true` while this node holds a pointer
174    /// press that has not slid off, been claimed by a peer, or been cancelled.
175    /// Opted into via `BuildContext::pressed_signal`, and written by the
176    /// router — see [`crate::press`] for why the widget cannot maintain this
177    /// from its own handlers.
178    pub(crate) pressed_signal: Option<Signal<bool>>,
179    /// Framework-written mirror of [`WidgetArena::is_enabled`] for this node —
180    /// the AND of its own `enabled_state` and every ancestor's. Opted into via
181    /// `BuildContext::effective_enabled_signal`.
182    ///
183    /// This has to be a *node-resident* signal that the framework refreshes,
184    /// rather than a signal derived by walking ancestors at call time, because
185    /// a widget's `parent` is still `None` while its own `build()` runs — the
186    /// parent link is wired only after `build()` returns (see
187    /// `WidgetTree::insert_widget`). A signal derived during `build()` would
188    /// therefore capture an empty ancestor chain and report only the widget's
189    /// own `enabled` prop, forever. Refreshed in
190    /// `WidgetTree::flush_effective_enabled_signals`.
191    pub(crate) effective_enabled_signal: Option<Signal<bool>>,
192    pub(crate) alignment_override: Option<teksilo_tokens::Alignment>,
193    /// When true, the paint pass clips child rendering to this widget's bounds.
194    /// Set by scroll areas and overflow-hidden containers.
195    pub clips_children: bool,
196    /// Optional OS input-method (IME) descriptor. `Some(..)` declares this
197    /// node a text-input surface — the platform enables the OS IME (with the
198    /// descriptor's purpose) while the node is focused. `None` (the default)
199    /// means no OS IME: enabling IME changes how text arrives, so the safe
200    /// common-case default is off. The platform reads the focused node's
201    /// descriptor at focus-change time. See [`crate::ime`].
202    pub ime: Option<crate::ime::ImeContext>,
203    /// When true, hit-testing skips this node — pointer events fall
204    /// through to whatever sits behind it. Descendants are still
205    /// hit-tested normally (the recursion walks into children before
206    /// the pass-through check), so an interactive subtree under a
207    /// pass-through wrapper stays usable. Used by the debug inspector's
208    /// `HighlightLayer` and `HoverProbe` to paint over the user's
209    /// content without absorbing clicks. Default `false`.
210    pub event_pass_through: bool,
211    /// When `true`, a pointer press anywhere in this widget's subtree must
212    /// NOT arm a drag/swipe recognizer on any ancestor **above** this node —
213    /// the subtree is a *gesture dead zone* for ancestor gestures. Used so
214    /// interactive controls (buttons, a `⋮` menu) placed inside a draggable /
215    /// swipeable container (a dock-panel header, a card, a list row) can be
216    /// clicked without a few px of pointer jitter starting the ancestor's drag.
217    /// The boundary is honored by `PointerSequence` member enrolment. Mirrors Electron's
218    /// `-webkit-app-region: no-drag`. Default `false`. See the `DeadZone`
219    /// wrapper widget.
220    pub gesture_dead_zone: bool,
221    /// When `true`, this node is part of the window's own chrome: a title
222    /// bar's drag surface, a window button, a resize edge. A modal in the
223    /// window dims it but leaves it live, as the window system's frame stays
224    /// live above any dialog in the window; the app's own content stays
225    /// blocked, including what it puts in a title bar. See
226    /// `WidgetTree::window_chrome_under_modal`. Default `false`.
227    pub(crate) window_chrome: bool,
228    /// When `true`, this node and its subtree are scroll chrome: a scroll
229    /// bar, a tab strip's scroll arrows. A click on them moves a view and runs
230    /// no command, so it has the focused field commit nothing. Default
231    /// `false`. Set with `HandlerSet::scroll_chrome`.
232    pub(crate) scroll_chrome: bool,
233    /// When `false`, a click or a tap that lands on this node or anywhere in
234    /// its subtree moves no focus: not to this node, not to a focusable node
235    /// inside it, not to a focusable ancestor. Keyboard focus, programmatic
236    /// focus and an assistive technology's focus still reach it. Default
237    /// `true`. Set with `.focus_on_click(..)`.
238    pub focus_on_click: bool,
239    /// What a **hold** on this node's subtree means when the widget itself does
240    /// not say — the selector for the tree-owned long-press route. Default
241    /// `LongPressRole::Auto`. Set via `.long_press_role(..)`. A node's own
242    /// `on_long_press` always takes precedence over this, and a mouse never
243    /// consults it. See [`crate::widget_tree::touch_route`].
244    pub long_press_role: crate::widget_tree::touch_route::LongPressRole,
245    /// What a direct pointer (touch, pen) is permitted to do to this node's
246    /// subtree. Intersected with every ancestor's on the way down by
247    /// `WidgetTree::effective_touch_action` — an ancestor can only narrow
248    /// what a descendant permits, never widen it. Default
249    /// [`TouchAction::AUTO`] (everything permitted). Set via
250    /// `.touch_action(..)`. A mouse never consults this field. Read at press
251    /// time, to gate pan claimants and the two-contact pinch — see
252    /// [`crate::pointer::touch_action`].
253    pub touch_action: TouchAction,
254    /// This node's declaration that it is a **pan surface** — it wants to
255    /// consume a direct pointer's drag as content panning. `None` (the
256    /// default) means the node makes no such claim. `WidgetTree::
257    /// pan_candidates` collects every claim from a target up to the root.
258    /// Set via `.pan_claim(..)` or the `.scroll_container(..)` sugar. Read at
259    /// press time to build the chain a synthesised pan walks — see
260    /// [`crate::pointer::touch_action`].
261    pub pan_claim: Option<PanClaim>,
262    /// Whether this node absorbs a scroll it cannot use, or lets it chain to
263    /// the next scrollable outward — the CSS `overscroll-behavior` model.
264    ///
265    /// Read by `WidgetTree::deliver_pan` when it walks the claimant chain: an
266    /// [`OverscrollBehavior::Contain`](crate::OverscrollBehavior::Contain)
267    /// claimant **stops** the chain even when it absorbed nothing, so a
268    /// self-contained panel never lets a boundary pan escape into the page
269    /// behind it. Default
270    /// [`Chain`](crate::OverscrollBehavior::Chain). Set via
271    /// `.overscroll_behavior(..)`.
272    ///
273    /// Declared on the node rather than left inside each scrollable's own
274    /// `on_scroll` closure because the *chain* has to read it, and the chain
275    /// runs in the router, above every handler.
276    pub overscroll_behavior: crate::OverscrollBehavior,
277    /// When a drag on this node may begin relative to the press that starts
278    /// it. [`DragActivation::Auto`](teksilo_tokens::DragActivation::Auto) — the
279    /// default — resolves to `Immediate`
280    /// for a precise pointer (today's behaviour, unchanged) and to
281    /// `AfterLongPress` for a coarse pointer whose axis is already claimed by
282    /// a pan surface. Set via `.drag_activation(..)`, read by the arbitration
283    /// when the node is enrolled as a sequence member.
284    pub drag_activation: teksilo_tokens::DragActivation,
285    /// How many simultaneous contacts this node's gesture recognizers serve.
286    /// Default [`MultiContact::First`] — one press at a time, which is what
287    /// every widget written before the touch programme assumes. Under it a
288    /// *second* contact arriving while the first is live is terminated at this
289    /// node: not delivered to it, and not bubbled to an ancestor either, so two
290    /// fingers on a button inside a scroll area cannot start a pan with the
291    /// second finger. Set via `.multi_contact(..)`.
292    pub multi_contact: MultiContact,
293    /// The tap streak of a gesture arena a rebuild dropped, kept until the
294    /// next press seeds it into the arena that press installs.
295    ///
296    /// The arena lives in the own handler bucket, which a rebuild resets, so
297    /// without this a double click whose first click makes the node rebuild
298    /// (a data-view row the first click selects) would count its second
299    /// click as a first one. Only the streak is carried: the recognizers are
300    /// read again from the new build's handlers. A stale streak invalidates
301    /// itself, since `TapStreak::advance` restarts on a long gap, a long
302    /// travel or another button.
303    pub(crate) carried_tap_streak: Option<crate::gesture::TapStreak>,
304    /// When `true` and this widget holds keyboard focus, a `KeyDown` is
305    /// delivered straight to it **without** first running shortcut →
306    /// intent → action resolution. The node is a *keyboard capture*
307    /// surface: it wants every keystroke (including chords the host app
308    /// binds as `Shortcut`s — `Ctrl+C`, `Ctrl+W`, `Alt+<letter>`, …).
309    /// Used by a terminal emulator (which must forward `Ctrl+C` to the
310    /// child process, not trigger the app's copy shortcut), a game
311    /// viewport, or a vim-mode editor. Honored by `dispatch_event_impl`,
312    /// which skips the shortcut block for a focused capture node.
313    ///
314    /// **`Ctrl+Tab` / `Ctrl+Shift+Tab` are reserved**: `dispatch_event_impl`
315    /// cycles focus on that chord before dispatching to a focused capture
316    /// node, so no capture surface can trap the keyboard (WCAG 2.1.2).
317    /// Escape is not reserved — overlay back-navigation runs ahead of the
318    /// check only while an overlay is open, so a capture surface below no
319    /// overlay does see Escape. Default `false`.
320    pub keyboard_capture: bool,
321    /// The node receives the release of every key it had the press of, even
322    /// once a modal has opened over it. See
323    /// [`BuildContext::receive_every_key_release`](crate::build_context::BuildContext::receive_every_key_release).
324    pub(crate) receives_every_key_release: bool,
325    /// The node judges a `ScrollIntoView` against its live viewport, so the
326    /// reveal walk always asks it. See
327    /// [`BuildContext::judge_reveals_live`](crate::build_context::BuildContext::judge_reveals_live).
328    pub(crate) judges_reveals_live: bool,
329    /// When `Some`, this node is a focus region (an F6 stop) with this name.
330    /// See [`HandlerSet::focus_region`](crate::widget_builder::HandlerSet::focus_region).
331    pub focus_region: Option<crate::signal::Prop<String>>,
332    /// The last focus that was one of this region's own focusables, where F6
333    /// lands next. Written by `WidgetTree::set_focused`.
334    pub(crate) focus_region_last_focus: Option<WidgetId>,
335    /// When `true`, this widget AND its entire subtree are invisible to
336    /// hit-testing: the recursion returns immediately without descending
337    /// into children, so the point falls through to whatever sits
338    /// behind. Unlike [`event_pass_through`](Self::event_pass_through)
339    /// (which is per-node — descendants stay hittable), this excludes
340    /// the whole subtree. Use for purely decorative overlays whose
341    /// children are themselves widgets — a count badge over a button, a
342    /// watermark, a status dot — so they never steal clicks meant for
343    /// the control underneath. Default `false`.
344    pub hit_transparent: bool,
345    /// Per-node override of the *miss-only* slop this node may earn, set via
346    /// `.hit_slop(..)`. Second link of the precedence chain — it beats the
347    /// widget's own `Widget::hit_slop` and the density default, and loses only
348    /// to [`no_hit_slop`](Self::no_hit_slop). `None` (the default) defers to
349    /// the widget, then to the density.
350    pub hit_slop: Option<crate::pointer::hit_slop::HitSlop>,
351    /// When `true`, this node is excluded from **both** hit-widening
352    /// mechanisms: it earns no slop outset in the miss-only pass, and its
353    /// `Widget::hit_outset` is ignored inside the exact pass. The head of the
354    /// precedence chain, set via `.no_hit_slop()`.
355    ///
356    /// Per-node, not per-subtree: a descendant may still widen. Excluding a
357    /// whole subtree from hit-testing is
358    /// [`hit_transparent`](Self::hit_transparent)'s job, and excluding a region
359    /// that hosts foreign content (a `WebView` surface) is exactly this flag on
360    /// that one node. Default `false`.
361    pub no_hit_slop: bool,
362    /// Optional opacity multiplier (0..1) applied to this widget's
363    /// entire subtree during paint. The render walker emits
364    /// `SetOpacity(value)` before walking the widget's own paint and
365    /// children, then `RestoreOpacity` afterwards — so the multiplier
366    /// composes with ancestor opacity scopes via the canvas's
367    /// already-stacked opacity model. Bound at `Repaint` level: opacity
368    /// changes never trigger relayout. `None` means "no opacity scope"
369    /// (the default for almost every widget). The `Fade` widget sets
370    /// this on its own node to drive an animated visibility tween.
371    pub(crate) opacity_prop: Option<Prop<f32>>,
372    /// Optional 2D affine transform applied to this widget's entire
373    /// subtree during paint. The render walker emits
374    /// `PushTransform(value)` before walking the widget's own paint
375    /// and children, then `PopTransform` afterwards — the renderer
376    /// composes it onto its transform stack so nested wrappers and
377    /// widget-internal canvas transforms compose correctly. Bound at
378    /// `Repaint` level by default (visual-only); a wrapper that wants
379    /// the transform to drive layout (e.g. `Scale::reflow(true)`)
380    /// must additionally bind its driver signal at `Relayout`.
381    /// `None` means "no transform scope" (the default for almost every
382    /// widget). The `Scale` and `Rotate` widgets set this on their own
383    /// node.
384    pub(crate) transform_prop: Option<Prop<teksilo_canvas::Transform2D>>,
385    /// Whether [`transform_prop`](Self::transform_prop) transforms this node's
386    /// **content** within a fixed parent-space viewport (`true`), versus
387    /// transforming the **node itself** (`false`, the default).
388    ///
389    /// `Scale` / `Rotate` are *self* transforms: the node's own bounds move
390    /// with the transform, so hit-testing inverse-applies the transform before
391    /// the bounds test (a click lands where the scaled/rotated visual is).
392    ///
393    /// `SceneView` is a *content* transform: its bounds are a fixed screen
394    /// viewport and the pan/zoom only moves its content, so hit-testing must
395    /// test the bounds in parent space (keeping the whole visible viewport
396    /// interactive at any pan) and apply the transform only when descending
397    /// into children. Set via `BuildContext::set_content_transform`.
398    pub(crate) content_transform: bool,
399    /// Optional Gaussian-equivalent blur radius applied to this widget's
400    /// entire subtree during paint. The render walker emits
401    /// `BeginBlurredSubtree { bounds, radius }` before walking the
402    /// widget's own paint and children, then `EndBlurredSubtree`
403    /// afterwards — the renderer redirects drawing into an intermediate
404    /// texture, runs a dual-Kawase blur chain at the requested radius,
405    /// and composites the blurred result back into the parent pass.
406    /// Bound at `Repaint` level: blur radius changes never trigger
407    /// relayout. `None` (or `Some(radius < 0.5)`) means "no blur scope"
408    /// — the walker skips the Begin/End pair entirely so disabled blur
409    /// has zero per-frame cost. The `Blur` widget sets this on its own
410    /// node.
411    pub(crate) blur_prop: Option<Prop<f32>>,
412    /// Cached paint output for this widget (excludes children).
413    /// Reused when `needs_paint` is false to avoid re-running `paint()`.
414    pub(crate) cached_paint: Option<RenderFrame>,
415    /// Cached foreground output for widgets that override
416    /// [`Widget::post_paint`] — the
417    /// draws emitted *after* this widget's children. Separate frame from
418    /// `cached_paint` because it lands at a different position in
419    /// `draw_order` (after the child subtree). Reused on the same
420    /// `needs_paint` gate.
421    pub(crate) cached_post_paint: Option<RenderFrame>,
422    /// The ambient raster scale `cached_paint` / `cached_post_paint`
423    /// were baked at (the paint walker's accumulated transform scale,
424    /// quantized). Glyph quads in those frames reference bitmaps of
425    /// that density; when the walker's current scale differs (a scene
426    /// zoom crossed a quantization bucket), the cached frames are
427    /// treated as `needs_paint` even though the widget itself is clean.
428    pub(crate) paint_raster_scale: f32,
429    /// The `WidgetTree::paint_epoch` at which this widget's bounds were
430    /// last observed inside the window viewport by the paint pass.
431    /// The animation scheduler uses this to pause looping animations
432    /// for offscreen widgets: an animation whose
433    /// `last_painted_epoch + 1 < tree.paint_epoch` is considered
434    /// off-screen and skipped. `0` means "not yet painted"; visibility is
435    /// waived wholesale while `tree.paint_epoch` is itself `0`, which keeps
436    /// headless tests (no `render()` call) from regressing.
437    pub last_painted_epoch: u64,
438    /// The `WidgetTree::paint_epoch` of the last full render that ran this
439    /// widget's `paint()`. Unlike `last_painted_epoch`, which is stamped as
440    /// soon as the walker reaches the node, this one moves only when the
441    /// paint actually ran, so the end-of-render clear can tell a dirty node
442    /// that repainted from one the walker skipped (clipped out, or under a
443    /// sub-perceptual opacity): the skipped one must drop its stale caches.
444    pub(crate) paint_consumed_epoch: u64,
445    /// The off-thread wake state of the `RepaintTrigger`s attached to this
446    /// widget, if any: the paint walker takes its repaint flag right before
447    /// `paint()`, and activation takes its relayout flag.
448    pub(crate) repaint_wake: Option<std::sync::Arc<crate::off_thread::NodeWakeState>>,
449
450    // --- V2 fields ---
451    /// Event handlers the widget attached to itself during its own
452    /// `build()` via `BuildContext::apply_self_handlers`. Cleared on
453    /// rebuild so accumulating `apply_self_handlers` calls across
454    /// rebuilds don't stack N-fold handler chains.
455    pub(crate) handlers: EventHandlers,
456    /// Event handlers attached *externally* — either via the
457    /// `WidgetBuilder` chain at the widget's creation site
458    /// (`SomeWidget::new().on_tap(...)`) or by a parent's
459    /// `BuildContext::apply_handlers(child_id, ...)`. These survive
460    /// rebuilds: the widget didn't register them and shouldn't decide
461    /// when they go away.
462    pub(crate) external_handlers: EventHandlers,
463    /// Focusable override set via HandlerSet. The only source of a node's
464    /// focusability — `WidgetTree::is_node_focusable` reads this field and
465    /// nothing else, so `None` means not focusable.
466    pub(crate) node_focusable: Option<bool>,
467    /// Tab index override set via HandlerSet.
468    pub(crate) node_tab_index: Option<i32>,
469    /// Traversal-scope marker. When `Some(policy)`, `cycle_focus` treats this
470    /// node's subtree as an independent Tab group: `tab_index` numbering is
471    /// scoped to its descendants (so sibling scopes never interleave) and
472    /// `policy` governs what Tab does at the scope's ends. `None` (default)
473    /// means the node is transparent to traversal scoping. Set by the
474    /// `FocusScope` wrapper via `BuildContext::set_traversal_scope`. A node
475    /// carrying this marker is forced non-focusable (it is a boundary, never a
476    /// Tab stop). See [`crate::focus::TraversalScopePolicy`].
477    pub(crate) node_traversal_scope: Option<crate::focus::TraversalScopePolicy>,
478    /// Cursor override set via HandlerSet.
479    pub(crate) node_cursor: Option<CursorIcon>,
480    /// RAII observer handles for effects registered during build().
481    /// Dropped on rebuild or widget destruction.
482    pub(crate) effect_handles: Vec<ObserverHandle>,
483    /// Backend-event subscriptions registered during build() via
484    /// `BuildContext::subscribe_event`. Each entry pairs a subscription id
485    /// (used to remove the UI-side callback from `TreeAppContext`) with the
486    /// opaque source-side handle whose `Drop` removes the subscriber from
487    /// the source's internal registry.
488    pub(crate) subscription_handles: Vec<(SubscriptionId, SubscriptionHandle)>,
489    /// Parentless nodes this widget created during `build()` and still owns —
490    /// pre-built overlay content (a menu, a calendar, a tooltip's nested
491    /// cascade children) that is deliberately *not* a child.
492    ///
493    /// Such content cannot be a child: activation and the paint walk both
494    /// descend through `children`, so a dormant popup parked there wakes with
495    /// its host and paints inline at zero size. Keeping it parentless fixes
496    /// that and creates the opposite problem — no teardown walk reaches it, so
497    /// every rebuild of the host strands another copy in the arena for the
498    /// lifetime of the process. This list is the missing ownership edge:
499    /// [`WidgetTree::destroy_subtree`](crate::widget_tree::WidgetTree) reaps it
500    /// with the owner, and a rebuild reaps the previous generation. Recorded
501    /// via `BuildContext::add_detached`.
502    pub(crate) detached: Vec<WidgetId>,
503    /// Context menu factory — invoked on right-click to produce overlay content.
504    pub(crate) context_menu_factory: Option<crate::widget_builder::ContextMenuFactory>,
505    /// See [`ContextMenuContribution`](crate::widget_builder::ContextMenuContribution).
506    pub(crate) context_menu_contribution: Option<crate::widget_builder::ContextMenuContribution>,
507    /// Intent-bound actions attached by this widget during `build()`.
508    /// Consulted during intent dispatch (source-widget → root walk).
509    /// Cleared on rebuild in the same pass that clears handlers.
510    pub(crate) actions: Vec<crate::action::Action>,
511    /// Builder-level accessibility overrides (`access_label`,
512    /// `access_role`, etc.). Mirrored from the wrapper's `HandlerSet`
513    /// at insertion via `apply_handler_set`. Applied by the
514    /// accessibility tree walker after the inner widget's
515    /// `accessibility(&self, builder)` runs. Action callbacks
516    /// (`actions`, `custom_actions` inside this struct) are dispatched
517    /// by `pointer_router.rs` when handling
518    /// `WidgetEvent::AccessAction`.
519    pub(crate) access_overrides: Option<Box<crate::widget_builder::AccessibilityOverrides>>,
520    /// Subtree visibility / merge mode (`access_exclude_subtree` /
521    /// `access_merge_subtree`). Mirrored from the wrapper's
522    /// `HandlerSet`.
523    pub(crate) access_subtree: crate::widget_builder::AccessSubtreeMode,
524}
525
526impl std::fmt::Debug for WidgetNode {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        f.debug_struct("WidgetNode")
529            .field("widget", &self.widget)
530            .field("parent", &self.parent)
531            .field("children", &self.children)
532            .field("activation", &self.activation)
533            .field("dirty", &self.dirty)
534            .field("bounds", &self.bounds)
535            .field("has_gesture_arena", &self.handlers.gesture_arena.is_some())
536            .field("has_theme_override", &self.theme_override.is_some())
537            .field("has_visible_state", &self.visible_state.is_some())
538            .field("has_enabled_state", &self.enabled_state.is_some())
539            .finish()
540    }
541}
542
543impl WidgetNode {
544    /// Construct a fresh node wrapping `widget`, parented at `parent`
545    /// (`None` for a root). All other fields take their insertion defaults;
546    /// the caller wires up `children` / parent back-links afterward.
547    pub(crate) fn new(widget: Box<dyn Widget>, parent: Option<WidgetId>) -> Self {
548        WidgetNode {
549            widget,
550            parent,
551            children: Vec::new(),
552            activation: ActivationState::Active,
553            self_dormant: false,
554            dirty: DirtyFlags {
555                needs_layout: true,
556                needs_paint: true,
557                needs_rebuild: false,
558            },
559            bounds: teksilo_canvas::Rect::ZERO,
560            theme_override: None,
561            visible_state: None,
562            enabled_state: None,
563            tab_stop: None,
564            tab_gate: None,
565            keyboard_toggle: None,
566            focus_within_signal: None,
567            view_focus_signal: None,
568            hover_within_signal: None,
569            activation_signal: None,
570            pressed_signal: None,
571            effective_enabled_signal: None,
572            alignment_override: None,
573            clips_children: false,
574            ime: None,
575            event_pass_through: false,
576            gesture_dead_zone: false,
577            window_chrome: false,
578            scroll_chrome: false,
579            focus_on_click: true,
580            long_press_role: crate::widget_tree::touch_route::LongPressRole::Auto,
581            touch_action: TouchAction::AUTO,
582            pan_claim: None,
583            overscroll_behavior: crate::OverscrollBehavior::Chain,
584            drag_activation: teksilo_tokens::DragActivation::Auto,
585            multi_contact: MultiContact::First,
586            carried_tap_streak: None,
587            keyboard_capture: false,
588            receives_every_key_release: false,
589            judges_reveals_live: false,
590            focus_region: None,
591            focus_region_last_focus: None,
592            hit_transparent: false,
593            hit_slop: None,
594            no_hit_slop: false,
595            opacity_prop: None,
596            transform_prop: None,
597            content_transform: false,
598            blur_prop: None,
599            cached_paint: None,
600            cached_post_paint: None,
601            paint_raster_scale: 1.0,
602            last_painted_epoch: 0,
603            paint_consumed_epoch: 0,
604            repaint_wake: None,
605            handlers: EventHandlers::new(),
606            external_handlers: EventHandlers::new(),
607            node_focusable: None,
608            node_tab_index: None,
609            node_traversal_scope: None,
610            node_cursor: None,
611            effect_handles: Vec::new(),
612            subscription_handles: Vec::new(),
613            detached: Vec::new(),
614            context_menu_factory: None,
615            context_menu_contribution: None,
616            actions: Vec::new(),
617            access_overrides: None,
618            access_subtree: crate::widget_builder::AccessSubtreeMode::default(),
619        }
620    }
621
622    /// Does EITHER handler slot (own or external) have a handler of the
623    /// requested kind? Use this when deciding whether to build a gesture
624    /// arena, mark the node as a drop target, etc.
625    pub(crate) fn any_handler<F>(&self, f: F) -> bool
626    where
627        F: Fn(&EventHandlers) -> bool,
628    {
629        f(&self.handlers) || f(&self.external_handlers)
630    }
631}
632
633/// One widget's handler space: how a window-logical point maps into the
634/// space its handlers receive positions in, and back out.
635///
636/// Built by [`WidgetArena::local_frame`]. Kept as the transform, its inverse
637/// and the origin rather than folded into one matrix, so that
638/// [`to_local`](Self::to_local) performs exactly the arithmetic the dispatcher
639/// always has — a composed matrix rounds differently — and
640/// [`to_window`](Self::to_window) undoes it step for step.
641#[derive(Clone, Copy, Debug, PartialEq)]
642pub(crate) struct LocalFrame {
643    /// From the frame's pre-transform space to window space: the node's
644    /// effective transform, or its parent's for a content-transform node.
645    to_window: teksilo_canvas::Transform2D,
646    /// The inverse of `to_window`, `None` when that is singular. A singular
647    /// transform collapses the subtree, so nothing can aim inside it; both
648    /// directions then apply no transform, as localisation always has, and
649    /// stay each other's inverse.
650    from_window: Option<teksilo_canvas::Transform2D>,
651    /// The bounds origin local points are relative to — zero for a
652    /// content-transform node, whose handlers receive its parent's space.
653    origin: teksilo_canvas::Point,
654}
655
656impl LocalFrame {
657    /// The handler-space point for a window-logical one.
658    pub(crate) fn to_local(self, window: teksilo_canvas::Point) -> teksilo_canvas::Point {
659        let p = match &self.from_window {
660            Some(inverse) => inverse.apply_point(window),
661            // Degenerate transform: fall back to the raw point rather than
662            // dropping the event.
663            None => window,
664        };
665        teksilo_canvas::Point::new(p.x - self.origin.x, p.y - self.origin.y)
666    }
667
668    /// The window-logical point whose handler-space point is `local`: the
669    /// inverse of [`to_local`](Self::to_local).
670    pub(crate) fn to_window(self, local: teksilo_canvas::Point) -> teksilo_canvas::Point {
671        let p = teksilo_canvas::Point::new(local.x + self.origin.x, local.y + self.origin.y);
672        match &self.from_window {
673            Some(_) => self.to_window.apply_point(p),
674            None => p,
675        }
676    }
677}
678
679/// Flat arena storage for all widgets, using SlotMap for O(1) access.
680pub struct WidgetArena {
681    nodes: SlotMap<WidgetId, WidgetNode>,
682    /// Number of nodes with theme overrides. When zero, resolve_theme is O(1).
683    pub(crate) theme_override_count: usize,
684    /// Whether any node was ever marked a focus region. Never reset: while
685    /// it is false, the region scans (run for command availability every
686    /// pass) answer at once.
687    pub(crate) focus_regions_seen: bool,
688    /// Cached root widget IDs (widgets with no parent).
689    cached_roots: Vec<WidgetId>,
690    /// Whether the cached_roots list needs rebuilding.
691    roots_dirty: bool,
692    /// Per-pass memoization of `Widget::layout_response`, keyed by
693    /// `(WidgetId, ProposalKey)`. Cleared once at the start of every layout
694    /// pass (see `clear_layout_cache`). Height-for-width negotiation queries
695    /// each child along the main axis and again along the cross axis, so
696    /// without this the cost compounds super-linearly with nesting depth;
697    /// with it, each `(id, proposal)` is computed at most once per pass.
698    /// `RefCell` because layout runs through shared `&WidgetArena` borrows.
699    layout_cache: std::cell::RefCell<
700        std::collections::HashMap<(WidgetId, ProposalKey), crate::widget::LayoutResponse>,
701    >,
702    /// Widgets whose box moved without changing size since the last
703    /// accessibility walk, and by how much.
704    ///
705    /// A move is the one geometry change the accessibility tree can absorb
706    /// without being rebuilt: nothing about a widget's *content* depends
707    /// on where it sits, so its node and every text run under it can be
708    /// re-placed in the cached tree by the same delta. A scroll frame
709    /// moves every descendant of the scroll area, so this is the common
710    /// case and re-walking for it was what made the AT tree go stale
711    /// instead — the walk was too expensive to run per frame, so it was
712    /// not run at all and every node's bounds drifted.
713    a11y_moved: std::collections::HashMap<WidgetId, teksilo_canvas::Point>,
714    /// Set when any widget's box changed *size* since the last
715    /// accessibility walk.
716    ///
717    /// A resize is not absorbable: a wrapped label re-wraps, so its lines —
718    /// and therefore its text runs — are a different set, not the same set
719    /// somewhere else.
720    a11y_resized: bool,
721    /// True while [`measure_intrinsic`](Self::measure_intrinsic) is running.
722    /// In this mode `cached_layout_response` measures even dormant widgets
723    /// (and their dormant subtrees) and bypasses the cache, so an adaptive
724    /// container can size an item it intends to keep hidden without that size
725    /// leaking into the normal per-pass cache.
726    measuring: std::cell::Cell<bool>,
727    /// Active↔Dormant transitions of nodes carrying an `activation_signal`,
728    /// recorded by [`set_dormant`](Self::set_dormant) / [`activate`](Self::activate)
729    /// and drained by `WidgetTree::flush_activation_signals` *after* the
730    /// mutation completes. Signals are fired at the tree level, never from
731    /// inside the arena recursion — mirroring how `focus_within` /
732    /// `hover_within` are updated from `WidgetTree` methods rather than mid
733    /// mutation, so an observer (e.g. a `WebView`'s `set_visible`, which on a
734    /// real backend is an OS call) never runs while the arena is being walked.
735    /// Only nodes with a signal contribute, so the buffer is empty for the
736    /// overwhelming majority of trees.
737    pending_activation_changes: Vec<(WidgetId, bool)>,
738    /// Widgets whose activation took a relayout a `RepaintTrigger` had
739    /// requested while they slept. One woken inside a layout walk by a
740    /// culling parent was measured before it was woken, possibly at the size
741    /// before the request, so the walk's end marks it for layout again.
742    pub(crate) relayout_rewake: Vec<WidgetId>,
743    /// Every node that installed an `effective_enabled_signal`, so the
744    /// per-pass refresh visits only opted-in nodes instead of the whole arena.
745    /// Unlike `pending_activation_changes` this is NOT a change queue: an
746    /// ancestor's `enabled` prop is a `Signal` that can flip at any time
747    /// without the arena being told, so there is no single mutation site to
748    /// record a transition at. The refresh recomputes and diffs instead —
749    /// see `WidgetTree::flush_effective_enabled_signals`. Dead ids are pruned
750    /// there, so a destroyed widget cannot leak.
751    effective_enabled_watchers: Vec<WidgetId>,
752}
753
754/// Hashable key for a [`teksilo_canvas::SizeProposal`] used by the per-pass
755/// layout cache. Each axis is encoded to a `u64`: `None` → a sentinel
756/// distinct from any finite `f32`, `Some(v)` → the canonicalized `f32` bits
757/// (`-0.0` folded to `0.0`, all NaNs folded to one pattern) so two equal
758/// proposals always hash and compare equal.
759#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
760struct ProposalKey([u64; 2]);
761
762impl ProposalKey {
763    fn from_proposal(p: teksilo_canvas::SizeProposal) -> Self {
764        fn axis_bits(v: Option<f32>) -> u64 {
765            match v {
766                // `f32::to_bits()` widens into 0..=u32::MAX, so u64::MAX is a
767                // safe sentinel that no `Some(_)` can collide with.
768                None => u64::MAX,
769                Some(f) => {
770                    let canon = if f == 0.0 {
771                        0.0
772                    } else if f.is_nan() {
773                        f32::NAN
774                    } else {
775                        f
776                    };
777                    canon.to_bits() as u64
778                }
779            }
780        }
781        Self([axis_bits(p.width), axis_bits(p.height)])
782    }
783}
784
785impl WidgetArena {
786    pub fn new() -> Self {
787        Self {
788            nodes: SlotMap::with_key(),
789            theme_override_count: 0,
790            focus_regions_seen: false,
791            cached_roots: Vec::new(),
792            roots_dirty: true,
793            layout_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
794            measuring: std::cell::Cell::new(false),
795            a11y_moved: std::collections::HashMap::new(),
796            a11y_resized: false,
797            pending_activation_changes: Vec::new(),
798            relayout_rewake: Vec::new(),
799            effective_enabled_watchers: Vec::new(),
800        }
801    }
802
803    /// Record what a bounds change means for the accessibility tree.
804    ///
805    /// Called by the layout pass at each of its two bounds writers, after
806    /// the node has been updated. Same size = a move the cached tree can
807    /// absorb; any size change = a rebuild.
808    pub(crate) fn note_bounds_change(
809        &mut self,
810        id: WidgetId,
811        previous: teksilo_canvas::Rect,
812        current: teksilo_canvas::Rect,
813    ) {
814        if previous.width != current.width || previous.height != current.height {
815            self.a11y_resized = true;
816            self.a11y_moved.remove(&id);
817            return;
818        }
819        let delta = teksilo_canvas::Point::new(current.x - previous.x, current.y - previous.y);
820        // A widget can move several times between two walks; the cached
821        // tree only ever sees the total.
822        let entry = self
823            .a11y_moved
824            .entry(id)
825            .or_insert(teksilo_canvas::Point::new(0.0, 0.0));
826        entry.x += delta.x;
827        entry.y += delta.y;
828    }
829
830    /// Whether any widget changed size since the last accessibility walk,
831    /// clearing the flag.
832    pub(crate) fn take_a11y_resized(&mut self) -> bool {
833        std::mem::take(&mut self.a11y_resized)
834    }
835
836    /// The widgets that moved since the last accessibility walk, clearing
837    /// the record.
838    pub(crate) fn take_a11y_moved(
839        &mut self,
840    ) -> std::collections::HashMap<WidgetId, teksilo_canvas::Point> {
841        std::mem::take(&mut self.a11y_moved)
842    }
843
844    /// Clear the per-pass layout memoization cache. Called once at the start of
845    /// each layout pass — geometry (and therefore `layout_response` results)
846    /// may change between passes, so the cache is valid only within one pass.
847    pub(crate) fn clear_layout_cache(&self) {
848        self.layout_cache.borrow_mut().clear();
849    }
850
851    /// Compute a widget's layout response, memoized per `(id, proposal)` for
852    /// the current layout pass. Returns `None` if the id is missing or
853    /// dormant. Widgets that opt out via `Widget::cacheable_layout() == false`
854    /// (e.g. the inspector's bounds tracker, which deliberately mutates signals
855    /// in `layout_response`) bypass the cache so their side effect fires on
856    /// every call.
857    ///
858    /// The key is `(id, proposal)` only: `layout_response` also reads the
859    /// `LayoutContext` (resolved theme, layout direction, text backend), but
860    /// those are a stable function of `id` within a single pass, so the pair
861    /// uniquely determines the input.
862    pub(crate) fn cached_layout_response(
863        &self,
864        id: WidgetId,
865        proposal: teksilo_canvas::SizeProposal,
866        ctx: &crate::widget::LayoutContext,
867    ) -> Option<crate::widget::LayoutResponse> {
868        let node = self.nodes.get(id)?;
869        let measuring = self.measuring.get();
870        if node.activation != ActivationState::Active && !measuring {
871            return None;
872        }
873        // While measuring intrinsic sizes (incl. of dormant subtrees), bypass
874        // the cache entirely so a dormant widget's size never pollutes the
875        // normal per-pass cache.
876        if measuring || !node.widget.cacheable_layout() {
877            return Some(node.widget.layout_response(proposal, ctx));
878        }
879        let key = (id, ProposalKey::from_proposal(proposal));
880        // Scope the shared borrow so it is released before `layout_response`
881        // runs — that call recurses into children, which borrow the same
882        // `layout_cache` (read, then write) and would otherwise alias.
883        {
884            if let Some(cached) = self.layout_cache.borrow().get(&key) {
885                return Some(*cached);
886            }
887        }
888        let resp = node.widget.layout_response(proposal, ctx);
889        self.layout_cache.borrow_mut().insert(key, resp);
890        Some(resp)
891    }
892
893    /// Measure a widget's intrinsic `layout_response` size for `proposal`,
894    /// **regardless of activation** — including dormant/collapsed widgets and
895    /// their dormant subtrees. Returns `None` only if the id is absent.
896    ///
897    /// Adaptive containers (e.g. an overflow [`Toolbar`](crate) that collapses
898    /// items into a menu) use this to size an item they intend to keep hidden,
899    /// so they can decide when to show it again as space grows — something
900    /// `child_layout_response` cannot do, since it returns `None` for inactive
901    /// widgets.
902    ///
903    /// Runs uncached (a dormant widget's size never enters the per-pass cache)
904    /// and is re-entrant-safe (saves/restores the measuring flag). Calls
905    /// `layout_response`, which must be idempotent (see
906    /// [`Widget::cacheable_layout`]).
907    pub(crate) fn measure_intrinsic(
908        &self,
909        id: WidgetId,
910        proposal: teksilo_canvas::SizeProposal,
911        ctx: &crate::widget::LayoutContext,
912    ) -> Option<teksilo_canvas::Size> {
913        if !self.nodes.contains_key(id) {
914            return None;
915        }
916        let prev = self.measuring.replace(true);
917        // `cached_layout_response` (and every nested child query during this
918        // call) sees `measuring == true`, so it bypasses the active check and
919        // the cache for the whole subtree.
920        let resp = self.cached_layout_response(id, proposal, ctx);
921        self.measuring.set(prev);
922        resp.map(|r| r.size)
923    }
924
925    /// Insert a widget into the arena as a root-level widget.
926    pub fn insert(&mut self, widget: Box<dyn Widget>) -> WidgetId {
927        self.roots_dirty = true;
928        let children = widget.children();
929        let id = self.nodes.insert(WidgetNode::new(widget, None));
930        // Set up parent-child for declared children
931        for &child_id in &children {
932            if let Some(child_node) = self.nodes.get_mut(child_id) {
933                child_node.parent = Some(id);
934            }
935        }
936        if let Some(node) = self.nodes.get_mut(id) {
937            node.children = children;
938        }
939        id
940    }
941
942    /// Insert a widget as a child of the given parent.
943    pub fn insert_child(&mut self, parent: WidgetId, widget: Box<dyn Widget>) -> WidgetId {
944        assert!(
945            self.nodes.contains_key(parent),
946            "insert_child() called with invalid parent WidgetId {parent:?}"
947        );
948        self.roots_dirty = true;
949        let children = widget.children();
950        let id = self.nodes.insert(WidgetNode::new(widget, Some(parent)));
951        // Set up parent-child for declared children
952        for &child_id in &children {
953            if let Some(child_node) = self.nodes.get_mut(child_id) {
954                child_node.parent = Some(id);
955            }
956        }
957        if let Some(node) = self.nodes.get_mut(id) {
958            node.children = children;
959        }
960        if let Some(parent_node) = self.nodes.get_mut(parent) {
961            parent_node.children.push(id);
962        }
963        id
964    }
965
966    pub fn get(&self, id: WidgetId) -> Option<&WidgetNode> {
967        self.nodes.get(id)
968    }
969
970    pub fn get_mut(&mut self, id: WidgetId) -> Option<&mut WidgetNode> {
971        self.nodes.get_mut(id)
972    }
973
974    pub fn children(&self, id: WidgetId) -> &[WidgetId] {
975        self.nodes
976            .get(id)
977            .map(|n| n.children.as_slice())
978            .unwrap_or(&[])
979    }
980
981    pub fn parent(&self, id: WidgetId) -> Option<WidgetId> {
982        self.nodes.get(id).and_then(|n| n.parent)
983    }
984
985    pub fn bounds(&self, id: WidgetId) -> teksilo_canvas::Rect {
986        self.nodes
987            .get(id)
988            .map(|n| n.bounds)
989            .unwrap_or(teksilo_canvas::Rect::ZERO)
990    }
991
992    /// The accumulated 2D affine transform that maps `id`'s pre-transform
993    /// local-space points to screen space — equivalent to the renderer's
994    /// `transform_stack` top by the time it begins painting `id`. Used by
995    /// hit-testing and any consumer that needs to project a node's
996    /// pre-transform bounds into screen space (e.g. teksilo-scene's a11y
997    /// bounds projection of view-transformed scene items).
998    ///
999    /// **Composition order.** Mirrors `crates/teksilo-render/src/renderer.rs`'s
1000    /// `PushTransform` handling: each push composes as
1001    /// `new_top = device_t.then(prev_top)`, so the deepest (innermost)
1002    /// transform is applied **first** to a local point and outer ancestors
1003    /// compose afterward. Walking root→leaf, each ancestor's
1004    /// `transform_prop` is folded in via `t.then(effective)` (NOT
1005    /// `effective.then(t)`).
1006    ///
1007    /// Returns `Transform2D::IDENTITY` if no ancestor sets a non-identity
1008    /// transform, which is the common case (90%+ of widgets).
1009    pub fn effective_transform(&self, id: WidgetId) -> teksilo_canvas::Transform2D {
1010        // Collect leaf→root, then iterate root→leaf. Composition is
1011        // `t_new.then(effective_so_far)` so the outer ancestor is applied
1012        // *after* the deeper push — matching the renderer's stack semantic
1013        // (`device_t.then(prev_top)` at PushTransform).
1014        let mut chain: Vec<WidgetId> = Vec::new();
1015        let mut current = Some(id);
1016        while let Some(c) = current {
1017            chain.push(c);
1018            current = self.parent(c);
1019        }
1020        let mut effective = teksilo_canvas::Transform2D::IDENTITY;
1021        for node_id in chain.iter().rev() {
1022            if let Some(node) = self.nodes.get(*node_id)
1023                && let Some(p) = node.transform_prop.as_ref()
1024            {
1025                let t = p.get();
1026                if !t.is_identity() {
1027                    effective = t.then(&effective);
1028                }
1029            }
1030        }
1031        effective
1032    }
1033
1034    /// Convert a **window-space** pointer position into the **widget-local**
1035    /// coordinate space of `id`'s event handlers — i.e. relative to `id`'s
1036    /// top-left, after undoing any transform scopes between the window and
1037    /// `id`. This is the single conversion the dispatcher applies before
1038    /// handing a position to `on_tap` / `on_drag` / `on_pointer_event`, so
1039    /// every handler sees positions in its own local space.
1040    ///
1041    /// The transform handling mirrors `Self::hit_test_recursive` so the
1042    /// position a handler receives is in the same space the hit-test used
1043    /// to pick it:
1044    /// * A **content** transform node (`content_transform`, e.g.
1045    ///   `SceneView`) owns its transform and maps its content itself. The
1046    ///   framework feeds such a node positions in its **parent-effective**
1047    ///   space (the same space `hit_test_recursive` passes through
1048    ///   `inv(transform)`), with **no** bounds-origin subtraction — the
1049    ///   node's `view_transform` already accounts for its placement.
1050    /// * Any other node (the 90%+ identity case, plus `Scale` / `Rotate`
1051    ///   self-transforms) receives widget-local coordinates: undo the full
1052    ///   transform chain including its own, then subtract its bounds
1053    ///   origin so the result is relative to its top-left.
1054    ///
1055    /// In the common no-transform case this collapses to
1056    /// `window_point - bounds.origin`.
1057    pub fn local_pointer_position(
1058        &self,
1059        id: WidgetId,
1060        window_point: teksilo_canvas::Point,
1061    ) -> teksilo_canvas::Point {
1062        self.local_frame(id).to_local(window_point)
1063    }
1064
1065    /// The frame [`local_pointer_position`](Self::local_pointer_position)
1066    /// localises into for `id`, which also maps back out of it. One walk up
1067    /// the tree, so a caller with several points to convert (a gesture's,
1068    /// a batch's) pays it once.
1069    pub(crate) fn local_frame(&self, id: WidgetId) -> LocalFrame {
1070        let content_transform = self.get(id).map(|n| n.content_transform).unwrap_or(false);
1071        let (to_window, origin) = if content_transform {
1072            // Parent-effective space, no origin subtraction (the node's
1073            // own transform consumes these coordinates).
1074            let to_parent = self
1075                .parent(id)
1076                .map(|p| self.effective_transform(p))
1077                .unwrap_or(teksilo_canvas::Transform2D::IDENTITY);
1078            (to_parent, teksilo_canvas::Point::ZERO)
1079        } else {
1080            let bounds = self.bounds(id);
1081            (
1082                self.effective_transform(id),
1083                teksilo_canvas::Point::new(bounds.x, bounds.y),
1084            )
1085        };
1086        LocalFrame {
1087            from_window: to_window.inverse(),
1088            to_window,
1089            origin,
1090        }
1091    }
1092
1093    /// Get all root-level widget IDs (widgets with no parent).
1094    pub fn roots(&self) -> Vec<WidgetId> {
1095        if self.roots_dirty {
1096            // Fall back to scanning when cache is stale.
1097            // refresh_roots() should be called from layout() for the fast path.
1098            return self
1099                .nodes
1100                .iter()
1101                .filter(|(_, node)| node.parent.is_none())
1102                .map(|(id, _)| id)
1103                .collect();
1104        }
1105        self.cached_roots.clone()
1106    }
1107
1108    /// Refresh the cached roots list. Call once per frame from layout().
1109    pub fn refresh_roots(&mut self) {
1110        if self.roots_dirty {
1111            self.cached_roots = self
1112                .nodes
1113                .iter()
1114                .filter(|(_, node)| node.parent.is_none())
1115                .map(|(id, _)| id)
1116                .collect();
1117            self.roots_dirty = false;
1118        }
1119    }
1120
1121    /// Walk the active widget tree at `point` and return the deepest
1122    /// widget under it (the front-most hit, last child wins). Honors
1123    /// `event_pass_through` (such nodes pass through to whatever sits
1124    /// behind them but their descendants are still hit-testable). Does
1125    /// not consider overlays — for the full pointer-routing hit-test
1126    /// see `WidgetTree::hit_test`.
1127    ///
1128    /// `exclude`: if `Some(id)`, that widget (and any descendants
1129    /// within its subtree) are skipped during the walk. Used by the
1130    /// debug inspector's picker tool to ignore the picker overlay
1131    /// itself, and by drag-and-drop to ignore the drag preview.
1132    pub fn hit_test_at(
1133        &self,
1134        point: teksilo_canvas::Point,
1135        exclude: Option<WidgetId>,
1136    ) -> Option<WidgetId> {
1137        self.hit_test_at_with(point, exclude, &HitContext::mouse())
1138    }
1139
1140    /// [`hit_test_at`](Self::hit_test_at) on behalf of a named pointer.
1141    ///
1142    /// The **exact** pass only: `Widget::hit_outset` is consulted (so a grip
1143    /// wins over what it overlaps for the kind that asked), but no slop
1144    /// re-attribution happens. Callers that want re-attribution too use
1145    /// [`hit_test_at_with_slop`](Self::hit_test_at_with_slop).
1146    pub fn hit_test_at_with(
1147        &self,
1148        point: teksilo_canvas::Point,
1149        exclude: Option<WidgetId>,
1150        hit: &HitContext<'_>,
1151    ) -> Option<WidgetId> {
1152        self.hit_test_roots_with(&self.roots(), point, exclude, hit)
1153    }
1154
1155    /// [`hit_test_at_with`](Self::hit_test_at_with) over `roots` alone, which
1156    /// is a subset of [`roots`](Self::roots) in the same order.
1157    fn hit_test_roots_with(
1158        &self,
1159        roots: &[WidgetId],
1160        point: teksilo_canvas::Point,
1161        exclude: Option<WidgetId>,
1162        hit: &HitContext<'_>,
1163    ) -> Option<WidgetId> {
1164        // Roots take the outset pre-pass too, so a grip that happens to be a
1165        // top-level node behaves like one nested anywhere else. The window is
1166        // its "parent", and the window does not clip — and, having no widget,
1167        // it vetoes nothing.
1168        let no_veto = |_: WidgetId| false;
1169        if let Some(grip) = self.outset_hit(roots, point, exclude, hit, &no_veto) {
1170            return Some(grip);
1171        }
1172        for &root in roots.iter().rev() {
1173            if let Some(found) = self.hit_test_recursive(root, point, exclude, hit) {
1174                return Some(found);
1175            }
1176        }
1177        None
1178    }
1179
1180    /// The full two-stage hit test: the exact pass, then — **only when it found
1181    /// nothing eligible** — the nearest-candidate slop pass.
1182    ///
1183    /// Returns whatever the exact pass returned unless a slop candidate is
1184    /// strictly closer than the bubble owner's uninflated shape. See
1185    /// [`hit_candidates`](Self::hit_candidates) for the eligibility rules and
1186    /// `docs/density-and-targets.md` for the prose.
1187    ///
1188    /// For a mouse this is [`hit_test_at`](Self::hit_test_at): the mouse slop
1189    /// radius is `0.0` at every density, so the second stage short-circuits
1190    /// before it walks anything.
1191    pub fn hit_test_at_with_slop(
1192        &self,
1193        point: teksilo_canvas::Point,
1194        exclude: Option<WidgetId>,
1195        hit: &HitContext<'_>,
1196    ) -> Option<WidgetId> {
1197        self.hit_test_roots_with_slop(self.roots(), point, exclude, hit)
1198    }
1199
1200    /// [`hit_test_at_with_slop`](Self::hit_test_at_with_slop) over `roots`
1201    /// alone, which is a subset of [`roots`](Self::roots) in the same order:
1202    /// neither pass looks inside a root left out.
1203    pub(crate) fn hit_test_roots_with_slop(
1204        &self,
1205        roots: Vec<WidgetId>,
1206        point: teksilo_canvas::Point,
1207        exclude: Option<WidgetId>,
1208        hit: &HitContext<'_>,
1209    ) -> Option<WidgetId> {
1210        let exact = self.hit_test_roots_with(&roots, point, exclude, hit);
1211        self.apply_slop(roots, point, exclude, hit, exact)
1212    }
1213
1214    /// [`hit_test_in_subtree`](Self::hit_test_in_subtree) with the slop pass,
1215    /// scoped so candidates never leave `start`'s subtree.
1216    ///
1217    /// This is what restricts the pass to the topmost overlay layer the exact
1218    /// pass entered: the tree calls it with the overlay's content root, so a
1219    /// press inside a menu can never be re-attributed to a control on the page
1220    /// behind it.
1221    pub fn hit_test_in_subtree_with_slop(
1222        &self,
1223        start: WidgetId,
1224        point: teksilo_canvas::Point,
1225        exclude: Option<WidgetId>,
1226        hit: &HitContext<'_>,
1227    ) -> Option<WidgetId> {
1228        let exact = self.hit_test_recursive(start, point, exclude, hit);
1229        self.apply_slop(vec![start], point, exclude, hit, exact)
1230    }
1231
1232    /// Hit-test starting from a specific subtree root rather than the
1233    /// arena's top-level roots. Same semantics as
1234    /// [`hit_test_at`](Self::hit_test_at) but scoped — useful when
1235    /// callers want to ignore everything outside a known subtree
1236    /// (e.g. the inspector's picker hit-tests inside the user-root
1237    /// subtree so it never resolves to its own chrome).
1238    pub fn hit_test_in_subtree(
1239        &self,
1240        start: WidgetId,
1241        point: teksilo_canvas::Point,
1242    ) -> Option<WidgetId> {
1243        self.hit_test_recursive(start, point, None, &HitContext::mouse())
1244    }
1245
1246    /// Like [`hit_test_in_subtree`](Self::hit_test_in_subtree) but also
1247    /// excludes a widget (and its descendants) from the walk. Lets the
1248    /// overlay / drag-and-drop hit-test reuse the single canonical recursion
1249    /// in `hit_test_recursive` instead of duplicating it.
1250    pub fn hit_test_in_subtree_excluding(
1251        &self,
1252        start: WidgetId,
1253        point: teksilo_canvas::Point,
1254        exclude: Option<WidgetId>,
1255    ) -> Option<WidgetId> {
1256        self.hit_test_recursive(start, point, exclude, &HitContext::mouse())
1257    }
1258
1259    /// [`hit_test_in_subtree_excluding`](Self::hit_test_in_subtree_excluding)
1260    /// on behalf of a named pointer. Exact pass only.
1261    pub fn hit_test_in_subtree_with(
1262        &self,
1263        start: WidgetId,
1264        point: teksilo_canvas::Point,
1265        exclude: Option<WidgetId>,
1266        hit: &HitContext<'_>,
1267    ) -> Option<WidgetId> {
1268        self.hit_test_recursive(start, point, exclude, hit)
1269    }
1270
1271    fn hit_test_recursive(
1272        &self,
1273        id: WidgetId,
1274        point: teksilo_canvas::Point,
1275        exclude: Option<WidgetId>,
1276        hit: &HitContext<'_>,
1277    ) -> Option<WidgetId> {
1278        if !self.is_active(id) || Some(id) == exclude {
1279            return None;
1280        }
1281        // Decorative subtree: skip this node and ALL its descendants so
1282        // the point falls through to whatever is painted behind. Checked
1283        // before descending into children (the difference from
1284        // `event_pass_through`, which is applied only after the children
1285        // miss).
1286        if self.get(id).map(|n| n.hit_transparent).unwrap_or(false) {
1287            return None;
1288        }
1289        let space = self.hit_space(id, point)?;
1290        let HitSpace {
1291            bounds_point,
1292            child_point,
1293            bounds,
1294            ..
1295        } = space;
1296        if !bounds.contains(bounds_point) {
1297            return None;
1298        }
1299        // Shape rejection: a widget with a non-rectangular silhouette (an
1300        // ellipse / cloud scene node, a circular handle) can reject a point
1301        // that is inside its bounding box but outside its actual shape via
1302        // `Widget::hit_shape`. Returning None here lets the caller's
1303        // reverse-sibling loop fall through to whatever is painted
1304        // underneath — the same path `event_pass_through` takes, but
1305        // shape-aware (only the rejected sub-region falls through, not the
1306        // whole widget). Default `hit_shape` returns true, so rectangular
1307        // widgets take this branch for free with no behavior change.
1308        if let Some(node) = self.get(id)
1309            && !node.widget.hit_shape(bounds_point, bounds)
1310        {
1311            return None;
1312        }
1313        let pass_through = self.get(id).map(|n| n.event_pass_through).unwrap_or(false);
1314        let children: Vec<WidgetId> = self.children(id).to_vec();
1315        // A child that declares a `Widget::hit_outset` is offered the point
1316        // BEFORE the ordinary reverse-sibling walk, so a thin grip wins over
1317        // whatever it overlaps rather than losing to whichever neighbour is
1318        // painted on top of it. Only the ring OUTSIDE a child's own bounds is
1319        // resolved here — a point genuinely inside a child falls through to the
1320        // normal walk below, which resolves descendants and honours
1321        // `hit_shape`, so declaring an outset never changes where an in-bounds
1322        // press lands.
1323        // A parent that owns a second picking system over the same area gets
1324        // to veto a child for this point — see `Widget::accepts_child_hit`.
1325        // Resolved once here and threaded into `outset_hit`, so a grip cannot
1326        // sneak past a veto the ordinary walk would have honoured.
1327        let parent = self.get(id);
1328        let vetoes = |child: WidgetId| {
1329            parent.is_some_and(|node| !node.widget.accepts_child_hit(child, child_point))
1330        };
1331        if let Some(grip) = self.outset_hit(&children, child_point, exclude, hit, &vetoes) {
1332            return Some(grip);
1333        }
1334        for &child in children.iter().rev() {
1335            if vetoes(child) {
1336                continue;
1337            }
1338            if let Some(found) = self.hit_test_recursive(child, child_point, exclude, hit) {
1339                return Some(found);
1340            }
1341        }
1342        if pass_through {
1343            return None;
1344        }
1345        Some(id)
1346    }
1347
1348    /// The outset pre-pass over one parent's children.
1349    ///
1350    /// A child that declares an outset is offered the point against its
1351    /// **inflated** bounds, ahead of the ordinary reverse-sibling walk, so a
1352    /// thin grip wins over whatever is painted on top of it — both in its ring
1353    /// and in its own body, which is the whole point of a splitter gutter lying
1354    /// under two panes.
1355    ///
1356    /// Ordering is by distance to the child's own uninflated rectangle, so two
1357    /// adjacent grips whose rings overlap split the difference at the midpoint
1358    /// rather than letting sibling order decide; ties go to the later sibling,
1359    /// which is the one painted on top.
1360    ///
1361    /// A candidate is resolved through the ordinary recursion first, so a
1362    /// descendant inside the grip still wins and `hit_shape` is still honoured;
1363    /// only a point genuinely in the ring — outside the child's real bounds —
1364    /// resolves to the child itself. A candidate that resolves to nothing hands
1365    /// over to the next-nearest, and finally to the normal walk.
1366    ///
1367    /// `vetoes` is the parent's own per-point rejection
1368    /// ([`Widget::accepts_child_hit`]),
1369    /// applied here as well as in the ordinary walk — a grip must not win a
1370    /// point the parent has already refused for it.
1371    fn outset_hit(
1372        &self,
1373        children: &[WidgetId],
1374        point: teksilo_canvas::Point,
1375        exclude: Option<WidgetId>,
1376        hit: &HitContext<'_>,
1377        vetoes: &dyn Fn(WidgetId) -> bool,
1378    ) -> Option<WidgetId> {
1379        // Almost every parent has no outset-declaring child at all, so the
1380        // common case allocates nothing and returns on the first loop.
1381        let mut candidates: Vec<(WidgetId, f32, bool)> = Vec::new();
1382        // Walked topmost-first so that, after a STABLE ascending sort, two
1383        // grips at exactly the same distance are resolved in paint order.
1384        for &child in children.iter().rev() {
1385            if !self.is_active(child) || Some(child) == exclude {
1386                continue;
1387            }
1388            let Some(node) = self.get(child) else {
1389                continue;
1390            };
1391            // A decorative or pass-through node never absorbs a press, so
1392            // widening it would only punch a hole in whatever is behind it.
1393            // `no_hit_slop` is the head of the precedence chain and turns off
1394            // BOTH widening mechanisms.
1395            if node.hit_transparent || node.event_pass_through || node.no_hit_slop {
1396                continue;
1397            }
1398            let outset = node.widget.hit_outset(hit.kind(), hit.tokens());
1399            let (top, bottom) = (finite(outset.top), finite(outset.bottom));
1400            let (leading, trailing) = (finite(outset.leading), finite(outset.trailing));
1401            if top <= 0.0 && bottom <= 0.0 && leading <= 0.0 && trailing <= 0.0 {
1402                continue;
1403            }
1404            // The parent's per-point veto applies here too: a grip that the
1405            // ordinary walk would refuse must not win by being offered first.
1406            //
1407            // Asked **after** the zero-outset test, not before. Almost no child
1408            // declares an outset, and this predicate is a real per-point query
1409            // (the `SceneView`'s is a snapshot scan), so asking it first made
1410            // every hit test on a vetoing parent pay it twice per child — once
1411            // here for children that were about to be skipped anyway, and once
1412            // in the ordinary walk. The order does not change the answer: a
1413            // child that survives to `candidates` is exactly one this used to
1414            // reach.
1415            if vetoes(child) {
1416                continue;
1417            }
1418            let Some(space) = self.hit_space(child, point) else {
1419                continue;
1420            };
1421            // Reading order → screen edges.
1422            let (left, right) = match hit.layout_direction() {
1423                crate::environment::LayoutDirection::LeftToRight => (leading, trailing),
1424                crate::environment::LayoutDirection::RightToLeft => (trailing, leading),
1425            };
1426            let inflated = teksilo_canvas::Rect::new(
1427                space.bounds.x - left,
1428                space.bounds.y - top,
1429                space.bounds.width + left + right,
1430                space.bounds.height + top + bottom,
1431            );
1432            if !inflated.contains(space.bounds_point) {
1433                continue;
1434            }
1435            let inside = space.bounds.contains(space.bounds_point);
1436            let distance =
1437                crate::pointer::hit_slop::rect_distance(space.bounds, space.bounds_point);
1438            candidates.push((child, distance, inside));
1439        }
1440        if candidates.is_empty() {
1441            return None;
1442        }
1443        candidates.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1444        for (child, _, inside) in candidates {
1445            if let Some(found) = self.hit_test_recursive(child, point, exclude, hit) {
1446                return Some(found);
1447            }
1448            // The ring: the point is outside the child's real bounds, so the
1449            // ordinary recursion could never have found it, and the outset is
1450            // the whole reason it is being offered.
1451            if !inside {
1452                return Some(child);
1453            }
1454            // Inside the bounds but the recursion declined (a `hit_shape`
1455            // rejection, an empty pass-through): the outset has nothing to add,
1456            // so hand back to the normal walk.
1457        }
1458        None
1459    }
1460
1461    /// Resolve one node's transform for hit-testing: the point to test its own
1462    /// bounds against, the point to hand its children, and its bounds.
1463    ///
1464    /// The input point arrives in this node's parent-effective space. A
1465    /// `set_transform` scope is composed by the render walker around this
1466    /// node's subtree, so hit-testing mirrors it by inverse-applying the
1467    /// transform once. *Which* rectangle the transform applies to depends
1468    /// on whether it's a **content** transform or a **self** transform
1469    /// (see `WidgetNode::content_transform`):
1470    ///
1471    /// * A **content** transform (`content_transform`, e.g. `SceneView`) is
1472    ///   a fixed viewport: its bounds are a rectangle in PARENT space and
1473    ///   the transform pans / zooms only its CONTENT. Test the bounds
1474    ///   against the parent-space point; inverse-transform only for
1475    ///   descending into children, so the whole visible viewport stays
1476    ///   interactive regardless of pan / zoom. (Without this, panning the
1477    ///   content shifts the hittable region off the viewport — clicks /
1478    ///   wheel over the visible scene fall through to whatever is behind.)
1479    /// * A **self** transform (`Scale` / `Rotate`, whose own bounds move
1480    ///   with the transform) inverse-transforms first, then tests its
1481    ///   bounds in the resulting local space (a click lands where the
1482    ///   scaled / rotated visual actually is).
1483    ///
1484    /// Identity / missing transforms collapse both paths to the scalar
1485    /// case, so the hot path stays cheap. `content_transform` is
1486    /// `SceneView`-only today, so this only changes SceneView hit-testing;
1487    /// `Scale` / `Rotate` (also `clips_children`) keep the self-transform
1488    /// path.
1489    ///
1490    /// `None` when the transform is singular (a collapsed axis) — that hides
1491    /// the entire subtree visually, and hit-testing mirrors it.
1492    fn hit_space(&self, id: WidgetId, point: teksilo_canvas::Point) -> Option<HitSpace> {
1493        let transform = self
1494            .get(id)
1495            .and_then(|n| n.transform_prop.as_ref())
1496            .map(|p| p.get())
1497            .filter(|t| !t.is_identity());
1498        let content_transform = self.get(id).map(|n| n.content_transform).unwrap_or(false);
1499        let child_point = match transform {
1500            Some(t) => t.inverse()?.apply_point(point),
1501            None => point,
1502        };
1503        let bounds_point = if content_transform {
1504            point
1505        } else {
1506            child_point
1507        };
1508        Some(HitSpace {
1509            bounds_point,
1510            child_point,
1511            bounds: self.bounds(id),
1512            scale: transform
1513                .as_ref()
1514                .map(crate::pointer::hit_slop::min_singular_value)
1515                .unwrap_or(1.0),
1516        })
1517    }
1518
1519    /// Every node the *miss-only* slop pass would consider for `point`, nearest
1520    /// first, scoped to `start`'s subtree.
1521    ///
1522    /// Public so a test — and the target-conformance audit — can inspect the
1523    /// pass's reasoning rather than only its verdict. Returning candidates does
1524    /// **not** mean one of them wins: see
1525    /// [`hit_test_at_with_slop`](Self::hit_test_at_with_slop) for the
1526    /// bubble-path rule that decides.
1527    ///
1528    /// # Eligibility
1529    ///
1530    /// A node is a candidate only if all of the following hold. Each is pinned
1531    /// by its own test in `widget_tree::hit_targeting_tests`.
1532    ///
1533    /// * It earns a non-zero outset from its resolved [`HitSlop`] — which, by
1534    ///   the size formula, excludes anything already at least `up_to` on its
1535    ///   smaller axis. A scrim, a page, a list row are excluded by arithmetic.
1536    /// * It would actually *do* something with the press:
1537    ///   [`takes_a_press`](Self::takes_a_press). Re-attributing to a node that
1538    ///   ignores presses would silently swallow one.
1539    /// * It is **enabled** — its own `enabled_state` and every ancestor's.
1540    /// * It is not **read-only**, as reported by the context's probe.
1541    /// * It does not carry `no_hit_slop`, and it is not `event_pass_through`
1542    ///   (which absorbs nothing; its **children** stay eligible).
1543    /// * It is not inside a `hit_transparent` subtree — those are pruned whole.
1544    /// * No `clips_children` ancestor's **uninflated** rectangle excludes the
1545    ///   point: slop never reaches out of a scroller.
1546    /// * Its [`Widget::hit_distance`] answers `Some(d)` with `0 < d ≤ outset`.
1547    ///   `d = 0` means the point is inside the shape, which is the exact pass's
1548    ///   business — the slop pass only ever re-attributes a genuine miss.
1549    ///
1550    /// Distances are measured in each node's own space and converted to screen
1551    /// dp through the accumulated
1552    /// [`min_singular_value`](crate::pointer::hit_slop::min_singular_value) of
1553    /// the transforms above it. For a chain of transforms the product of the
1554    /// per-node minima is a lower bound on the true composed minimum, so the
1555    /// reach under a stack of transforms errs towards being generous rather
1556    /// than short.
1557    ///
1558    /// [`HitSlop`]: crate::pointer::hit_slop::HitSlop
1559    /// [`Widget::hit_distance`]: crate::widget::Widget::hit_distance
1560    pub fn hit_candidates(
1561        &self,
1562        start: WidgetId,
1563        point: teksilo_canvas::Point,
1564        exclude: Option<WidgetId>,
1565        hit: &HitContext<'_>,
1566    ) -> Vec<HitCandidate> {
1567        let mut out = Vec::new();
1568        if hit.slop_enabled() {
1569            self.collect_candidates(start, point, 1.0, true, exclude, hit, &mut out);
1570            out.sort_by(|a, b| {
1571                a.distance
1572                    .partial_cmp(&b.distance)
1573                    .unwrap_or(std::cmp::Ordering::Equal)
1574            });
1575        }
1576        out
1577    }
1578
1579    #[allow(clippy::too_many_arguments)]
1580    fn collect_candidates(
1581        &self,
1582        id: WidgetId,
1583        point: teksilo_canvas::Point,
1584        scale: f32,
1585        enabled: bool,
1586        exclude: Option<WidgetId>,
1587        hit: &HitContext<'_>,
1588        out: &mut Vec<HitCandidate>,
1589    ) {
1590        if !self.is_active(id) || Some(id) == exclude {
1591            return;
1592        }
1593        let Some(node) = self.get(id) else { return };
1594        // Decorative subtree: pruned whole, exactly as in the exact pass.
1595        if node.hit_transparent {
1596            return;
1597        }
1598        let Some(space) = self.hit_space(id, point) else {
1599            return;
1600        };
1601        // Slop never escapes a clipping ancestor's UNINFLATED rectangle: a
1602        // control scrolled out of a `ScrollArea` must not catch a press landing
1603        // on the scroller's border.
1604        if node.clips_children && !space.bounds.contains(space.bounds_point) {
1605            return;
1606        }
1607        let enabled = enabled
1608            && node
1609                .enabled_state
1610                .as_ref()
1611                .map(|state| state.get())
1612                .unwrap_or(true);
1613        let scale_children = scale * space.scale;
1614        // A content transform leaves the node's own bounds in parent space; a
1615        // self transform moves them with it.
1616        let scale_self = if node.content_transform {
1617            scale
1618        } else {
1619            scale_children
1620        };
1621        // A node with no area is a helper, not a control: a frame-tick driver,
1622        // a measuring probe, a collapsed placeholder. Nobody aimed at it, and
1623        // the size formula would hand it the largest top-up there is, so it
1624        // would catch every miss within reach of a point. Its children are
1625        // still walked: an empty node may host real content that overflows it.
1626        let has_area = space.bounds.width > 0.0 && space.bounds.height > 0.0;
1627        if enabled
1628            && has_area
1629            && !node.no_hit_slop
1630            && !node.event_pass_through
1631            && !hit.is_read_only(id)
1632            && self.takes_a_press(id)
1633        {
1634            let slop = node
1635                .hit_slop
1636                .or_else(|| node.widget.hit_slop(hit.kind(), hit.tokens()))
1637                .unwrap_or_else(|| hit.default_slop());
1638            let outset = slop.outset_for(space.bounds.size());
1639            if outset > 0.0
1640                && let Some(local) = node.widget.hit_distance(space.bounds_point, space.bounds)
1641            {
1642                let distance = local * scale_self;
1643                if distance > 0.0 && distance <= outset && distance.is_finite() {
1644                    out.push(HitCandidate {
1645                        id,
1646                        distance,
1647                        outset,
1648                    });
1649                }
1650            }
1651        }
1652        for &child in self.children(id) {
1653            self.collect_candidates(
1654                child,
1655                space.child_point,
1656                scale_children,
1657                enabled,
1658                exclude,
1659                hit,
1660                out,
1661            );
1662        }
1663    }
1664
1665    /// Whether a press landing on this node would do anything at all — the
1666    /// definition of an "eligible handler" for the slop pass's bubble-path
1667    /// rule.
1668    ///
1669    /// A node qualifies if it carries any pointer-facing handler (tap, multi
1670    /// tap, long press, drag, swipe, pinch, the raw pointer stream, scroll) or
1671    /// is focusable, and is enabled. Accessibility actions and key handlers do
1672    /// not count: neither is reachable from a pointer.
1673    pub fn takes_a_press(&self, id: WidgetId) -> bool {
1674        let Some(node) = self.get(id) else {
1675            return false;
1676        };
1677        if !self.is_enabled(id) {
1678            return false;
1679        }
1680        let pointer_facing = |h: &crate::event_handlers::EventHandlers| {
1681            h.on_tap.is_some()
1682                || h.on_double_tap.is_some()
1683                || h.on_triple_tap.is_some()
1684                || h.on_long_press.is_some()
1685                || h.on_drag.is_some()
1686                || h.on_swipe.is_some()
1687                || h.on_pinch.is_some()
1688                || h.on_pointer_event.is_some()
1689                || h.on_scroll.is_some()
1690        };
1691        pointer_facing(&node.handlers)
1692            || pointer_facing(&node.external_handlers)
1693            || node.node_focusable.unwrap_or(false)
1694    }
1695
1696    /// Run the miss-only pass over `roots` and decide between it and `exact`.
1697    ///
1698    /// The rule, in one place: the exact hit's **entire bubble path** is
1699    /// examined, and a slop candidate wins only when that path carries no
1700    /// eligible handler at all, or when the candidate is strictly closer than
1701    /// the bubble owner's *uninflated* shape. That is what keeps a press on a
1702    /// row label 5 dp from an inline checkbox on the row — the row owns the
1703    /// press at distance zero, and nothing beats zero.
1704    fn apply_slop(
1705        &self,
1706        roots: Vec<WidgetId>,
1707        point: teksilo_canvas::Point,
1708        exclude: Option<WidgetId>,
1709        hit: &HitContext<'_>,
1710        exact: Option<WidgetId>,
1711    ) -> Option<WidgetId> {
1712        if !hit.slop_enabled() {
1713            return exact;
1714        }
1715        let owner_distance = match exact.and_then(|target| self.bubble_owner(target, &roots)) {
1716            Some(owner) => self.distance_to(owner, point, &roots).unwrap_or(0.0),
1717            // Either nothing was hit, or what was hit ignores presses all the
1718            // way up: there is nothing to beat.
1719            None => f32::INFINITY,
1720        };
1721        if owner_distance <= 0.0 {
1722            return exact;
1723        }
1724        // A grip that won its point through its own `Widget::hit_outset` made a
1725        // deliberate claim *inside* the exact pass, and the miss-only pass must
1726        // not take it back.
1727        //
1728        // Without this the two mechanisms fight, and the outset loses every
1729        // time: a grip only ever claims a point at a positive distance from its
1730        // own shape, so any slop-eligible node under its ring is strictly
1731        // closer and wins. The rule, rather than the arithmetic: a ring is taken
1732        // back wherever a neighbour is still small enough to earn a top-up of
1733        // its own, so raising the density can LOWER a grip's reach — `up_to`
1734        // grows from 24 to 44 dp and rows that earned nothing become candidates.
1735        // It is not confined to the coarse densities either: at Compact a
1736        // neighbour under 24 dp is already a candidate.
1737        //
1738        // The two measurements this rests on are pinned in
1739        // teksilo-target-conformance by
1740        // `an_outsets_claim_survives_the_slop_pass_in_the_shipped_controls`
1741        // (a SearchField's clear button at Compact, a TableView's scroll bar at
1742        // Touch), and the mechanism itself by
1743        // `a_grip_that_won_through_its_outset_keeps_its_point_against_the_slop_pass`
1744        // in this crate. Reverting this branch reddens all three. The precedence
1745        // chain in `docs/density-and-targets.md` names one chain for both
1746        // mechanisms, and this is what keeps it one.
1747        if exact.is_some_and(|target| self.won_through_outset(target, point, &roots, hit)) {
1748            return exact;
1749        }
1750        let mut best: Option<HitCandidate> = None;
1751        for &root in roots.iter().rev() {
1752            for candidate in self.hit_candidates(root, point, exclude, hit) {
1753                if candidate.distance < owner_distance
1754                    && best.is_none_or(|b| candidate.distance < b.distance)
1755                {
1756                    best = Some(candidate);
1757                }
1758            }
1759        }
1760        best.map(|c| c.id).or(exact)
1761    }
1762
1763    /// The deepest node on `target`'s own path (itself, then ancestors, up to
1764    /// and including whichever of `roots` contains it) that would act on a
1765    /// press.
1766    fn bubble_owner(&self, target: WidgetId, roots: &[WidgetId]) -> Option<WidgetId> {
1767        let mut current = Some(target);
1768        while let Some(id) = current {
1769            if self.takes_a_press(id) {
1770                return Some(id);
1771            }
1772            if roots.contains(&id) {
1773                return None;
1774            }
1775            current = self.get(id).and_then(|n| n.parent);
1776        }
1777        None
1778    }
1779
1780    /// Whether `target`, or a node on its path to a root, claimed `point`
1781    /// through its own [`Widget::hit_outset`] — the point sits outside that
1782    /// node's real bounds and inside its inflated ones.
1783    ///
1784    /// The predicate behind the outset's precedence over the miss-only pass in
1785    /// [`apply_slop`](Self::apply_slop). The whole path is examined because the
1786    /// pre-pass resolves a candidate *through* the ordinary recursion, so the
1787    /// node the exact pass returns may be a descendant of the grip that won.
1788    ///
1789    /// [`Widget::hit_outset`]: crate::widget::Widget::hit_outset
1790    fn won_through_outset(
1791        &self,
1792        target: WidgetId,
1793        point: teksilo_canvas::Point,
1794        roots: &[WidgetId],
1795        hit: &HitContext<'_>,
1796    ) -> bool {
1797        let mut chain = vec![target];
1798        let mut current = target;
1799        while !roots.contains(&current) {
1800            match self.get(current).and_then(|n| n.parent) {
1801                Some(parent) => {
1802                    chain.push(parent);
1803                    current = parent;
1804                }
1805                None => break,
1806            }
1807        }
1808        chain.reverse();
1809        let mut p = point;
1810        for &node_id in &chain {
1811            let Some(space) = self.hit_space(node_id, p) else {
1812                return false;
1813            };
1814            let Some(node) = self.get(node_id) else {
1815                return false;
1816            };
1817            if !node.no_hit_slop {
1818                let outset = node.widget.hit_outset(hit.kind(), hit.tokens());
1819                let (top, bottom) = (finite(outset.top), finite(outset.bottom));
1820                let (leading, trailing) = (finite(outset.leading), finite(outset.trailing));
1821                if top > 0.0 || bottom > 0.0 || leading > 0.0 || trailing > 0.0 {
1822                    let (left, right) = match hit.layout_direction() {
1823                        crate::environment::LayoutDirection::LeftToRight => (leading, trailing),
1824                        crate::environment::LayoutDirection::RightToLeft => (trailing, leading),
1825                    };
1826                    let inflated = teksilo_canvas::Rect::new(
1827                        space.bounds.x - left,
1828                        space.bounds.y - top,
1829                        space.bounds.width + left + right,
1830                        space.bounds.height + top + bottom,
1831                    );
1832                    if !space.bounds.contains(space.bounds_point)
1833                        && inflated.contains(space.bounds_point)
1834                    {
1835                        return true;
1836                    }
1837                }
1838            }
1839            p = space.child_point;
1840        }
1841        false
1842    }
1843
1844    /// Distance from a root-space `point` to `id`'s own shape, in screen dp.
1845    ///
1846    /// Walks down from whichever of `roots` owns `id` so the transforms are
1847    /// applied in the same order the hit test applies them, and converts the
1848    /// local distance through the accumulated minimum singular value.
1849    fn distance_to(
1850        &self,
1851        id: WidgetId,
1852        point: teksilo_canvas::Point,
1853        roots: &[WidgetId],
1854    ) -> Option<f32> {
1855        let mut chain = vec![id];
1856        let mut current = id;
1857        while !roots.contains(&current) {
1858            match self.get(current).and_then(|n| n.parent) {
1859                Some(parent) => {
1860                    chain.push(parent);
1861                    current = parent;
1862                }
1863                None => break,
1864            }
1865        }
1866        chain.reverse();
1867        let mut p = point;
1868        let mut scale = 1.0_f32;
1869        for (index, &node_id) in chain.iter().enumerate() {
1870            let space = self.hit_space(node_id, p)?;
1871            if index + 1 == chain.len() {
1872                let scale_self = if self.get(node_id).map(|n| n.content_transform)? {
1873                    scale
1874                } else {
1875                    scale * space.scale
1876                };
1877                let local = self
1878                    .get(node_id)?
1879                    .widget
1880                    .hit_distance(space.bounds_point, space.bounds)?;
1881                return Some(local * scale_self);
1882            }
1883            scale *= space.scale;
1884            p = space.child_point;
1885        }
1886        None
1887    }
1888
1889    /// Iterate over all active widget IDs.
1890    ///
1891    /// Allocating wrapper around [`Self::active_ids_iter`]. Hot-path
1892    /// callers that hold `&self` for the whole iteration should call
1893    /// the iterator directly to avoid the per-call `Vec` allocation;
1894    /// callers that need an owned snapshot (because they mutate
1895    /// arena state inside the loop) should use
1896    /// [`Self::fill_active_ids`] with a reusable buffer.
1897    pub fn active_ids(&self) -> Vec<WidgetId> {
1898        self.active_ids_iter().collect()
1899    }
1900
1901    /// Stream all active widget IDs without allocating. The iterator
1902    /// borrows the arena, so the caller cannot mutate it while
1903    /// iterating — for that case use [`Self::fill_active_ids`].
1904    pub fn active_ids_iter(&self) -> impl Iterator<Item = WidgetId> + '_ {
1905        self.nodes
1906            .iter()
1907            .filter(|(_, node)| node.activation == ActivationState::Active)
1908            .map(|(id, _)| id)
1909    }
1910
1911    /// Fill `out` with every active widget ID. Clears `out` first so
1912    /// callers can reuse a long-lived buffer across calls. Use this
1913    /// when the iteration site needs an owned snapshot independent
1914    /// of the arena borrow (typically because it mutates per-widget
1915    /// state with `arena.get_mut(id)` inside the loop).
1916    pub fn fill_active_ids(&self, out: &mut Vec<WidgetId>) {
1917        out.clear();
1918        out.extend(self.active_ids_iter());
1919    }
1920
1921    /// Set a widget subtree to dormant state (state preserved, not rendered).
1922    /// Recursively dormants all children.
1923    ///
1924    /// The node named here is marked self-parked (`WidgetNode::self_dormant`);
1925    /// the descendants swept along by the recursion are not, since their
1926    /// dormancy belongs to this ancestor rather than to them. That distinction
1927    /// is what lets [`activate`](Self::activate) put the subtree back exactly as
1928    /// it found it instead of waking content that was already closed.
1929    ///
1930    /// **Returns the whole parked subtree**, `id` first, because a caller that
1931    /// cannot see which nodes went to sleep cannot cancel the pointers holding
1932    /// them. Dormancy is invisible to hit-testing and to dispatch, so a widget
1933    /// parked mid-interaction keeps whatever the press latched and never
1934    /// receives another event: the ids are how the tree finds it and tells it
1935    /// to let go. Every caller is audited in `docs/touch-and-pen.md` §3.3.
1936    pub fn set_dormant(&mut self, id: WidgetId) -> Vec<WidgetId> {
1937        let mut parked = Vec::new();
1938        self.park(id, true, &mut parked);
1939        parked
1940    }
1941
1942    /// [`set_dormant`](Self::set_dormant)'s body, plus whether `id` is being
1943    /// parked on its own account or dragged along by an ancestor, and the
1944    /// accumulator the parked ids land in.
1945    ///
1946    /// A node already self-parked stays that way when an ancestor sweeps over
1947    /// it — the flag is only ever set here, never cleared, so nesting two
1948    /// dormancy cycles cannot lose the inner one.
1949    fn park(&mut self, id: WidgetId, on_its_own_account: bool, parked: &mut Vec<WidgetId>) {
1950        if let Some(node) = self.nodes.get_mut(id) {
1951            let was_active = node.activation == ActivationState::Active;
1952            node.activation = ActivationState::Dormant;
1953            if on_its_own_account {
1954                node.self_dormant = true;
1955            }
1956            // Record the Active→Dormant transition for nodes that opted into an
1957            // activation signal; the signal is fired later by
1958            // `WidgetTree::flush_activation_signals`, not here — see the
1959            // `pending_activation_changes` field docs.
1960            if was_active && node.activation_signal.is_some() {
1961                self.pending_activation_changes.push((id, false));
1962            }
1963            parked.push(id);
1964        }
1965        let children: Vec<WidgetId> = self.children(id).to_vec();
1966        for child in children {
1967            self.park(child, false, parked);
1968        }
1969    }
1970
1971    /// Activate a dormant widget subtree (triggers relayout and repaint).
1972    /// Recursively activates all children, **except** those a descendant
1973    /// widget has independently gated off via `visible_when(false)`.
1974    ///
1975    /// The directly-targeted `id` is always activated (the caller asked for
1976    /// it). When recursing, a child whose own `visible_state` currently
1977    /// evaluates to `false` is left dormant along with its subtree: it is
1978    /// hidden by its own gate, not by the ancestor's dormancy, so a parent
1979    /// reactivation must not wake it. This is what keeps a `ComboBox`'s
1980    /// closed dropdown panel, a collapsed overlay, or any `visible_when`-
1981    /// gated child from leaking back to the screen when an ancestor (e.g. a
1982    /// `Toolbar` item reappearing from overflow) is re-activated. The
1983    /// per-pass visibility reconciliation
1984    /// ([`visibility_checks_iter`](Self::visibility_checks_iter)) still owns
1985    /// the eventual activate/dormant transitions when the gate flips.
1986    pub fn activate(&mut self, id: WidgetId) {
1987        if let Some(node) = self.nodes.get_mut(id) {
1988            // Only Dormant→Active is a real "show" transition. Guard on
1989            // `== Dormant` (not `!= Active`) so a `Destroyed` node — or any
1990            // future non-Active state — is never resurrected or signalled.
1991            let was_dormant = node.activation == ActivationState::Dormant;
1992            node.activation = ActivationState::Active;
1993            node.self_dormant = false;
1994            node.dirty.needs_layout = true;
1995            node.dirty.needs_paint = true;
1996            // A relayout requested off the UI thread while it slept: take it
1997            // (re-arming the request), since the pre-pass skips sleepers.
1998            if let Some(wake) = &node.repaint_wake
1999                && wake.take_relayout()
2000            {
2001                self.relayout_rewake.push(id);
2002            }
2003            if was_dormant && node.activation_signal.is_some() {
2004                self.pending_activation_changes.push((id, true));
2005            }
2006        }
2007        let children: Vec<WidgetId> = self.children(id).to_vec();
2008        for child in children {
2009            let asleep_on_its_own_account = self
2010                .nodes
2011                .get(child)
2012                .map(|n| {
2013                    n.self_dormant
2014                        || n.visible_state
2015                            .as_ref()
2016                            .map(|vs| !vs.get())
2017                            .unwrap_or(false)
2018                })
2019                .unwrap_or(false);
2020            if asleep_on_its_own_account {
2021                continue;
2022            }
2023            self.activate(child);
2024        }
2025    }
2026
2027    /// Destroy a widget and remove it from the arena entirely.
2028    /// Recursively destroys all children. State is gone.
2029    pub fn destroy(&mut self, id: WidgetId) {
2030        self.roots_dirty = true;
2031        let children: Vec<WidgetId> = self.children(id).to_vec();
2032        for child in children {
2033            self.destroy(child);
2034        }
2035        self.remove_node(id);
2036    }
2037
2038    /// Remove a *single* node: unlink it from its parent's child list and drop
2039    /// it from the arena. Does **not** recurse into its children.
2040    ///
2041    /// The caller owns the recursion. This exists for
2042    /// [`WidgetTree::destroy_subtree`](crate::widget_tree::WidgetTree) /
2043    /// the reconciling rebuild path, which walks the subtree itself so it can
2044    /// honour re-parenting — a child re-homed into the surviving tree must NOT
2045    /// be torn down via this node's now-stale `children` list. Using
2046    /// [`destroy`](Self::destroy) there would re-recurse that stale list and
2047    /// destroy the re-homed survivor.
2048    pub fn remove_node(&mut self, id: WidgetId) {
2049        self.roots_dirty = true;
2050        if let Some(parent_id) = self.parent(id)
2051            && let Some(parent) = self.nodes.get_mut(parent_id)
2052        {
2053            parent.children.retain(|&c| c != id);
2054        }
2055        self.nodes.remove(id);
2056    }
2057
2058    /// Drain the buffered Active↔Dormant transitions recorded since the last
2059    /// call. Each `(id, active)` is fed to `WidgetTree::flush_activation_signals`
2060    /// which fires the node's `activation_signal` — at the tree level, outside
2061    /// any arena mutation.
2062    pub(crate) fn take_activation_changes(&mut self) -> Vec<(WidgetId, bool)> {
2063        std::mem::take(&mut self.pending_activation_changes)
2064    }
2065
2066    /// Record that `id` installed an `effective_enabled_signal`. Idempotent —
2067    /// the signal is install-or-reuse, so a rebuild re-registering the same
2068    /// node must not grow the list.
2069    pub(crate) fn watch_effective_enabled(&mut self, id: WidgetId) {
2070        if !self.effective_enabled_watchers.contains(&id) {
2071            self.effective_enabled_watchers.push(id);
2072        }
2073    }
2074
2075    /// The nodes carrying an `effective_enabled_signal`, for the per-pass
2076    /// refresh. Cloned so the caller can recompute `is_enabled` (an immutable
2077    /// ancestor walk) without holding a borrow on the arena.
2078    pub(crate) fn effective_enabled_watchers(&self) -> Vec<WidgetId> {
2079        self.effective_enabled_watchers.clone()
2080    }
2081
2082    /// Drop watchers whose node is gone (destroyed / rebuilt away).
2083    pub(crate) fn prune_effective_enabled_watchers(&mut self) {
2084        self.effective_enabled_watchers
2085            .retain(|id| self.nodes.contains_key(*id));
2086    }
2087
2088    pub fn is_active(&self, id: WidgetId) -> bool {
2089        self.nodes
2090            .get(id)
2091            .map(|n| n.activation == ActivationState::Active)
2092            .unwrap_or(false)
2093    }
2094
2095    pub fn len(&self) -> usize {
2096        self.nodes.len()
2097    }
2098
2099    pub fn is_empty(&self) -> bool {
2100        self.nodes.is_empty()
2101    }
2102
2103    pub fn mark_all_clean(&mut self) {
2104        for (_, node) in self.nodes.iter_mut() {
2105            node.dirty = DirtyFlags::default();
2106        }
2107    }
2108
2109    pub fn any_needs_layout(&self) -> bool {
2110        self.nodes
2111            .values()
2112            .any(|n| n.activation == ActivationState::Active && n.dirty.needs_layout)
2113    }
2114
2115    pub fn any_needs_paint(&self) -> bool {
2116        self.nodes
2117            .values()
2118            .any(|n| n.activation == ActivationState::Active && n.dirty.needs_paint)
2119    }
2120
2121    pub fn mark_needs_paint(&mut self, id: WidgetId) {
2122        if let Some(node) = self.nodes.get_mut(id) {
2123            node.dirty.needs_paint = true;
2124        }
2125    }
2126
2127    /// Recursively mark a widget and all its descendants needs_paint.
2128    /// Used by callers that want a fresh paint of an entire subtree
2129    /// — e.g. a rich tooltip whose dwell indicator child would
2130    /// otherwise reuse its cached_paint while the parent re-runs
2131    /// some per-frame logic.
2132    pub fn mark_subtree_needs_paint(&mut self, id: WidgetId) {
2133        if let Some(node) = self.nodes.get_mut(id) {
2134            node.dirty.needs_paint = true;
2135        }
2136        let children: Vec<WidgetId> = self.children(id).to_vec();
2137        for child in children {
2138            self.mark_subtree_needs_paint(child);
2139        }
2140    }
2141
2142    pub fn mark_needs_layout(&mut self, id: WidgetId) {
2143        if let Some(node) = self.nodes.get_mut(id) {
2144            node.dirty.needs_layout = true;
2145            node.dirty.needs_paint = true;
2146        }
2147    }
2148
2149    /// Mark a widget as needing its `build()` re-run.
2150    /// Also marks for layout and paint since rebuilt children need both.
2151    pub fn mark_needs_rebuild(&mut self, id: WidgetId) {
2152        if let Some(node) = self.nodes.get_mut(id) {
2153            node.dirty.needs_rebuild = true;
2154            node.dirty.needs_layout = true;
2155            node.dirty.needs_paint = true;
2156        }
2157    }
2158
2159    /// Collect widgets that need their `build()` re-run (data-driven rebuild).
2160    /// Only returns active widgets with `needs_rebuild == true`.
2161    ///
2162    /// Allocating wrapper around [`Self::needs_rebuild_iter`]. Prefer
2163    /// the iterator on hot paths.
2164    pub fn collect_needs_rebuild(&self) -> Vec<WidgetId> {
2165        self.needs_rebuild_iter().collect()
2166    }
2167
2168    /// Stream widgets that need `build()` re-run without allocating.
2169    ///
2170    /// `needs_rebuild` is set only by `BindingLevel::Rebuild` bindings —
2171    /// i.e. on composing widgets that explicitly want `build()` re-run
2172    /// when their data model changes. It is intentionally NOT gated on
2173    /// the widget currently having children: a data-driven widget that
2174    /// builds its children directly and starts EMPTY (e.g. the toast
2175    /// host with no toasts yet, an empty list that renders rows without
2176    /// a persistent container) must still rebuild to materialise its
2177    /// FIRST child. `rebuild_single_widget` handles a childless widget
2178    /// correctly (nothing to tear down, then it adopts `build()`'s
2179    /// output).
2180    pub fn needs_rebuild_iter(&self) -> impl Iterator<Item = WidgetId> + '_ {
2181        self.nodes
2182            .iter()
2183            .filter(|(_, n)| n.activation == ActivationState::Active && n.dirty.needs_rebuild)
2184            .map(|(id, _)| id)
2185    }
2186
2187    /// Check all widgets with visible_state bindings and return
2188    /// (id, is_currently_active, should_be_visible) tuples.
2189    ///
2190    /// Allocating wrapper around [`Self::visibility_checks_iter`].
2191    pub fn visibility_checks(&self) -> Vec<(WidgetId, bool, bool)> {
2192        self.visibility_checks_iter().collect()
2193    }
2194
2195    /// Stream widgets with `visible_state` bindings without
2196    /// allocating. Each entry is `(id, is_currently_active,
2197    /// should_be_visible)`.
2198    pub fn visibility_checks_iter(&self) -> impl Iterator<Item = (WidgetId, bool, bool)> + '_ {
2199        self.nodes.iter().filter_map(|(id, node)| {
2200            node.visible_state.as_ref().map(|state| {
2201                let is_active = node.activation == ActivationState::Active;
2202                let should_be_visible = state.get();
2203                (id, is_active, should_be_visible)
2204            })
2205        })
2206    }
2207
2208    /// Check if a widget is effectively enabled, walking up the parent chain.
2209    ///
2210    /// Returns `false` if the widget itself or any ancestor has `enabled_state`
2211    /// bound to `false`. This lets containers like `GroupBox` disable a whole
2212    /// subtree by binding a single signal on their content wrapper.
2213    pub fn is_enabled(&self, id: WidgetId) -> bool {
2214        let mut current = Some(id);
2215        while let Some(node_id) = current {
2216            if let Some(node) = self.nodes.get(node_id) {
2217                if let Some(ref state) = node.enabled_state
2218                    && !state.get()
2219                {
2220                    return false;
2221                }
2222                current = node.parent;
2223            } else {
2224                return true;
2225            }
2226        }
2227        true
2228    }
2229
2230    /// Set a per-child alignment override on a widget.
2231    pub fn set_alignment_override(&mut self, id: WidgetId, alignment: teksilo_tokens::Alignment) {
2232        if let Some(node) = self.get_mut(id) {
2233            node.alignment_override = Some(alignment);
2234        }
2235    }
2236
2237    /// Mark a widget as clipping its children (scroll area, overflow hidden).
2238    pub fn set_clips_children(&mut self, id: WidgetId, clips: bool) {
2239        if let Some(node) = self.get_mut(id) {
2240            node.clips_children = clips;
2241        }
2242    }
2243
2244    /// The OS-IME descriptor for the widget at `id`, or `None` if the node
2245    /// is not a text-input surface (the default) or the id is unknown. The
2246    /// platform IME layer queries this for the focused widget to decide
2247    /// whether to enable the OS input method and with which purpose.
2248    pub fn ime_context(&self, id: WidgetId) -> Option<crate::ime::ImeContext> {
2249        self.get(id).and_then(|n| n.ime)
2250    }
2251
2252    /// Set (or clear, with `None`) the OS-IME descriptor for the widget at
2253    /// `id`.
2254    pub fn set_ime_context(&mut self, id: WidgetId, ime: Option<crate::ime::ImeContext>) {
2255        if let Some(node) = self.get_mut(id) {
2256            node.ime = ime;
2257        }
2258    }
2259
2260    /// Apply a `HandlerSet` to an existing node, merging handlers and
2261    /// transferring node-level metadata (focusable, cursor, clips,
2262    /// context menu). The `scope` argument controls whether the
2263    /// handlers go into the rebuild-cleared `handlers` slot or the
2264    /// persistent `external_handlers` slot.
2265    pub(crate) fn apply_handler_set(
2266        &mut self,
2267        id: WidgetId,
2268        handler_set: crate::widget_builder::HandlerSet,
2269        scope: HandlerScope,
2270    ) {
2271        if handler_set.focus_region.is_some() {
2272            self.focus_regions_seen = true;
2273        }
2274        if let Some(node) = self.get_mut(id) {
2275            let target = match scope {
2276                HandlerScope::Own => &mut node.handlers,
2277                HandlerScope::External => &mut node.external_handlers,
2278            };
2279            let existing = std::mem::take(target);
2280            *target = existing.merge(handler_set.handlers);
2281            if let Some(focusable) = handler_set.focusable {
2282                node.node_focusable = Some(focusable);
2283            }
2284            if let Some(tab_index) = handler_set.tab_index {
2285                node.node_tab_index = Some(tab_index);
2286            }
2287            if let Some(cursor) = handler_set.cursor {
2288                node.node_cursor = Some(cursor);
2289            }
2290            if let Some(clips) = handler_set.clips_children {
2291                node.clips_children = clips;
2292            }
2293            if let Some(ime) = handler_set.ime {
2294                node.ime = Some(ime);
2295            }
2296            if let Some(pass_through) = handler_set.event_pass_through {
2297                node.event_pass_through = pass_through;
2298            }
2299            if let Some(dead_zone) = handler_set.gesture_dead_zone {
2300                node.gesture_dead_zone = dead_zone;
2301            }
2302            if let Some(chrome) = handler_set.window_chrome {
2303                node.window_chrome = chrome;
2304            }
2305            if let Some(chrome) = handler_set.scroll_chrome {
2306                node.scroll_chrome = chrome;
2307            }
2308            if let Some(focus_on_click) = handler_set.focus_on_click {
2309                node.focus_on_click = focus_on_click;
2310            }
2311            if let Some(role) = handler_set.long_press_role {
2312                node.long_press_role = role;
2313            }
2314            if let Some(action) = handler_set.touch_action {
2315                node.touch_action = action;
2316            }
2317            if let Some(claim) = handler_set.pan_claim {
2318                node.pan_claim = Some(claim);
2319            }
2320            if let Some(behavior) = handler_set.overscroll_behavior {
2321                node.overscroll_behavior = behavior;
2322            }
2323            if let Some(activation) = handler_set.drag_activation {
2324                node.drag_activation = activation;
2325            }
2326            if let Some(policy) = handler_set.multi_contact {
2327                node.multi_contact = policy;
2328            }
2329            if let Some(keyboard_capture) = handler_set.keyboard_capture {
2330                node.keyboard_capture = keyboard_capture;
2331            }
2332            if let Some(name) = handler_set.focus_region {
2333                node.focus_region = Some(name);
2334            }
2335            if let Some(hit_transparent) = handler_set.hit_transparent {
2336                node.hit_transparent = hit_transparent;
2337            }
2338            if let Some(slop) = handler_set.hit_slop {
2339                node.hit_slop = Some(slop);
2340            }
2341            if let Some(no_slop) = handler_set.no_hit_slop {
2342                node.no_hit_slop = no_slop;
2343            }
2344            if handler_set.context_menu_factory.is_some() {
2345                node.context_menu_factory = handler_set.context_menu_factory;
2346            }
2347            if handler_set.context_menu_contribution.is_some() {
2348                node.context_menu_contribution = handler_set.context_menu_contribution;
2349            }
2350            if let Some(sig) = handler_set.focus_within {
2351                node.focus_within_signal = Some(sig);
2352            }
2353            if let Some(sig) = handler_set.hover_within {
2354                node.hover_within_signal = Some(sig);
2355            }
2356            // Mirror builder-level accessibility overrides + subtree mode
2357            // onto the persistent WidgetNode so the accessibility tree
2358            // walker (and the event dispatcher, for action callbacks) can
2359            // read them after handler extraction.
2360            if handler_set.access.is_some() {
2361                // Merged, not assigned: a node can already carry a
2362                // block from its builder chain, and replacing it
2363                // drops everything in it (see
2364                // `AccessibilityOverrides::merge_from`).
2365                match (&mut node.access_overrides, handler_set.access) {
2366                    (Some(existing), Some(incoming)) => existing.merge_from(*incoming),
2367                    (slot, incoming) => *slot = incoming,
2368                }
2369            }
2370            if let Some(mode) = handler_set.access_subtree {
2371                node.access_subtree = mode;
2372            }
2373        }
2374    }
2375
2376    /// Get a widget's alignment override, if any.
2377    pub fn alignment_override(&self, id: WidgetId) -> Option<teksilo_tokens::Alignment> {
2378        self.get(id)?.alignment_override
2379    }
2380
2381    /// Temporarily take the widget box out of a node (for rebuild).
2382    /// The node remains in the arena with a placeholder.
2383    pub fn take_widget(&mut self, id: WidgetId) -> Option<Box<dyn Widget>> {
2384        let node = self.nodes.get_mut(id)?;
2385        // Replace with a minimal placeholder
2386        let taken = std::mem::replace(&mut node.widget, Box::new(PlaceholderWidget));
2387        Some(taken)
2388    }
2389
2390    /// Restore a widget box that was previously taken out.
2391    pub fn restore_widget(&mut self, id: WidgetId, widget: Box<dyn Widget>) {
2392        if let Some(node) = self.nodes.get_mut(id) {
2393            node.widget = widget;
2394        }
2395    }
2396
2397    /// Walk up the parent chain from `id` and mark each ancestor as needing layout.
2398    /// Called when a relayout-level binding changes, since a child's size change
2399    /// may affect its parent's size, and so on up to the root.
2400    pub fn mark_ancestors_need_layout(&mut self, id: WidgetId) {
2401        let mut current = self.parent(id);
2402        while let Some(pid) = current {
2403            if let Some(node) = self.get_mut(pid) {
2404                node.dirty.needs_layout = true;
2405                node.dirty.needs_paint = true;
2406            }
2407            current = self.parent(pid);
2408        }
2409    }
2410
2411    /// Mark all widgets as needing layout and paint (e.g. after a theme change).
2412    /// Also clears per-widget paint caches since the visual output is stale.
2413    pub fn mark_all_dirty(&mut self) {
2414        for (_, node) in self.nodes.iter_mut() {
2415            node.dirty.needs_layout = true;
2416            node.dirty.needs_paint = true;
2417            node.cached_paint = None;
2418            node.cached_post_paint = None;
2419        }
2420    }
2421
2422    /// Mark every active node for repaint **without** touching layout, rebuild,
2423    /// or the per-widget paint caches. Used for a global visual change that
2424    /// leaves geometry untouched — the window's active-state flip (caret
2425    /// hiding, selection desaturation, `DimWhenInactive`). Lighter than
2426    /// [`Self::mark_all_dirty`]: the paint walker re-runs `paint()` for any
2427    /// node whose `needs_paint` is set and overwrites its cache, so there is no
2428    /// need to clear `cached_paint`; and skipping `needs_layout` avoids a
2429    /// pointless relayout pass. Dormant nodes are skipped — they don't paint,
2430    /// and they're re-marked on reactivation.
2431    pub fn mark_all_needs_paint_only(&mut self) {
2432        for (_, node) in self.nodes.iter_mut() {
2433            if node.activation == ActivationState::Active {
2434                node.dirty.needs_paint = true;
2435            }
2436        }
2437    }
2438
2439    /// Resolve the effective theme for a widget by walking ancestors and
2440    /// applying any theme overrides encountered along the way.
2441    /// The base theme is the tree-level default.
2442    pub fn resolve_theme<'a>(
2443        &self,
2444        id: WidgetId,
2445        base: &'a crate::styles::Theme,
2446    ) -> std::borrow::Cow<'a, crate::styles::Theme> {
2447        // Fast path: if no widget has a theme override, borrow the base
2448        // theme — no clone. This is the per-widget hot path during layout
2449        // and paint, so avoiding `Theme::clone()` (which clones the
2450        // typography token strings and bumps ~42 style-slot `Rc`s) here
2451        // saves that work on every node, every pass, in the common case.
2452        if self.theme_override_count == 0 {
2453            return std::borrow::Cow::Borrowed(base);
2454        }
2455
2456        // Collect ancestor chain from root to widget
2457        let mut chain = vec![id];
2458        let mut current = self.parent(id);
2459        while let Some(pid) = current {
2460            chain.push(pid);
2461            current = self.parent(pid);
2462        }
2463        chain.reverse(); // root first
2464
2465        let mut theme = base.clone();
2466        for nid in chain {
2467            if let Some(node) = self.nodes.get(nid)
2468                && let Some(ovr) = &node.theme_override
2469            {
2470                (ovr.func)(&mut theme);
2471            }
2472        }
2473        std::borrow::Cow::Owned(theme)
2474    }
2475}
2476
2477impl Default for WidgetArena {
2478    fn default() -> Self {
2479        Self::new()
2480    }
2481}
2482
2483#[cfg(test)]
2484mod tests {
2485    use super::*;
2486    use crate::test_widgets::FillWidget;
2487    use teksilo_canvas::SizeProposal;
2488
2489    fn key(w: Option<f32>, h: Option<f32>) -> ProposalKey {
2490        ProposalKey::from_proposal(SizeProposal {
2491            width: w,
2492            height: h,
2493        })
2494    }
2495
2496    #[test]
2497    fn activate_skips_a_child_gated_off_by_visible_state() {
2498        // Reactivating a subtree must not wake a child that its own widget
2499        // has gated off via `visible_when(false)` — e.g. a ComboBox's closed
2500        // dropdown panel, or a collapsed overlay. Regression for ghost
2501        // dropdown rows after a `visible_when` collapse→reappear cycle.
2502        let mut arena = WidgetArena::new();
2503        let parent = arena.insert(Box::new(FillWidget::new()));
2504        let visible_child = arena.insert_child(parent, Box::new(FillWidget::new()));
2505        let gated_child = arena.insert_child(parent, Box::new(FillWidget::new()));
2506        // The gated child is hidden by its own visibility gate.
2507        if let Some(node) = arena.get_mut(gated_child) {
2508            node.visible_state = Some(Prop::Static(false));
2509        }
2510
2511        arena.set_dormant(parent);
2512        assert!(!arena.is_active(gated_child));
2513
2514        arena.activate(parent);
2515        assert!(arena.is_active(parent), "the targeted node activates");
2516        assert!(
2517            arena.is_active(visible_child),
2518            "an ungated child activates with its parent"
2519        );
2520        assert!(
2521            !arena.is_active(gated_child),
2522            "a visible_when(false) child stays dormant when its parent reactivates"
2523        );
2524    }
2525
2526    #[test]
2527    fn activate_skips_a_child_parked_directly_by_set_dormant() {
2528        // The ungated twin of the test above, and the one that was missing.
2529        //
2530        // Widgets that pre-build hidden content register it as a child with
2531        // `ctx.add(..)` + `ctx.set_dormant(..)` and show it through an overlay:
2532        // `SplitButton` and `MenuBar` menus, `Popover`, `Snackbar`, the date
2533        // editors' calendars. Such a child carries no `visible_state`, so the
2534        // gate check alone let an ancestor's dormancy cycle wake it — and it
2535        // then rendered inline, with no overlay behind it, because the overlay
2536        // presentation never ran. Seen as export menu-item labels floating
2537        // under the title bar after leaving a mode that parked the shell.
2538        let mut arena = WidgetArena::new();
2539        let parent = arena.insert(Box::new(FillWidget::new()));
2540        let visible_child = arena.insert_child(parent, Box::new(FillWidget::new()));
2541        let menu = arena.insert_child(parent, Box::new(FillWidget::new()));
2542        let menu_row = arena.insert_child(menu, Box::new(FillWidget::new()));
2543
2544        // The widget parks its own closed menu — no gate involved.
2545        arena.set_dormant(menu);
2546        assert!(!arena.is_active(menu));
2547
2548        // An ancestor now goes dormant and comes back.
2549        arena.set_dormant(parent);
2550        arena.activate(parent);
2551
2552        assert!(arena.is_active(parent), "the targeted node activates");
2553        assert!(
2554            arena.is_active(visible_child),
2555            "an ordinary child activates with its parent"
2556        );
2557        assert!(
2558            !arena.is_active(menu),
2559            "the ancestor's dormancy cycle woke a menu that was closed before it \
2560             started — its content is now on screen with no overlay behind it"
2561        );
2562        assert!(
2563            !arena.is_active(menu_row),
2564            "the closed menu's own subtree woke with it"
2565        );
2566
2567        // …and opening it still works: activating by id is how the overlay
2568        // shows this content, so it must clear the self-parked mark.
2569        arena.activate(menu);
2570        assert!(arena.is_active(menu), "the menu can still be opened");
2571        assert!(arena.is_active(menu_row), "…along with its rows");
2572    }
2573
2574    #[test]
2575    fn a_reopened_menu_parks_again_and_survives_the_next_cycle() {
2576        // The flag must be re-armed by every `set_dormant`, not just the first:
2577        // open the menu, close it, then put an ancestor through another
2578        // dormancy cycle. Without re-arming, the second cycle leaks.
2579        let mut arena = WidgetArena::new();
2580        let parent = arena.insert(Box::new(FillWidget::new()));
2581        let menu = arena.insert_child(parent, Box::new(FillWidget::new()));
2582
2583        arena.set_dormant(menu);
2584        arena.activate(menu); // opened
2585        arena.set_dormant(menu); // dismissed
2586
2587        arena.set_dormant(parent);
2588        arena.activate(parent);
2589        assert!(
2590            !arena.is_active(menu),
2591            "a menu that was opened once no longer stays closed across a \
2592             dormancy cycle"
2593        );
2594    }
2595
2596    #[test]
2597    fn an_ancestor_cycle_does_not_strand_an_open_menu() {
2598        // The mirror risk of the fix: `park` marks only the node it is given,
2599        // so a menu that is *open* when an ancestor parks must come back with
2600        // that ancestor rather than being stranded closed.
2601        let mut arena = WidgetArena::new();
2602        let parent = arena.insert(Box::new(FillWidget::new()));
2603        let menu = arena.insert_child(parent, Box::new(FillWidget::new()));
2604
2605        arena.set_dormant(menu);
2606        arena.activate(menu); // open when the ancestor parks
2607
2608        arena.set_dormant(parent);
2609        arena.activate(parent);
2610        assert!(
2611            arena.is_active(menu),
2612            "an open menu was stranded closed by its ancestor's dormancy cycle"
2613        );
2614    }
2615
2616    #[test]
2617    fn proposal_key_distinguishes_none_from_zero() {
2618        // `None` (ask for ideal) must not collide with `Some(0.0)` (give zero).
2619        assert_ne!(key(None, None), key(Some(0.0), None));
2620        assert_ne!(key(Some(0.0), None), key(None, Some(0.0)));
2621    }
2622
2623    #[test]
2624    fn proposal_key_canonicalizes_signed_zero_and_nan() {
2625        assert_eq!(key(Some(-0.0), None), key(Some(0.0), None));
2626        assert_eq!(key(Some(f32::NAN), None), key(Some(f32::NAN), None));
2627    }
2628
2629    #[test]
2630    fn proposal_key_separates_distinct_values_and_axes() {
2631        assert_ne!(key(Some(1.0), None), key(Some(2.0), None));
2632        // Same scalar on different axes must not collide.
2633        assert_ne!(key(Some(10.0), None), key(None, Some(10.0)));
2634    }
2635
2636    #[test]
2637    fn insert_and_retrieve() {
2638        let mut arena = WidgetArena::new();
2639        let id = arena.insert(Box::new(FillWidget::new()));
2640        assert!(arena.get(id).is_some());
2641        assert_eq!(arena.len(), 1);
2642    }
2643
2644    #[test]
2645    fn new_widget_is_dirty() {
2646        let mut arena = WidgetArena::new();
2647        let id = arena.insert(Box::new(FillWidget::new()));
2648        let node = arena.get(id).unwrap();
2649        assert!(node.dirty.needs_layout);
2650        assert!(node.dirty.needs_paint);
2651    }
2652
2653    #[test]
2654    fn roots_returns_parentless_widgets() {
2655        let mut arena = WidgetArena::new();
2656        let root = arena.insert(Box::new(FillWidget::new()));
2657        let _child = arena.insert_child(root, Box::new(FillWidget::new()));
2658        let roots = arena.roots();
2659        assert_eq!(roots.len(), 1);
2660        assert_eq!(roots[0], root);
2661    }
2662
2663    #[test]
2664    fn content_transform_node_claims_viewport_in_parent_space() {
2665        // A content-transform node (the SceneView pattern) is a fixed
2666        // viewport: its bounds are tested in PARENT space and the transform
2667        // only positions its content, so the whole visible viewport stays
2668        // hittable regardless of the content pan/zoom. Before the fix, the
2669        // bounds were tested in content space, so a content pan shifted the
2670        // hittable region off the viewport.
2671        use teksilo_canvas::{Point, Rect, Transform2D};
2672        let mut arena = WidgetArena::new();
2673        let id = arena.insert(Box::new(FillWidget::new()));
2674        {
2675            let node = arena.get_mut(id).unwrap();
2676            node.bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
2677            node.clips_children = true;
2678            node.content_transform = true;
2679            // Content panned by (50, 30).
2680            node.transform_prop = Some(Prop::Static(Transform2D::translate(50.0, 30.0)));
2681        }
2682        // Points across the whole parent-space viewport hit, regardless of the
2683        // pan (these all missed before the fix).
2684        assert_eq!(arena.hit_test_at(Point::new(10.0, 10.0), None), Some(id));
2685        assert_eq!(arena.hit_test_at(Point::new(100.0, 50.0), None), Some(id));
2686        assert_eq!(arena.hit_test_at(Point::new(199.0, 99.0), None), Some(id));
2687        // Outside the viewport: miss.
2688        assert_eq!(arena.hit_test_at(Point::new(250.0, 50.0), None), None);
2689    }
2690
2691    #[test]
2692    fn self_transform_node_tests_bounds_in_local_space() {
2693        // Regression guard: a *self* transform wrapper (Scale / Rotate, NOT a
2694        // content transform) keeps the original semantics — its own bounds
2695        // move with the transform, so the point is inverse-transformed before
2696        // the bounds test. `clips_children` is irrelevant here (Scale clips
2697        // too); only `content_transform` selects the viewport path.
2698        use teksilo_canvas::{Point, Rect, Transform2D};
2699        let mut arena = WidgetArena::new();
2700        let id = arena.insert(Box::new(FillWidget::new()));
2701        {
2702            let node = arena.get_mut(id).unwrap();
2703            node.bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
2704            node.clips_children = true; // Scale clips, but is NOT content_transform.
2705            node.content_transform = false;
2706            // Visually scaled to 50x50 around the origin.
2707            node.transform_prop = Some(Prop::Static(Transform2D::scale(0.5, 0.5)));
2708        }
2709        // Inside the scaled-down 50x50 visual → hit.
2710        assert_eq!(arena.hit_test_at(Point::new(25.0, 25.0), None), Some(id));
2711        // Past the scaled-down visual (but inside the un-scaled 100x100 bounds
2712        // in parent space) → miss, because the bounds test is in local space.
2713        assert_eq!(arena.hit_test_at(Point::new(75.0, 75.0), None), None);
2714    }
2715
2716    #[test]
2717    fn nested_content_transform_nodes_each_claim_their_viewport() {
2718        // A content-transform node embedded inside another (the nested-
2719        // SceneView case): each level tests its own viewport bounds in its
2720        // parent's space, and only the transform is applied when descending.
2721        // The inner viewport stays hittable regardless of either node's pan.
2722        use teksilo_canvas::{Point, Rect, Transform2D};
2723        let mut arena = WidgetArena::new();
2724        let outer = arena.insert(Box::new(FillWidget::new()));
2725        let inner = arena.insert_child(outer, Box::new(FillWidget::new()));
2726        {
2727            let n = arena.get_mut(outer).unwrap();
2728            n.bounds = Rect::new(0.0, 0.0, 200.0, 200.0);
2729            n.clips_children = true;
2730            n.content_transform = true;
2731            n.transform_prop = Some(Prop::Static(Transform2D::translate(20.0, 20.0)));
2732        }
2733        {
2734            let n = arena.get_mut(inner).unwrap();
2735            // Inner viewport expressed in the OUTER's content space.
2736            n.bounds = Rect::new(10.0, 10.0, 50.0, 50.0);
2737            n.clips_children = true;
2738            n.content_transform = true;
2739            n.transform_prop = Some(Prop::Static(Transform2D::translate(5.0, 5.0)));
2740        }
2741        // Screen (40,40) → outer-content (20,20) ∈ inner viewport → reaches inner.
2742        assert_eq!(arena.hit_test_at(Point::new(40.0, 40.0), None), Some(inner));
2743        // Screen (5,5) → outer-content (-15,-15) ∉ inner viewport → reaches outer.
2744        assert_eq!(arena.hit_test_at(Point::new(5.0, 5.0), None), Some(outer));
2745    }
2746
2747    /// Accepts only the right half of its bounds via `hit_shape`; the left
2748    /// half is rejected so a click there falls through to a sibling beneath.
2749    #[derive(Debug)]
2750    struct RightHalfWidget;
2751
2752    impl crate::widget::Widget for RightHalfWidget {
2753        fn layout_response(
2754            &self,
2755            proposal: teksilo_canvas::SizeProposal,
2756            _ctx: &crate::widget::LayoutContext,
2757        ) -> crate::widget::LayoutResponse {
2758            proposal.resolve(0.0, 0.0).into()
2759        }
2760
2761        fn hit_shape(
2762            &self,
2763            local_point: teksilo_canvas::Point,
2764            bounds: teksilo_canvas::Rect,
2765        ) -> bool {
2766            local_point.x >= bounds.x + bounds.width / 2.0
2767        }
2768    }
2769
2770    #[test]
2771    fn hit_shape_rejection_falls_through_to_sibling_underneath() {
2772        // Two overlapping siblings under a common parent. `lower` is a
2773        // full-rect FillWidget; `upper` (inserted later → painted on top,
2774        // hit-tested first) rejects its left half via `hit_shape`. A click in
2775        // the rejected left half must reach `lower` underneath; a click in the
2776        // accepted right half must hit `upper`.
2777        use teksilo_canvas::{Point, Rect};
2778        let mut arena = WidgetArena::new();
2779        let parent = arena.insert(Box::new(FillWidget::new()));
2780        let lower = arena.insert_child(parent, Box::new(FillWidget::new()));
2781        let upper = arena.insert_child(parent, Box::new(RightHalfWidget));
2782        for id in [parent, lower, upper] {
2783            arena.get_mut(id).unwrap().bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
2784        }
2785        // Right half: upper accepts → hit upper.
2786        assert_eq!(arena.hit_test_at(Point::new(75.0, 50.0), None), Some(upper));
2787        // Left half: upper rejects via hit_shape → falls through to lower.
2788        assert_eq!(arena.hit_test_at(Point::new(25.0, 50.0), None), Some(lower));
2789    }
2790}