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