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::signal::{ObserverHandle, Prop, Signal};
10use crate::widget::{CursorIcon, Widget};
11use crate::widget_id::WidgetId;
12use teksilo_canvas::RenderFrame;
13
14/// Minimal placeholder widget used during composite rebuild and ID reservation.
15#[derive(Debug)]
16pub(crate) struct PlaceholderWidget;
17
18impl Widget for PlaceholderWidget {
19    fn layout_response(
20        &self,
21        _proposal: teksilo_canvas::SizeProposal,
22        _ctx: &crate::widget::LayoutContext,
23    ) -> crate::widget::LayoutResponse {
24        teksilo_canvas::Size::ZERO.into()
25    }
26}
27
28/// Activation state for a widget in the arena.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ActivationState {
31    Active,
32    Dormant,
33    Destroyed,
34}
35
36/// Where a `HandlerSet` should land on the node: handlers the widget
37/// attaches to itself (cleared on rebuild) vs handlers attached from
38/// outside (persist across rebuilds).
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub(crate) enum HandlerScope {
41    /// Handlers registered during the widget's own `build()` via
42    /// `BuildContext::apply_self_handlers`.
43    Own,
44    /// Handlers attached externally — at insertion time via
45    /// `WidgetBuilder::on_tap` et al., or by a composing parent's
46    /// `BuildContext::apply_handlers(child_id, ...)`.
47    External,
48}
49
50/// Dirty flags for a widget.
51#[derive(Debug, Clone, Copy, Default)]
52pub struct DirtyFlags {
53    pub needs_layout: bool,
54    pub needs_paint: bool,
55    /// When true, the widget's `build()` should be re-run to regenerate children.
56    /// Set by `BindingLevel::Rebuild` bindings (data-driven widgets).
57    pub needs_rebuild: bool,
58}
59
60/// A node in the widget arena storing a widget and its metadata.
61pub struct WidgetNode {
62    pub widget: Box<dyn Widget>,
63    pub parent: Option<WidgetId>,
64    pub children: Vec<WidgetId>,
65    pub activation: ActivationState,
66    /// Whether this node is dormant **on its own account** — parked by a
67    /// direct [`WidgetArena::set_dormant`] rather than swept along by an
68    /// ancestor going dormant.
69    ///
70    /// This is the ungated twin of `visible_state`, and [`WidgetArena::activate`]
71    /// honours the two identically: a self-parked child is left asleep when an
72    /// ancestor wakes, because the ancestor's dormancy was never why it was
73    /// asleep. Cleared the moment a caller activates this node *by id*, which is
74    /// exactly how pre-registered overlay content is shown.
75    ///
76    /// Without it, every widget that pre-builds hidden content as a child with
77    /// `ctx.add(..)` + `ctx.set_dormant(..)` — `SplitButton`'s dropdown,
78    /// `MenuBar`'s menus, `Popover`, `Snackbar`, the date editors' calendars —
79    /// spilled that content onto the screen as soon as any ancestor completed a
80    /// dormancy cycle, laid out inline with no overlay behind it.
81    pub(crate) self_dormant: bool,
82    pub dirty: DirtyFlags,
83    pub bounds: teksilo_canvas::Rect,
84    pub(crate) theme_override: Option<ThemeOverride>,
85    pub(crate) visible_state: Option<Prop<bool>>,
86    pub(crate) enabled_state: Option<Prop<bool>>,
87    /// Reactive Tab-key participation. When bound and evaluates to
88    /// `false`, the widget is excluded from Tab / Shift+Tab traversal
89    /// (`cycle_focus`) — but remains reachable via `request_focus`
90    /// and arrow-key navigation that calls `request_focus`. This
91    /// implements the ARIA roving-tabindex pattern (HTML
92    /// `tabindex="-1"` semantics). `None` means "always a Tab stop
93    /// when focusable" — the default. The selected `TabHeader` is the
94    /// canonical user.
95    pub(crate) tab_stop: Option<Prop<bool>>,
96    /// User-bound signal that the framework sets to `true` whenever
97    /// the focused widget is a strict descendant of this node, and
98    /// `false` otherwise. Used by `Panel` / `Card` / composite
99    /// widgets that want a unified focus halo without per-child
100    /// `on_focus` plumbing. See `WidgetBuilder::focus_within`.
101    pub(crate) focus_within_signal: Option<Signal<bool>>,
102    /// Framework-managed signal, lazily attached to a focusable node, set to
103    /// `true` whenever the focus is this node **or** a descendant (i.e. the node
104    /// is an *inclusive* ancestor of the focused widget). Unlike
105    /// `focus_within_signal` (strict descendants), this includes the node being
106    /// focused itself — so a data view that holds focus directly reads `true`.
107    /// Powers focus-aware selection (`BuildContext::view_focus_active`).
108    pub(crate) view_focus_signal: Option<Signal<bool>>,
109    /// User-bound signal that the framework sets to `true` whenever
110    /// the hovered widget is a strict descendant of this node.
111    /// Symmetric to `focus_within_signal`. See
112    /// `WidgetBuilder::hover_within`.
113    pub(crate) hover_within_signal: Option<Signal<bool>>,
114    /// User-bound signal that the framework sets to `true` while this
115    /// node is `ActivationState::Active` and `false` while it is
116    /// `Dormant`. Opted into via `BuildContext::activation_signal`.
117    /// Unlike every other widget — which is hidden automatically when
118    /// the paint pass skips a dormant subtree — a widget that owns a
119    /// resource living *outside* the wgpu pass (a native OS subview: a
120    /// `WebView` engine surface) has no other way to learn it was parked
121    /// dormant by a `Switcher` / `visible_when` gate, so it cannot hide
122    /// that resource. This signal is that notification. Set only on an
123    /// actual Active↔Dormant transition. See `set_dormant` / `activate`.
124    pub(crate) activation_signal: Option<Signal<bool>>,
125    /// Framework-written mirror of [`WidgetArena::is_enabled`] for this node —
126    /// the AND of its own `enabled_state` and every ancestor's. Opted into via
127    /// `BuildContext::effective_enabled_signal`.
128    ///
129    /// This has to be a *node-resident* signal that the framework refreshes,
130    /// rather than a signal derived by walking ancestors at call time, because
131    /// a widget's `parent` is still `None` while its own `build()` runs — the
132    /// parent link is wired only after `build()` returns (see
133    /// `WidgetTree::insert_widget`). A signal derived during `build()` would
134    /// therefore capture an empty ancestor chain and report only the widget's
135    /// own `enabled` prop, forever. Refreshed in
136    /// `WidgetTree::flush_effective_enabled_signals`.
137    pub(crate) effective_enabled_signal: Option<Signal<bool>>,
138    pub(crate) alignment_override: Option<teksilo_tokens::Alignment>,
139    /// When true, the paint pass clips child rendering to this widget's bounds.
140    /// Set by scroll areas and overflow-hidden containers.
141    pub clips_children: bool,
142    /// Optional OS input-method (IME) descriptor. `Some(..)` declares this
143    /// node a text-input surface — the platform enables the OS IME (with the
144    /// descriptor's purpose) while the node is focused. `None` (the default)
145    /// means no OS IME: enabling IME changes how text arrives, so the safe
146    /// common-case default is off. The platform reads the focused node's
147    /// descriptor at focus-change time. See [`crate::ime`].
148    pub ime: Option<crate::ime::ImeContext>,
149    /// When true, hit-testing skips this node — pointer events fall
150    /// through to whatever sits behind it. Descendants are still
151    /// hit-tested normally (the recursion walks into children before
152    /// the pass-through check), so an interactive subtree under a
153    /// pass-through wrapper stays usable. Used by the debug inspector's
154    /// `HighlightLayer` and `HoverProbe` to paint over the user's
155    /// content without absorbing clicks. Default `false`.
156    pub event_pass_through: bool,
157    /// When `true`, a pointer press anywhere in this widget's subtree must
158    /// NOT arm a drag/swipe recognizer on any ancestor **above** this node —
159    /// the subtree is a *gesture dead zone* for ancestor gestures. Used so
160    /// interactive controls (buttons, a `⋮` menu) placed inside a draggable /
161    /// swipeable container (a dock-panel header, a card, a list row) can be
162    /// clicked without a few px of pointer jitter starting the ancestor's drag.
163    /// The boundary is honored by `arm_drag_observers`. Mirrors Electron's
164    /// `-webkit-app-region: no-drag`. Default `false`. See the `DeadZone`
165    /// wrapper widget.
166    pub gesture_dead_zone: bool,
167    /// When `true` and this widget holds keyboard focus, a `KeyDown` is
168    /// delivered straight to it **without** first running shortcut →
169    /// intent → action resolution. The node is a *keyboard capture*
170    /// surface: it wants every keystroke (including chords the host app
171    /// binds as `Shortcut`s — `Ctrl+C`, `Ctrl+W`, `Alt+<letter>`, …).
172    /// Used by a terminal emulator (which must forward `Ctrl+C` to the
173    /// child process, not trigger the app's copy shortcut), a game
174    /// viewport, or a vim-mode editor. Honored by `dispatch_event_impl`,
175    /// which skips the shortcut block for a focused capture node.
176    ///
177    /// **`Ctrl+Tab` / `Ctrl+Shift+Tab` are reserved**: `dispatch_event_impl`
178    /// cycles focus on that chord before dispatching to a focused capture
179    /// node, so no capture surface can trap the keyboard (WCAG 2.1.2).
180    /// Escape is not reserved — overlay back-navigation runs ahead of the
181    /// check only while an overlay is open, so a capture surface below no
182    /// overlay does see Escape. Default `false`.
183    pub keyboard_capture: bool,
184    /// When `true`, this widget AND its entire subtree are invisible to
185    /// hit-testing: the recursion returns immediately without descending
186    /// into children, so the point falls through to whatever sits
187    /// behind. Unlike [`event_pass_through`](Self::event_pass_through)
188    /// (which is per-node — descendants stay hittable), this excludes
189    /// the whole subtree. Use for purely decorative overlays whose
190    /// children are themselves widgets — a count badge over a button, a
191    /// watermark, a status dot — so they never steal clicks meant for
192    /// the control underneath. Default `false`.
193    pub hit_transparent: bool,
194    /// Optional opacity multiplier (0..1) applied to this widget's
195    /// entire subtree during paint. The render walker emits
196    /// `SetOpacity(value)` before walking the widget's own paint and
197    /// children, then `RestoreOpacity` afterwards — so the multiplier
198    /// composes with ancestor opacity scopes via the canvas's
199    /// already-stacked opacity model. Bound at `Repaint` level: opacity
200    /// changes never trigger relayout. `None` means "no opacity scope"
201    /// (the default for almost every widget). The `Fade` widget sets
202    /// this on its own node to drive an animated visibility tween.
203    pub(crate) opacity_prop: Option<Prop<f32>>,
204    /// Optional 2D affine transform applied to this widget's entire
205    /// subtree during paint. The render walker emits
206    /// `PushTransform(value)` before walking the widget's own paint
207    /// and children, then `PopTransform` afterwards — the renderer
208    /// composes it onto its transform stack so nested wrappers and
209    /// widget-internal canvas transforms compose correctly. Bound at
210    /// `Repaint` level by default (visual-only); a wrapper that wants
211    /// the transform to drive layout (e.g. `Scale::reflow(true)`)
212    /// must additionally bind its driver signal at `Relayout`.
213    /// `None` means "no transform scope" (the default for almost every
214    /// widget). The `Scale` and `Rotate` widgets set this on their own
215    /// node.
216    pub(crate) transform_prop: Option<Prop<teksilo_canvas::Transform2D>>,
217    /// Whether [`transform_prop`](Self::transform_prop) transforms this node's
218    /// **content** within a fixed parent-space viewport (`true`), versus
219    /// transforming the **node itself** (`false`, the default).
220    ///
221    /// `Scale` / `Rotate` are *self* transforms: the node's own bounds move
222    /// with the transform, so hit-testing inverse-applies the transform before
223    /// the bounds test (a click lands where the scaled/rotated visual is).
224    ///
225    /// `SceneView` is a *content* transform: its bounds are a fixed screen
226    /// viewport and the pan/zoom only moves its content, so hit-testing must
227    /// test the bounds in parent space (keeping the whole visible viewport
228    /// interactive at any pan) and apply the transform only when descending
229    /// into children. Set via `BuildContext::set_content_transform`.
230    pub(crate) content_transform: bool,
231    /// Optional Gaussian-equivalent blur radius applied to this widget's
232    /// entire subtree during paint. The render walker emits
233    /// `BeginBlurredSubtree { bounds, radius }` before walking the
234    /// widget's own paint and children, then `EndBlurredSubtree`
235    /// afterwards — the renderer redirects drawing into an intermediate
236    /// texture, runs a dual-Kawase blur chain at the requested radius,
237    /// and composites the blurred result back into the parent pass.
238    /// Bound at `Repaint` level: blur radius changes never trigger
239    /// relayout. `None` (or `Some(radius < 0.5)`) means "no blur scope"
240    /// — the walker skips the Begin/End pair entirely so disabled blur
241    /// has zero per-frame cost. The `Blur` widget sets this on its own
242    /// node.
243    pub(crate) blur_prop: Option<Prop<f32>>,
244    /// Cached paint output for this widget (excludes children).
245    /// Reused when `needs_paint` is false to avoid re-running `paint()`.
246    pub(crate) cached_paint: Option<RenderFrame>,
247    /// Cached foreground output for widgets that override
248    /// [`Widget::post_paint`] — the
249    /// draws emitted *after* this widget's children. Separate frame from
250    /// `cached_paint` because it lands at a different position in
251    /// `draw_order` (after the child subtree). Reused on the same
252    /// `needs_paint` gate.
253    pub(crate) cached_post_paint: Option<RenderFrame>,
254    /// The ambient raster scale `cached_paint` / `cached_post_paint`
255    /// were baked at (the paint walker's accumulated transform scale,
256    /// quantized). Glyph quads in those frames reference bitmaps of
257    /// that density; when the walker's current scale differs (a scene
258    /// zoom crossed a quantization bucket), the cached frames are
259    /// treated as `needs_paint` even though the widget itself is clean.
260    pub(crate) paint_raster_scale: f32,
261    /// The `WidgetTree::paint_epoch` at which this widget's bounds were
262    /// last observed inside the window viewport by the paint pass.
263    /// The animation scheduler uses this to pause looping animations
264    /// for offscreen widgets: an animation whose
265    /// `last_painted_epoch + 1 < tree.paint_epoch` is considered
266    /// off-screen and skipped. `0` means "not yet painted" — treated
267    /// as "always visible" to keep headless tests (no `render()` call)
268    /// from regressing.
269    pub last_painted_epoch: u64,
270
271    // --- V2 fields ---
272    /// Event handlers the widget attached to itself during its own
273    /// `build()` via `BuildContext::apply_self_handlers`. Cleared on
274    /// rebuild so accumulating `apply_self_handlers` calls across
275    /// rebuilds don't stack N-fold handler chains.
276    pub(crate) handlers: EventHandlers,
277    /// Event handlers attached *externally* — either via the
278    /// `WidgetBuilder` chain at the widget's creation site
279    /// (`SomeWidget::new().on_tap(...)`) or by a parent's
280    /// `BuildContext::apply_handlers(child_id, ...)`. These survive
281    /// rebuilds: the widget didn't register them and shouldn't decide
282    /// when they go away.
283    pub(crate) external_handlers: EventHandlers,
284    /// Focusable override set via HandlerSet. Takes precedence over widget.is_focusable().
285    pub(crate) node_focusable: Option<bool>,
286    /// Tab index override set via HandlerSet.
287    pub(crate) node_tab_index: Option<i32>,
288    /// Traversal-scope marker. When `Some(policy)`, `cycle_focus` treats this
289    /// node's subtree as an independent Tab group: `tab_index` numbering is
290    /// scoped to its descendants (so sibling scopes never interleave) and
291    /// `policy` governs what Tab does at the scope's ends. `None` (default)
292    /// means the node is transparent to traversal scoping. Set by the
293    /// `FocusScope` wrapper via `BuildContext::set_traversal_scope`. A node
294    /// carrying this marker is forced non-focusable (it is a boundary, never a
295    /// Tab stop). See [`crate::focus::TraversalScopePolicy`].
296    pub(crate) node_traversal_scope: Option<crate::focus::TraversalScopePolicy>,
297    /// Cursor override set via HandlerSet.
298    pub(crate) node_cursor: Option<CursorIcon>,
299    /// RAII observer handles for effects registered during build().
300    /// Dropped on rebuild or widget destruction.
301    pub(crate) effect_handles: Vec<ObserverHandle>,
302    /// Backend-event subscriptions registered during build() via
303    /// `BuildContext::subscribe_event`. Each entry pairs a subscription id
304    /// (used to remove the UI-side callback from `TreeAppContext`) with the
305    /// opaque source-side handle whose `Drop` removes the subscriber from
306    /// the source's internal registry.
307    pub(crate) subscription_handles: Vec<(SubscriptionId, SubscriptionHandle)>,
308    /// Parentless nodes this widget created during `build()` and still owns —
309    /// pre-built overlay content (a menu, a calendar, a tooltip's nested
310    /// cascade children) that is deliberately *not* a child.
311    ///
312    /// Such content cannot be a child: activation and the paint walk both
313    /// descend through `children`, so a dormant popup parked there wakes with
314    /// its host and paints inline at zero size. Keeping it parentless fixes
315    /// that and creates the opposite problem — no teardown walk reaches it, so
316    /// every rebuild of the host strands another copy in the arena for the
317    /// lifetime of the process. This list is the missing ownership edge:
318    /// [`WidgetTree::destroy_subtree`](crate::widget_tree::WidgetTree) reaps it
319    /// with the owner, and a rebuild reaps the previous generation. Recorded
320    /// via `BuildContext::add_detached`.
321    pub(crate) detached: Vec<WidgetId>,
322    /// Context menu factory — invoked on right-click to produce overlay content.
323    pub(crate) context_menu_factory: Option<crate::widget_builder::ContextMenuFactory>,
324    /// Intent-bound actions attached by this widget during `build()`.
325    /// Consulted during intent dispatch (source-widget → root walk).
326    /// Cleared on rebuild in the same pass that clears handlers.
327    pub(crate) actions: Vec<crate::action::Action>,
328    /// Builder-level accessibility overrides (`access_label`,
329    /// `access_role`, etc.). Mirrored from the wrapper's `HandlerSet`
330    /// at insertion via `apply_handler_set`. Applied by the
331    /// accessibility tree walker after the inner widget's
332    /// `accessibility(&self, builder)` runs. Action callbacks
333    /// (`actions`, `custom_actions` inside this struct) are dispatched
334    /// by `event_dispatch_impl.rs` when handling
335    /// `WidgetEvent::AccessAction`.
336    pub(crate) access_overrides: Option<Box<crate::widget_builder::AccessibilityOverrides>>,
337    /// Subtree visibility / merge mode (`access_exclude_subtree` /
338    /// `access_merge_subtree`). Mirrored from the wrapper's
339    /// `HandlerSet`.
340    pub(crate) access_subtree: crate::widget_builder::AccessSubtreeMode,
341}
342
343impl std::fmt::Debug for WidgetNode {
344    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345        f.debug_struct("WidgetNode")
346            .field("widget", &self.widget)
347            .field("parent", &self.parent)
348            .field("children", &self.children)
349            .field("activation", &self.activation)
350            .field("dirty", &self.dirty)
351            .field("bounds", &self.bounds)
352            .field("has_gesture_arena", &self.handlers.gesture_arena.is_some())
353            .field("has_theme_override", &self.theme_override.is_some())
354            .field("has_visible_state", &self.visible_state.is_some())
355            .field("has_enabled_state", &self.enabled_state.is_some())
356            .finish()
357    }
358}
359
360impl WidgetNode {
361    /// Construct a fresh node wrapping `widget`, parented at `parent`
362    /// (`None` for a root). All other fields take their insertion defaults;
363    /// the caller wires up `children` / parent back-links afterward.
364    pub(crate) fn new(widget: Box<dyn Widget>, parent: Option<WidgetId>) -> Self {
365        WidgetNode {
366            widget,
367            parent,
368            children: Vec::new(),
369            activation: ActivationState::Active,
370            self_dormant: false,
371            dirty: DirtyFlags {
372                needs_layout: true,
373                needs_paint: true,
374                needs_rebuild: false,
375            },
376            bounds: teksilo_canvas::Rect::ZERO,
377            theme_override: None,
378            visible_state: None,
379            enabled_state: None,
380            tab_stop: None,
381            focus_within_signal: None,
382            view_focus_signal: None,
383            hover_within_signal: None,
384            activation_signal: None,
385            effective_enabled_signal: None,
386            alignment_override: None,
387            clips_children: false,
388            ime: None,
389            event_pass_through: false,
390            gesture_dead_zone: false,
391            keyboard_capture: false,
392            hit_transparent: false,
393            opacity_prop: None,
394            transform_prop: None,
395            content_transform: false,
396            blur_prop: None,
397            cached_paint: None,
398            cached_post_paint: None,
399            paint_raster_scale: 1.0,
400            last_painted_epoch: 0,
401            handlers: EventHandlers::new(),
402            external_handlers: EventHandlers::new(),
403            node_focusable: None,
404            node_tab_index: None,
405            node_traversal_scope: None,
406            node_cursor: None,
407            effect_handles: Vec::new(),
408            subscription_handles: Vec::new(),
409            detached: Vec::new(),
410            context_menu_factory: None,
411            actions: Vec::new(),
412            access_overrides: None,
413            access_subtree: crate::widget_builder::AccessSubtreeMode::default(),
414        }
415    }
416
417    /// Does EITHER handler slot (own or external) have a handler of the
418    /// requested kind? Use this when deciding whether to build a gesture
419    /// arena, mark the node as a drop target, etc.
420    pub(crate) fn any_handler<F>(&self, f: F) -> bool
421    where
422        F: Fn(&EventHandlers) -> bool,
423    {
424        f(&self.handlers) || f(&self.external_handlers)
425    }
426}
427
428/// Flat arena storage for all widgets, using SlotMap for O(1) access.
429pub struct WidgetArena {
430    nodes: SlotMap<WidgetId, WidgetNode>,
431    /// Number of nodes with theme overrides. When zero, resolve_theme is O(1).
432    pub(crate) theme_override_count: usize,
433    /// Cached root widget IDs (widgets with no parent).
434    cached_roots: Vec<WidgetId>,
435    /// Whether the cached_roots list needs rebuilding.
436    roots_dirty: bool,
437    /// Per-pass memoization of `Widget::layout_response`, keyed by
438    /// `(WidgetId, ProposalKey)`. Cleared once at the start of every layout
439    /// pass (see `clear_layout_cache`). Height-for-width negotiation queries
440    /// each child along the main axis and again along the cross axis, so
441    /// without this the cost compounds super-linearly with nesting depth;
442    /// with it, each `(id, proposal)` is computed at most once per pass.
443    /// `RefCell` because layout runs through shared `&WidgetArena` borrows.
444    layout_cache: std::cell::RefCell<
445        std::collections::HashMap<(WidgetId, ProposalKey), crate::widget::LayoutResponse>,
446    >,
447    /// True while [`measure_intrinsic`](Self::measure_intrinsic) is running.
448    /// In this mode `cached_layout_response` measures even dormant widgets
449    /// (and their dormant subtrees) and bypasses the cache, so an adaptive
450    /// container can size an item it intends to keep hidden without that size
451    /// leaking into the normal per-pass cache.
452    measuring: std::cell::Cell<bool>,
453    /// Active↔Dormant transitions of nodes carrying an `activation_signal`,
454    /// recorded by [`set_dormant`](Self::set_dormant) / [`activate`](Self::activate)
455    /// and drained by `WidgetTree::flush_activation_signals` *after* the
456    /// mutation completes. Signals are fired at the tree level, never from
457    /// inside the arena recursion — mirroring how `focus_within` /
458    /// `hover_within` are updated from `WidgetTree` methods rather than mid
459    /// mutation, so an observer (e.g. a `WebView`'s `set_visible`, which on a
460    /// real backend is an OS call) never runs while the arena is being walked.
461    /// Only nodes with a signal contribute, so the buffer is empty for the
462    /// overwhelming majority of trees.
463    pending_activation_changes: Vec<(WidgetId, bool)>,
464    /// Every node that installed an `effective_enabled_signal`, so the
465    /// per-pass refresh visits only opted-in nodes instead of the whole arena.
466    /// Unlike `pending_activation_changes` this is NOT a change queue: an
467    /// ancestor's `enabled` prop is a `Signal` that can flip at any time
468    /// without the arena being told, so there is no single mutation site to
469    /// record a transition at. The refresh recomputes and diffs instead —
470    /// see `WidgetTree::flush_effective_enabled_signals`. Dead ids are pruned
471    /// there, so a destroyed widget cannot leak.
472    effective_enabled_watchers: Vec<WidgetId>,
473}
474
475/// Hashable key for a [`teksilo_canvas::SizeProposal`] used by the per-pass
476/// layout cache. Each axis is encoded to a `u64`: `None` → a sentinel
477/// distinct from any finite `f32`, `Some(v)` → the canonicalized `f32` bits
478/// (`-0.0` folded to `0.0`, all NaNs folded to one pattern) so two equal
479/// proposals always hash and compare equal.
480#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
481struct ProposalKey([u64; 2]);
482
483impl ProposalKey {
484    fn from_proposal(p: teksilo_canvas::SizeProposal) -> Self {
485        fn axis_bits(v: Option<f32>) -> u64 {
486            match v {
487                // `f32::to_bits()` widens into 0..=u32::MAX, so u64::MAX is a
488                // safe sentinel that no `Some(_)` can collide with.
489                None => u64::MAX,
490                Some(f) => {
491                    let canon = if f == 0.0 {
492                        0.0
493                    } else if f.is_nan() {
494                        f32::NAN
495                    } else {
496                        f
497                    };
498                    canon.to_bits() as u64
499                }
500            }
501        }
502        Self([axis_bits(p.width), axis_bits(p.height)])
503    }
504}
505
506impl WidgetArena {
507    pub fn new() -> Self {
508        Self {
509            nodes: SlotMap::with_key(),
510            theme_override_count: 0,
511            cached_roots: Vec::new(),
512            roots_dirty: true,
513            layout_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
514            measuring: std::cell::Cell::new(false),
515            pending_activation_changes: Vec::new(),
516            effective_enabled_watchers: Vec::new(),
517        }
518    }
519
520    /// Clear the per-pass layout memoization cache. Called once at the start of
521    /// each layout pass — geometry (and therefore `layout_response` results)
522    /// may change between passes, so the cache is valid only within one pass.
523    pub(crate) fn clear_layout_cache(&self) {
524        self.layout_cache.borrow_mut().clear();
525    }
526
527    /// Compute a widget's layout response, memoized per `(id, proposal)` for
528    /// the current layout pass. Returns `None` if the id is missing or
529    /// dormant. Widgets that opt out via `Widget::cacheable_layout() == false`
530    /// (e.g. the inspector's bounds tracker, which deliberately mutates signals
531    /// in `layout_response`) bypass the cache so their side effect fires on
532    /// every call.
533    ///
534    /// The key is `(id, proposal)` only: `layout_response` also reads the
535    /// `LayoutContext` (resolved theme, layout direction, text backend), but
536    /// those are a stable function of `id` within a single pass, so the pair
537    /// uniquely determines the input.
538    pub(crate) fn cached_layout_response(
539        &self,
540        id: WidgetId,
541        proposal: teksilo_canvas::SizeProposal,
542        ctx: &crate::widget::LayoutContext,
543    ) -> Option<crate::widget::LayoutResponse> {
544        let node = self.nodes.get(id)?;
545        let measuring = self.measuring.get();
546        if node.activation != ActivationState::Active && !measuring {
547            return None;
548        }
549        // While measuring intrinsic sizes (incl. of dormant subtrees), bypass
550        // the cache entirely so a dormant widget's size never pollutes the
551        // normal per-pass cache.
552        if measuring || !node.widget.cacheable_layout() {
553            return Some(node.widget.layout_response(proposal, ctx));
554        }
555        let key = (id, ProposalKey::from_proposal(proposal));
556        // Scope the shared borrow so it is released before `layout_response`
557        // runs — that call recurses into children, which borrow the same
558        // `layout_cache` (read, then write) and would otherwise alias.
559        {
560            if let Some(cached) = self.layout_cache.borrow().get(&key) {
561                return Some(*cached);
562            }
563        }
564        let resp = node.widget.layout_response(proposal, ctx);
565        self.layout_cache.borrow_mut().insert(key, resp);
566        Some(resp)
567    }
568
569    /// Measure a widget's intrinsic `layout_response` size for `proposal`,
570    /// **regardless of activation** — including dormant/collapsed widgets and
571    /// their dormant subtrees. Returns `None` only if the id is absent.
572    ///
573    /// Adaptive containers (e.g. an overflow [`Toolbar`](crate) that collapses
574    /// items into a menu) use this to size an item they intend to keep hidden,
575    /// so they can decide when to show it again as space grows — something
576    /// `child_layout_response` cannot do, since it returns `None` for inactive
577    /// widgets.
578    ///
579    /// Runs uncached (a dormant widget's size never enters the per-pass cache)
580    /// and is re-entrant-safe (saves/restores the measuring flag). Calls
581    /// `layout_response`, which must be idempotent (see
582    /// [`Widget::cacheable_layout`]).
583    pub(crate) fn measure_intrinsic(
584        &self,
585        id: WidgetId,
586        proposal: teksilo_canvas::SizeProposal,
587        ctx: &crate::widget::LayoutContext,
588    ) -> Option<teksilo_canvas::Size> {
589        if !self.nodes.contains_key(id) {
590            return None;
591        }
592        let prev = self.measuring.replace(true);
593        // `cached_layout_response` (and every nested child query during this
594        // call) sees `measuring == true`, so it bypasses the active check and
595        // the cache for the whole subtree.
596        let resp = self.cached_layout_response(id, proposal, ctx);
597        self.measuring.set(prev);
598        resp.map(|r| r.size)
599    }
600
601    /// Insert a widget into the arena as a root-level widget.
602    pub fn insert(&mut self, widget: Box<dyn Widget>) -> WidgetId {
603        self.roots_dirty = true;
604        let children = widget.children();
605        let id = self.nodes.insert(WidgetNode::new(widget, None));
606        // Set up parent-child for declared children
607        for &child_id in &children {
608            if let Some(child_node) = self.nodes.get_mut(child_id) {
609                child_node.parent = Some(id);
610            }
611        }
612        if let Some(node) = self.nodes.get_mut(id) {
613            node.children = children;
614        }
615        id
616    }
617
618    /// Insert a widget as a child of the given parent.
619    pub fn insert_child(&mut self, parent: WidgetId, widget: Box<dyn Widget>) -> WidgetId {
620        assert!(
621            self.nodes.contains_key(parent),
622            "insert_child() called with invalid parent WidgetId {parent:?}"
623        );
624        self.roots_dirty = true;
625        let children = widget.children();
626        let id = self.nodes.insert(WidgetNode::new(widget, Some(parent)));
627        // Set up parent-child for declared children
628        for &child_id in &children {
629            if let Some(child_node) = self.nodes.get_mut(child_id) {
630                child_node.parent = Some(id);
631            }
632        }
633        if let Some(node) = self.nodes.get_mut(id) {
634            node.children = children;
635        }
636        if let Some(parent_node) = self.nodes.get_mut(parent) {
637            parent_node.children.push(id);
638        }
639        id
640    }
641
642    pub fn get(&self, id: WidgetId) -> Option<&WidgetNode> {
643        self.nodes.get(id)
644    }
645
646    pub fn get_mut(&mut self, id: WidgetId) -> Option<&mut WidgetNode> {
647        self.nodes.get_mut(id)
648    }
649
650    pub fn children(&self, id: WidgetId) -> &[WidgetId] {
651        self.nodes
652            .get(id)
653            .map(|n| n.children.as_slice())
654            .unwrap_or(&[])
655    }
656
657    pub fn parent(&self, id: WidgetId) -> Option<WidgetId> {
658        self.nodes.get(id).and_then(|n| n.parent)
659    }
660
661    pub fn bounds(&self, id: WidgetId) -> teksilo_canvas::Rect {
662        self.nodes
663            .get(id)
664            .map(|n| n.bounds)
665            .unwrap_or(teksilo_canvas::Rect::ZERO)
666    }
667
668    /// The accumulated 2D affine transform that maps `id`'s pre-transform
669    /// local-space points to screen space — equivalent to the renderer's
670    /// `transform_stack` top by the time it begins painting `id`. Used by
671    /// hit-testing and any consumer that needs to project a node's
672    /// pre-transform bounds into screen space (e.g. teksilo-scene's a11y
673    /// bounds projection of view-transformed scene items).
674    ///
675    /// **Composition order.** Mirrors `crates/teksilo-render/src/renderer.rs`'s
676    /// `PushTransform` handling: each push composes as
677    /// `new_top = device_t.then(prev_top)`, so the deepest (innermost)
678    /// transform is applied **first** to a local point and outer ancestors
679    /// compose afterward. Walking root→leaf, each ancestor's
680    /// `transform_prop` is folded in via `t.then(effective)` (NOT
681    /// `effective.then(t)`).
682    ///
683    /// Returns `Transform2D::IDENTITY` if no ancestor sets a non-identity
684    /// transform, which is the common case (90%+ of widgets).
685    pub fn effective_transform(&self, id: WidgetId) -> teksilo_canvas::Transform2D {
686        // Collect leaf→root, then iterate root→leaf. Composition is
687        // `t_new.then(effective_so_far)` so the outer ancestor is applied
688        // *after* the deeper push — matching the renderer's stack semantic
689        // (`device_t.then(prev_top)` at PushTransform).
690        let mut chain: Vec<WidgetId> = Vec::new();
691        let mut current = Some(id);
692        while let Some(c) = current {
693            chain.push(c);
694            current = self.parent(c);
695        }
696        let mut effective = teksilo_canvas::Transform2D::IDENTITY;
697        for node_id in chain.iter().rev() {
698            if let Some(node) = self.nodes.get(*node_id)
699                && let Some(p) = node.transform_prop.as_ref()
700            {
701                let t = p.get();
702                if !t.is_identity() {
703                    effective = t.then(&effective);
704                }
705            }
706        }
707        effective
708    }
709
710    /// Convert a **window-space** pointer position into the **widget-local**
711    /// coordinate space of `id`'s event handlers — i.e. relative to `id`'s
712    /// top-left, after undoing any transform scopes between the window and
713    /// `id`. This is the single conversion the dispatcher applies before
714    /// handing a position to `on_tap` / `on_drag` / `on_pointer_event`, so
715    /// every handler sees positions in its own local space.
716    ///
717    /// The transform handling mirrors `Self::hit_test_recursive` so the
718    /// position a handler receives is in the same space the hit-test used
719    /// to pick it:
720    /// * A **content** transform node (`content_transform`, e.g.
721    ///   `SceneView`) owns its transform and maps its content itself. The
722    ///   framework feeds such a node positions in its **parent-effective**
723    ///   space (the same space `hit_test_recursive` passes through
724    ///   `inv(transform)`), with **no** bounds-origin subtraction — the
725    ///   node's `view_transform` already accounts for its placement.
726    /// * Any other node (the 90%+ identity case, plus `Scale` / `Rotate`
727    ///   self-transforms) receives widget-local coordinates: undo the full
728    ///   transform chain including its own, then subtract its bounds
729    ///   origin so the result is relative to its top-left.
730    ///
731    /// In the common no-transform case this collapses to
732    /// `window_point - bounds.origin`.
733    pub fn local_pointer_position(
734        &self,
735        id: WidgetId,
736        window_point: teksilo_canvas::Point,
737    ) -> teksilo_canvas::Point {
738        let content_transform = self.get(id).map(|n| n.content_transform).unwrap_or(false);
739        if content_transform {
740            // Parent-effective space, no origin subtraction (the node's
741            // own transform consumes these coordinates).
742            let to_parent = self
743                .parent(id)
744                .map(|p| self.effective_transform(p))
745                .unwrap_or(teksilo_canvas::Transform2D::IDENTITY);
746            return match to_parent.inverse() {
747                Some(inv) => inv.apply_point(window_point),
748                None => window_point,
749            };
750        }
751        let in_local = match self.effective_transform(id).inverse() {
752            Some(inv) => inv.apply_point(window_point),
753            // Degenerate transform: fall back to the raw point rather than
754            // dropping the event.
755            None => window_point,
756        };
757        let bounds = self.bounds(id);
758        teksilo_canvas::Point::new(in_local.x - bounds.x, in_local.y - bounds.y)
759    }
760
761    /// Get all root-level widget IDs (widgets with no parent).
762    pub fn roots(&self) -> Vec<WidgetId> {
763        if self.roots_dirty {
764            // Fall back to scanning when cache is stale.
765            // refresh_roots() should be called from layout() for the fast path.
766            return self
767                .nodes
768                .iter()
769                .filter(|(_, node)| node.parent.is_none())
770                .map(|(id, _)| id)
771                .collect();
772        }
773        self.cached_roots.clone()
774    }
775
776    /// Refresh the cached roots list. Call once per frame from layout().
777    pub fn refresh_roots(&mut self) {
778        if self.roots_dirty {
779            self.cached_roots = self
780                .nodes
781                .iter()
782                .filter(|(_, node)| node.parent.is_none())
783                .map(|(id, _)| id)
784                .collect();
785            self.roots_dirty = false;
786        }
787    }
788
789    /// Walk the active widget tree at `point` and return the deepest
790    /// widget under it (the front-most hit, last child wins). Honors
791    /// `event_pass_through` (such nodes pass through to whatever sits
792    /// behind them but their descendants are still hit-testable). Does
793    /// not consider overlays — for the full pointer-routing hit-test
794    /// see `WidgetTree::hit_test`.
795    ///
796    /// `exclude`: if `Some(id)`, that widget (and any descendants
797    /// within its subtree) are skipped during the walk. Used by the
798    /// debug inspector's picker tool to ignore the picker overlay
799    /// itself, and by drag-and-drop to ignore the drag preview.
800    pub fn hit_test_at(
801        &self,
802        point: teksilo_canvas::Point,
803        exclude: Option<WidgetId>,
804    ) -> Option<WidgetId> {
805        for &root in self.roots().iter().rev() {
806            if let Some(hit) = self.hit_test_recursive(root, point, exclude) {
807                return Some(hit);
808            }
809        }
810        None
811    }
812
813    /// Hit-test starting from a specific subtree root rather than the
814    /// arena's top-level roots. Same semantics as
815    /// [`hit_test_at`](Self::hit_test_at) but scoped — useful when
816    /// callers want to ignore everything outside a known subtree
817    /// (e.g. the inspector's picker hit-tests inside the user-root
818    /// subtree so it never resolves to its own chrome).
819    pub fn hit_test_in_subtree(
820        &self,
821        start: WidgetId,
822        point: teksilo_canvas::Point,
823    ) -> Option<WidgetId> {
824        self.hit_test_recursive(start, point, None)
825    }
826
827    /// Like [`hit_test_in_subtree`](Self::hit_test_in_subtree) but also
828    /// excludes a widget (and its descendants) from the walk. Lets the
829    /// overlay / drag-and-drop hit-test reuse the single canonical recursion
830    /// in `hit_test_recursive` instead of duplicating it.
831    pub fn hit_test_in_subtree_excluding(
832        &self,
833        start: WidgetId,
834        point: teksilo_canvas::Point,
835        exclude: Option<WidgetId>,
836    ) -> Option<WidgetId> {
837        self.hit_test_recursive(start, point, exclude)
838    }
839
840    fn hit_test_recursive(
841        &self,
842        id: WidgetId,
843        point: teksilo_canvas::Point,
844        exclude: Option<WidgetId>,
845    ) -> Option<WidgetId> {
846        if !self.is_active(id) || Some(id) == exclude {
847            return None;
848        }
849        // Decorative subtree: skip this node and ALL its descendants so
850        // the point falls through to whatever is painted behind. Checked
851        // before descending into children (the difference from
852        // `event_pass_through`, which is applied only after the children
853        // miss).
854        if self.get(id).map(|n| n.hit_transparent).unwrap_or(false) {
855            return None;
856        }
857        // The input point arrives in this node's parent-effective space. A
858        // `set_transform` scope is composed by the render walker around this
859        // node's subtree, so hit-testing mirrors it by inverse-applying the
860        // transform once. *Which* rectangle the transform applies to depends
861        // on whether it's a **content** transform or a **self** transform
862        // (see `WidgetNode::content_transform`):
863        //
864        // * A **content** transform (`content_transform`, e.g. `SceneView`) is
865        //   a fixed viewport: its bounds are a rectangle in PARENT space and
866        //   the transform pans / zooms only its CONTENT. Test the bounds
867        //   against the parent-space point; inverse-transform only for
868        //   descending into children, so the whole visible viewport stays
869        //   interactive regardless of pan / zoom. (Without this, panning the
870        //   content shifts the hittable region off the viewport — clicks /
871        //   wheel over the visible scene fall through to whatever is behind.)
872        // * A **self** transform (`Scale` / `Rotate`, whose own bounds move
873        //   with the transform) inverse-transforms first, then tests its
874        //   bounds in the resulting local space (a click lands where the
875        //   scaled / rotated visual actually is).
876        //
877        // Identity / missing transforms collapse both paths to the scalar
878        // case, so the hot path stays cheap. `content_transform` is
879        // `SceneView`-only today, so this only changes SceneView hit-testing;
880        // `Scale` / `Rotate` (also `clips_children`) keep the self-transform
881        // path.
882        let transform = self
883            .get(id)
884            .and_then(|n| n.transform_prop.as_ref())
885            .map(|p| p.get())
886            .filter(|t| !t.is_identity());
887        let content_transform = self.get(id).map(|n| n.content_transform).unwrap_or(false);
888        // A degenerate transform (collapsed axis) hides the entire subtree
889        // visually; `inverse()` returning None mirrors that for hit-testing.
890        let child_point = match transform {
891            Some(t) => t.inverse()?.apply_point(point),
892            None => point,
893        };
894        // Content-transform nodes test their (parent-space) viewport against
895        // the incoming point; everything else tests in the inverse-transformed
896        // local space.
897        let bounds_point = if content_transform {
898            point
899        } else {
900            child_point
901        };
902        let bounds = self.bounds(id);
903        if !bounds.contains(bounds_point) {
904            return None;
905        }
906        // Shape rejection: a widget with a non-rectangular silhouette (an
907        // ellipse / cloud scene node, a circular handle) can reject a point
908        // that is inside its bounding box but outside its actual shape via
909        // `Widget::hit_shape`. Returning None here lets the caller's
910        // reverse-sibling loop fall through to whatever is painted
911        // underneath — the same path `event_pass_through` takes, but
912        // shape-aware (only the rejected sub-region falls through, not the
913        // whole widget). Default `hit_shape` returns true, so rectangular
914        // widgets take this branch for free with no behavior change.
915        if let Some(node) = self.get(id)
916            && !node.widget.hit_shape(bounds_point, bounds)
917        {
918            return None;
919        }
920        let pass_through = self.get(id).map(|n| n.event_pass_through).unwrap_or(false);
921        let children: Vec<WidgetId> = self.children(id).to_vec();
922        for &child in children.iter().rev() {
923            if let Some(hit) = self.hit_test_recursive(child, child_point, exclude) {
924                return Some(hit);
925            }
926        }
927        if pass_through {
928            return None;
929        }
930        Some(id)
931    }
932
933    /// Iterate over all active widget IDs.
934    ///
935    /// Allocating wrapper around [`Self::active_ids_iter`]. Hot-path
936    /// callers that hold `&self` for the whole iteration should call
937    /// the iterator directly to avoid the per-call `Vec` allocation;
938    /// callers that need an owned snapshot (because they mutate
939    /// arena state inside the loop) should use
940    /// [`Self::fill_active_ids`] with a reusable buffer.
941    pub fn active_ids(&self) -> Vec<WidgetId> {
942        self.active_ids_iter().collect()
943    }
944
945    /// Stream all active widget IDs without allocating. The iterator
946    /// borrows the arena, so the caller cannot mutate it while
947    /// iterating — for that case use [`Self::fill_active_ids`].
948    pub fn active_ids_iter(&self) -> impl Iterator<Item = WidgetId> + '_ {
949        self.nodes
950            .iter()
951            .filter(|(_, node)| node.activation == ActivationState::Active)
952            .map(|(id, _)| id)
953    }
954
955    /// Fill `out` with every active widget ID. Clears `out` first so
956    /// callers can reuse a long-lived buffer across calls. Use this
957    /// when the iteration site needs an owned snapshot independent
958    /// of the arena borrow (typically because it mutates per-widget
959    /// state with `arena.get_mut(id)` inside the loop).
960    pub fn fill_active_ids(&self, out: &mut Vec<WidgetId>) {
961        out.clear();
962        out.extend(self.active_ids_iter());
963    }
964
965    /// Set a widget subtree to dormant state (state preserved, not rendered).
966    /// Recursively dormants all children.
967    ///
968    /// The node named here is marked self-parked (`WidgetNode::self_dormant`);
969    /// the descendants swept along by the recursion are not, since their
970    /// dormancy belongs to this ancestor rather than to them. That distinction
971    /// is what lets [`activate`](Self::activate) put the subtree back exactly as
972    /// it found it instead of waking content that was already closed.
973    pub fn set_dormant(&mut self, id: WidgetId) {
974        self.park(id, true);
975    }
976
977    /// [`set_dormant`](Self::set_dormant)'s body, plus whether `id` is being
978    /// parked on its own account or dragged along by an ancestor.
979    ///
980    /// A node already self-parked stays that way when an ancestor sweeps over
981    /// it — the flag is only ever set here, never cleared, so nesting two
982    /// dormancy cycles cannot lose the inner one.
983    fn park(&mut self, id: WidgetId, on_its_own_account: bool) {
984        if let Some(node) = self.nodes.get_mut(id) {
985            let was_active = node.activation == ActivationState::Active;
986            node.activation = ActivationState::Dormant;
987            if on_its_own_account {
988                node.self_dormant = true;
989            }
990            // Record the Active→Dormant transition for nodes that opted into an
991            // activation signal; the signal is fired later by
992            // `WidgetTree::flush_activation_signals`, not here — see the
993            // `pending_activation_changes` field docs.
994            if was_active && node.activation_signal.is_some() {
995                self.pending_activation_changes.push((id, false));
996            }
997        }
998        let children: Vec<WidgetId> = self.children(id).to_vec();
999        for child in children {
1000            self.park(child, false);
1001        }
1002    }
1003
1004    /// Activate a dormant widget subtree (triggers relayout and repaint).
1005    /// Recursively activates all children, **except** those a descendant
1006    /// widget has independently gated off via `visible_when(false)`.
1007    ///
1008    /// The directly-targeted `id` is always activated (the caller asked for
1009    /// it). When recursing, a child whose own `visible_state` currently
1010    /// evaluates to `false` is left dormant along with its subtree: it is
1011    /// hidden by its own gate, not by the ancestor's dormancy, so a parent
1012    /// reactivation must not wake it. This is what keeps a `ComboBox`'s
1013    /// closed dropdown panel, a collapsed overlay, or any `visible_when`-
1014    /// gated child from leaking back to the screen when an ancestor (e.g. a
1015    /// `Toolbar` item reappearing from overflow) is re-activated. The
1016    /// per-pass visibility reconciliation
1017    /// ([`visibility_checks_iter`](Self::visibility_checks_iter)) still owns
1018    /// the eventual activate/dormant transitions when the gate flips.
1019    pub fn activate(&mut self, id: WidgetId) {
1020        if let Some(node) = self.nodes.get_mut(id) {
1021            // Only Dormant→Active is a real "show" transition. Guard on
1022            // `== Dormant` (not `!= Active`) so a `Destroyed` node — or any
1023            // future non-Active state — is never resurrected or signalled.
1024            let was_dormant = node.activation == ActivationState::Dormant;
1025            node.activation = ActivationState::Active;
1026            node.self_dormant = false;
1027            node.dirty.needs_layout = true;
1028            node.dirty.needs_paint = true;
1029            if was_dormant && node.activation_signal.is_some() {
1030                self.pending_activation_changes.push((id, true));
1031            }
1032        }
1033        let children: Vec<WidgetId> = self.children(id).to_vec();
1034        for child in children {
1035            let asleep_on_its_own_account = self
1036                .nodes
1037                .get(child)
1038                .map(|n| {
1039                    n.self_dormant
1040                        || n.visible_state
1041                            .as_ref()
1042                            .map(|vs| !vs.get())
1043                            .unwrap_or(false)
1044                })
1045                .unwrap_or(false);
1046            if asleep_on_its_own_account {
1047                continue;
1048            }
1049            self.activate(child);
1050        }
1051    }
1052
1053    /// Destroy a widget and remove it from the arena entirely.
1054    /// Recursively destroys all children. State is gone.
1055    pub fn destroy(&mut self, id: WidgetId) {
1056        self.roots_dirty = true;
1057        let children: Vec<WidgetId> = self.children(id).to_vec();
1058        for child in children {
1059            self.destroy(child);
1060        }
1061        self.remove_node(id);
1062    }
1063
1064    /// Remove a *single* node: unlink it from its parent's child list and drop
1065    /// it from the arena. Does **not** recurse into its children.
1066    ///
1067    /// The caller owns the recursion. This exists for
1068    /// [`WidgetTree::destroy_subtree`](crate::widget_tree::WidgetTree) /
1069    /// the reconciling rebuild path, which walks the subtree itself so it can
1070    /// honour re-parenting — a child re-homed into the surviving tree must NOT
1071    /// be torn down via this node's now-stale `children` list. Using
1072    /// [`destroy`](Self::destroy) there would re-recurse that stale list and
1073    /// destroy the re-homed survivor.
1074    pub fn remove_node(&mut self, id: WidgetId) {
1075        self.roots_dirty = true;
1076        if let Some(parent_id) = self.parent(id)
1077            && let Some(parent) = self.nodes.get_mut(parent_id)
1078        {
1079            parent.children.retain(|&c| c != id);
1080        }
1081        self.nodes.remove(id);
1082    }
1083
1084    /// Drain the buffered Active↔Dormant transitions recorded since the last
1085    /// call. Each `(id, active)` is fed to `WidgetTree::flush_activation_signals`
1086    /// which fires the node's `activation_signal` — at the tree level, outside
1087    /// any arena mutation.
1088    pub(crate) fn take_activation_changes(&mut self) -> Vec<(WidgetId, bool)> {
1089        std::mem::take(&mut self.pending_activation_changes)
1090    }
1091
1092    /// Record that `id` installed an `effective_enabled_signal`. Idempotent —
1093    /// the signal is install-or-reuse, so a rebuild re-registering the same
1094    /// node must not grow the list.
1095    pub(crate) fn watch_effective_enabled(&mut self, id: WidgetId) {
1096        if !self.effective_enabled_watchers.contains(&id) {
1097            self.effective_enabled_watchers.push(id);
1098        }
1099    }
1100
1101    /// The nodes carrying an `effective_enabled_signal`, for the per-pass
1102    /// refresh. Cloned so the caller can recompute `is_enabled` (an immutable
1103    /// ancestor walk) without holding a borrow on the arena.
1104    pub(crate) fn effective_enabled_watchers(&self) -> Vec<WidgetId> {
1105        self.effective_enabled_watchers.clone()
1106    }
1107
1108    /// Drop watchers whose node is gone (destroyed / rebuilt away).
1109    pub(crate) fn prune_effective_enabled_watchers(&mut self) {
1110        self.effective_enabled_watchers
1111            .retain(|id| self.nodes.contains_key(*id));
1112    }
1113
1114    pub fn is_active(&self, id: WidgetId) -> bool {
1115        self.nodes
1116            .get(id)
1117            .map(|n| n.activation == ActivationState::Active)
1118            .unwrap_or(false)
1119    }
1120
1121    pub fn len(&self) -> usize {
1122        self.nodes.len()
1123    }
1124
1125    pub fn is_empty(&self) -> bool {
1126        self.nodes.is_empty()
1127    }
1128
1129    pub fn mark_all_clean(&mut self) {
1130        for (_, node) in self.nodes.iter_mut() {
1131            node.dirty = DirtyFlags::default();
1132        }
1133    }
1134
1135    pub fn any_needs_layout(&self) -> bool {
1136        self.nodes
1137            .values()
1138            .any(|n| n.activation == ActivationState::Active && n.dirty.needs_layout)
1139    }
1140
1141    pub fn any_needs_paint(&self) -> bool {
1142        self.nodes
1143            .values()
1144            .any(|n| n.activation == ActivationState::Active && n.dirty.needs_paint)
1145    }
1146
1147    pub fn mark_needs_paint(&mut self, id: WidgetId) {
1148        if let Some(node) = self.nodes.get_mut(id) {
1149            node.dirty.needs_paint = true;
1150        }
1151    }
1152
1153    /// Recursively mark a widget and all its descendants needs_paint.
1154    /// Used by callers that want a fresh paint of an entire subtree
1155    /// — e.g. a rich tooltip whose dwell indicator child would
1156    /// otherwise reuse its cached_paint while the parent re-runs
1157    /// some per-frame logic.
1158    pub fn mark_subtree_needs_paint(&mut self, id: WidgetId) {
1159        if let Some(node) = self.nodes.get_mut(id) {
1160            node.dirty.needs_paint = true;
1161        }
1162        let children: Vec<WidgetId> = self.children(id).to_vec();
1163        for child in children {
1164            self.mark_subtree_needs_paint(child);
1165        }
1166    }
1167
1168    pub fn mark_needs_layout(&mut self, id: WidgetId) {
1169        if let Some(node) = self.nodes.get_mut(id) {
1170            node.dirty.needs_layout = true;
1171            node.dirty.needs_paint = true;
1172        }
1173    }
1174
1175    /// Mark a widget as needing its `build()` re-run.
1176    /// Also marks for layout and paint since rebuilt children need both.
1177    pub fn mark_needs_rebuild(&mut self, id: WidgetId) {
1178        if let Some(node) = self.nodes.get_mut(id) {
1179            node.dirty.needs_rebuild = true;
1180            node.dirty.needs_layout = true;
1181            node.dirty.needs_paint = true;
1182        }
1183    }
1184
1185    /// Collect widgets that need their `build()` re-run (data-driven rebuild).
1186    /// Only returns active widgets with `needs_rebuild == true`.
1187    ///
1188    /// Allocating wrapper around [`Self::needs_rebuild_iter`]. Prefer
1189    /// the iterator on hot paths.
1190    pub fn collect_needs_rebuild(&self) -> Vec<WidgetId> {
1191        self.needs_rebuild_iter().collect()
1192    }
1193
1194    /// Stream widgets that need `build()` re-run without allocating.
1195    ///
1196    /// `needs_rebuild` is set only by `BindingLevel::Rebuild` bindings —
1197    /// i.e. on composing widgets that explicitly want `build()` re-run
1198    /// when their data model changes. It is intentionally NOT gated on
1199    /// the widget currently having children: a data-driven widget that
1200    /// builds its children directly and starts EMPTY (e.g. the toast
1201    /// host with no toasts yet, an empty list that renders rows without
1202    /// a persistent container) must still rebuild to materialise its
1203    /// FIRST child. `rebuild_single_widget` handles a childless widget
1204    /// correctly (nothing to tear down, then it adopts `build()`'s
1205    /// output).
1206    pub fn needs_rebuild_iter(&self) -> impl Iterator<Item = WidgetId> + '_ {
1207        self.nodes
1208            .iter()
1209            .filter(|(_, n)| n.activation == ActivationState::Active && n.dirty.needs_rebuild)
1210            .map(|(id, _)| id)
1211    }
1212
1213    /// Check all widgets with visible_state bindings and return
1214    /// (id, is_currently_active, should_be_visible) tuples.
1215    ///
1216    /// Allocating wrapper around [`Self::visibility_checks_iter`].
1217    pub fn visibility_checks(&self) -> Vec<(WidgetId, bool, bool)> {
1218        self.visibility_checks_iter().collect()
1219    }
1220
1221    /// Stream widgets with `visible_state` bindings without
1222    /// allocating. Each entry is `(id, is_currently_active,
1223    /// should_be_visible)`.
1224    pub fn visibility_checks_iter(&self) -> impl Iterator<Item = (WidgetId, bool, bool)> + '_ {
1225        self.nodes.iter().filter_map(|(id, node)| {
1226            node.visible_state.as_ref().map(|state| {
1227                let is_active = node.activation == ActivationState::Active;
1228                let should_be_visible = state.get();
1229                (id, is_active, should_be_visible)
1230            })
1231        })
1232    }
1233
1234    /// Check if a widget is effectively enabled, walking up the parent chain.
1235    ///
1236    /// Returns `false` if the widget itself or any ancestor has `enabled_state`
1237    /// bound to `false`. This lets containers like `GroupBox` disable a whole
1238    /// subtree by binding a single signal on their content wrapper.
1239    pub fn is_enabled(&self, id: WidgetId) -> bool {
1240        let mut current = Some(id);
1241        while let Some(node_id) = current {
1242            if let Some(node) = self.nodes.get(node_id) {
1243                if let Some(ref state) = node.enabled_state
1244                    && !state.get()
1245                {
1246                    return false;
1247                }
1248                current = node.parent;
1249            } else {
1250                return true;
1251            }
1252        }
1253        true
1254    }
1255
1256    /// Set a per-child alignment override on a widget.
1257    pub fn set_alignment_override(&mut self, id: WidgetId, alignment: teksilo_tokens::Alignment) {
1258        if let Some(node) = self.get_mut(id) {
1259            node.alignment_override = Some(alignment);
1260        }
1261    }
1262
1263    /// Mark a widget as clipping its children (scroll area, overflow hidden).
1264    pub fn set_clips_children(&mut self, id: WidgetId, clips: bool) {
1265        if let Some(node) = self.get_mut(id) {
1266            node.clips_children = clips;
1267        }
1268    }
1269
1270    /// The OS-IME descriptor for the widget at `id`, or `None` if the node
1271    /// is not a text-input surface (the default) or the id is unknown. The
1272    /// platform IME layer queries this for the focused widget to decide
1273    /// whether to enable the OS input method and with which purpose.
1274    pub fn ime_context(&self, id: WidgetId) -> Option<crate::ime::ImeContext> {
1275        self.get(id).and_then(|n| n.ime)
1276    }
1277
1278    /// Set (or clear, with `None`) the OS-IME descriptor for the widget at
1279    /// `id`.
1280    pub fn set_ime_context(&mut self, id: WidgetId, ime: Option<crate::ime::ImeContext>) {
1281        if let Some(node) = self.get_mut(id) {
1282            node.ime = ime;
1283        }
1284    }
1285
1286    /// Apply a `HandlerSet` to an existing node, merging handlers and
1287    /// transferring node-level metadata (focusable, cursor, clips,
1288    /// context menu). The `scope` argument controls whether the
1289    /// handlers go into the rebuild-cleared `handlers` slot or the
1290    /// persistent `external_handlers` slot.
1291    pub(crate) fn apply_handler_set(
1292        &mut self,
1293        id: WidgetId,
1294        handler_set: crate::widget_builder::HandlerSet,
1295        scope: HandlerScope,
1296    ) {
1297        if let Some(node) = self.get_mut(id) {
1298            let target = match scope {
1299                HandlerScope::Own => &mut node.handlers,
1300                HandlerScope::External => &mut node.external_handlers,
1301            };
1302            let existing = std::mem::take(target);
1303            *target = existing.merge(handler_set.handlers);
1304            if let Some(focusable) = handler_set.focusable {
1305                node.node_focusable = Some(focusable);
1306            }
1307            if let Some(tab_index) = handler_set.tab_index {
1308                node.node_tab_index = Some(tab_index);
1309            }
1310            if let Some(cursor) = handler_set.cursor {
1311                node.node_cursor = Some(cursor);
1312            }
1313            if let Some(clips) = handler_set.clips_children {
1314                node.clips_children = clips;
1315            }
1316            if let Some(ime) = handler_set.ime {
1317                node.ime = Some(ime);
1318            }
1319            if let Some(pass_through) = handler_set.event_pass_through {
1320                node.event_pass_through = pass_through;
1321            }
1322            if let Some(dead_zone) = handler_set.gesture_dead_zone {
1323                node.gesture_dead_zone = dead_zone;
1324            }
1325            if let Some(keyboard_capture) = handler_set.keyboard_capture {
1326                node.keyboard_capture = keyboard_capture;
1327            }
1328            if let Some(hit_transparent) = handler_set.hit_transparent {
1329                node.hit_transparent = hit_transparent;
1330            }
1331            if handler_set.context_menu_factory.is_some() {
1332                node.context_menu_factory = handler_set.context_menu_factory;
1333            }
1334            if let Some(sig) = handler_set.focus_within {
1335                node.focus_within_signal = Some(sig);
1336            }
1337            if let Some(sig) = handler_set.hover_within {
1338                node.hover_within_signal = Some(sig);
1339            }
1340            // Mirror builder-level accessibility overrides + subtree mode
1341            // onto the persistent WidgetNode so the accessibility tree
1342            // walker (and the event dispatcher, for action callbacks) can
1343            // read them after handler extraction.
1344            if handler_set.access.is_some() {
1345                node.access_overrides = handler_set.access;
1346            }
1347            if let Some(mode) = handler_set.access_subtree {
1348                node.access_subtree = mode;
1349            }
1350        }
1351    }
1352
1353    /// Get a widget's alignment override, if any.
1354    pub fn alignment_override(&self, id: WidgetId) -> Option<teksilo_tokens::Alignment> {
1355        self.get(id)?.alignment_override
1356    }
1357
1358    /// Temporarily take the widget box out of a node (for rebuild).
1359    /// The node remains in the arena with a placeholder.
1360    pub fn take_widget(&mut self, id: WidgetId) -> Option<Box<dyn Widget>> {
1361        let node = self.nodes.get_mut(id)?;
1362        // Replace with a minimal placeholder
1363        let taken = std::mem::replace(&mut node.widget, Box::new(PlaceholderWidget));
1364        Some(taken)
1365    }
1366
1367    /// Restore a widget box that was previously taken out.
1368    pub fn restore_widget(&mut self, id: WidgetId, widget: Box<dyn Widget>) {
1369        if let Some(node) = self.nodes.get_mut(id) {
1370            node.widget = widget;
1371        }
1372    }
1373
1374    /// Walk up the parent chain from `id` and mark each ancestor as needing layout.
1375    /// Called when a relayout-level binding changes, since a child's size change
1376    /// may affect its parent's size, and so on up to the root.
1377    pub fn mark_ancestors_need_layout(&mut self, id: WidgetId) {
1378        let mut current = self.parent(id);
1379        while let Some(pid) = current {
1380            if let Some(node) = self.get_mut(pid) {
1381                node.dirty.needs_layout = true;
1382                node.dirty.needs_paint = true;
1383            }
1384            current = self.parent(pid);
1385        }
1386    }
1387
1388    /// Mark all widgets as needing layout and paint (e.g. after a theme change).
1389    /// Also clears per-widget paint caches since the visual output is stale.
1390    pub fn mark_all_dirty(&mut self) {
1391        for (_, node) in self.nodes.iter_mut() {
1392            node.dirty.needs_layout = true;
1393            node.dirty.needs_paint = true;
1394            node.cached_paint = None;
1395            node.cached_post_paint = None;
1396        }
1397    }
1398
1399    /// Mark every active node for repaint **without** touching layout, rebuild,
1400    /// or the per-widget paint caches. Used for a global visual change that
1401    /// leaves geometry untouched — the window's active-state flip (caret
1402    /// hiding, selection desaturation, `DimWhenInactive`). Lighter than
1403    /// [`Self::mark_all_dirty`]: the paint walker re-runs `paint()` for any
1404    /// node whose `needs_paint` is set and overwrites its cache, so there is no
1405    /// need to clear `cached_paint`; and skipping `needs_layout` avoids a
1406    /// pointless relayout pass. Dormant nodes are skipped — they don't paint,
1407    /// and they're re-marked on reactivation.
1408    pub fn mark_all_needs_paint_only(&mut self) {
1409        for (_, node) in self.nodes.iter_mut() {
1410            if node.activation == ActivationState::Active {
1411                node.dirty.needs_paint = true;
1412            }
1413        }
1414    }
1415
1416    /// Resolve the effective theme for a widget by walking ancestors and
1417    /// applying any theme overrides encountered along the way.
1418    /// The base theme is the tree-level default.
1419    pub fn resolve_theme<'a>(
1420        &self,
1421        id: WidgetId,
1422        base: &'a crate::styles::Theme,
1423    ) -> std::borrow::Cow<'a, crate::styles::Theme> {
1424        // Fast path: if no widget has a theme override, borrow the base
1425        // theme — no clone. This is the per-widget hot path during layout
1426        // and paint, so avoiding `Theme::clone()` (which clones the
1427        // typography token strings and bumps ~34 style-slot `Rc`s) here
1428        // saves that work on every node, every pass, in the common case.
1429        if self.theme_override_count == 0 {
1430            return std::borrow::Cow::Borrowed(base);
1431        }
1432
1433        // Collect ancestor chain from root to widget
1434        let mut chain = vec![id];
1435        let mut current = self.parent(id);
1436        while let Some(pid) = current {
1437            chain.push(pid);
1438            current = self.parent(pid);
1439        }
1440        chain.reverse(); // root first
1441
1442        let mut theme = base.clone();
1443        for nid in chain {
1444            if let Some(node) = self.nodes.get(nid)
1445                && let Some(ovr) = &node.theme_override
1446            {
1447                (ovr.func)(&mut theme);
1448            }
1449        }
1450        std::borrow::Cow::Owned(theme)
1451    }
1452}
1453
1454impl Default for WidgetArena {
1455    fn default() -> Self {
1456        Self::new()
1457    }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462    use super::*;
1463    use crate::test_widgets::FillWidget;
1464    use teksilo_canvas::SizeProposal;
1465
1466    fn key(w: Option<f32>, h: Option<f32>) -> ProposalKey {
1467        ProposalKey::from_proposal(SizeProposal {
1468            width: w,
1469            height: h,
1470        })
1471    }
1472
1473    #[test]
1474    fn activate_skips_a_child_gated_off_by_visible_state() {
1475        // Reactivating a subtree must not wake a child that its own widget
1476        // has gated off via `visible_when(false)` — e.g. a ComboBox's closed
1477        // dropdown panel, or a collapsed overlay. Regression for ghost
1478        // dropdown rows after a `visible_when` collapse→reappear cycle.
1479        let mut arena = WidgetArena::new();
1480        let parent = arena.insert(Box::new(FillWidget::new()));
1481        let visible_child = arena.insert_child(parent, Box::new(FillWidget::new()));
1482        let gated_child = arena.insert_child(parent, Box::new(FillWidget::new()));
1483        // The gated child is hidden by its own visibility gate.
1484        if let Some(node) = arena.get_mut(gated_child) {
1485            node.visible_state = Some(Prop::Static(false));
1486        }
1487
1488        arena.set_dormant(parent);
1489        assert!(!arena.is_active(gated_child));
1490
1491        arena.activate(parent);
1492        assert!(arena.is_active(parent), "the targeted node activates");
1493        assert!(
1494            arena.is_active(visible_child),
1495            "an ungated child activates with its parent"
1496        );
1497        assert!(
1498            !arena.is_active(gated_child),
1499            "a visible_when(false) child stays dormant when its parent reactivates"
1500        );
1501    }
1502
1503    #[test]
1504    fn activate_skips_a_child_parked_directly_by_set_dormant() {
1505        // The ungated twin of the test above, and the one that was missing.
1506        //
1507        // Widgets that pre-build hidden content register it as a child with
1508        // `ctx.add(..)` + `ctx.set_dormant(..)` and show it through an overlay:
1509        // `SplitButton` and `MenuBar` menus, `Popover`, `Snackbar`, the date
1510        // editors' calendars. Such a child carries no `visible_state`, so the
1511        // gate check alone let an ancestor's dormancy cycle wake it — and it
1512        // then rendered inline, with no overlay behind it, because the overlay
1513        // presentation never ran. Seen as export menu-item labels floating
1514        // under the title bar after leaving a mode that parked the shell.
1515        let mut arena = WidgetArena::new();
1516        let parent = arena.insert(Box::new(FillWidget::new()));
1517        let visible_child = arena.insert_child(parent, Box::new(FillWidget::new()));
1518        let menu = arena.insert_child(parent, Box::new(FillWidget::new()));
1519        let menu_row = arena.insert_child(menu, Box::new(FillWidget::new()));
1520
1521        // The widget parks its own closed menu — no gate involved.
1522        arena.set_dormant(menu);
1523        assert!(!arena.is_active(menu));
1524
1525        // An ancestor now goes dormant and comes back.
1526        arena.set_dormant(parent);
1527        arena.activate(parent);
1528
1529        assert!(arena.is_active(parent), "the targeted node activates");
1530        assert!(
1531            arena.is_active(visible_child),
1532            "an ordinary child activates with its parent"
1533        );
1534        assert!(
1535            !arena.is_active(menu),
1536            "the ancestor's dormancy cycle woke a menu that was closed before it \
1537             started — its content is now on screen with no overlay behind it"
1538        );
1539        assert!(
1540            !arena.is_active(menu_row),
1541            "the closed menu's own subtree woke with it"
1542        );
1543
1544        // …and opening it still works: activating by id is how the overlay
1545        // shows this content, so it must clear the self-parked mark.
1546        arena.activate(menu);
1547        assert!(arena.is_active(menu), "the menu can still be opened");
1548        assert!(arena.is_active(menu_row), "…along with its rows");
1549    }
1550
1551    #[test]
1552    fn a_reopened_menu_parks_again_and_survives_the_next_cycle() {
1553        // The flag must be re-armed by every `set_dormant`, not just the first:
1554        // open the menu, close it, then put an ancestor through another
1555        // dormancy cycle. Without re-arming, the second cycle leaks.
1556        let mut arena = WidgetArena::new();
1557        let parent = arena.insert(Box::new(FillWidget::new()));
1558        let menu = arena.insert_child(parent, Box::new(FillWidget::new()));
1559
1560        arena.set_dormant(menu);
1561        arena.activate(menu); // opened
1562        arena.set_dormant(menu); // dismissed
1563
1564        arena.set_dormant(parent);
1565        arena.activate(parent);
1566        assert!(
1567            !arena.is_active(menu),
1568            "a menu that was opened once no longer stays closed across a \
1569             dormancy cycle"
1570        );
1571    }
1572
1573    #[test]
1574    fn an_ancestor_cycle_does_not_strand_an_open_menu() {
1575        // The mirror risk of the fix: `park` marks only the node it is given,
1576        // so a menu that is *open* when an ancestor parks must come back with
1577        // that ancestor rather than being stranded closed.
1578        let mut arena = WidgetArena::new();
1579        let parent = arena.insert(Box::new(FillWidget::new()));
1580        let menu = arena.insert_child(parent, Box::new(FillWidget::new()));
1581
1582        arena.set_dormant(menu);
1583        arena.activate(menu); // open when the ancestor parks
1584
1585        arena.set_dormant(parent);
1586        arena.activate(parent);
1587        assert!(
1588            arena.is_active(menu),
1589            "an open menu was stranded closed by its ancestor's dormancy cycle"
1590        );
1591    }
1592
1593    #[test]
1594    fn proposal_key_distinguishes_none_from_zero() {
1595        // `None` (ask for ideal) must not collide with `Some(0.0)` (give zero).
1596        assert_ne!(key(None, None), key(Some(0.0), None));
1597        assert_ne!(key(Some(0.0), None), key(None, Some(0.0)));
1598    }
1599
1600    #[test]
1601    fn proposal_key_canonicalizes_signed_zero_and_nan() {
1602        assert_eq!(key(Some(-0.0), None), key(Some(0.0), None));
1603        assert_eq!(key(Some(f32::NAN), None), key(Some(f32::NAN), None));
1604    }
1605
1606    #[test]
1607    fn proposal_key_separates_distinct_values_and_axes() {
1608        assert_ne!(key(Some(1.0), None), key(Some(2.0), None));
1609        // Same scalar on different axes must not collide.
1610        assert_ne!(key(Some(10.0), None), key(None, Some(10.0)));
1611    }
1612
1613    #[test]
1614    fn insert_and_retrieve() {
1615        let mut arena = WidgetArena::new();
1616        let id = arena.insert(Box::new(FillWidget::new()));
1617        assert!(arena.get(id).is_some());
1618        assert_eq!(arena.len(), 1);
1619    }
1620
1621    #[test]
1622    fn new_widget_is_dirty() {
1623        let mut arena = WidgetArena::new();
1624        let id = arena.insert(Box::new(FillWidget::new()));
1625        let node = arena.get(id).unwrap();
1626        assert!(node.dirty.needs_layout);
1627        assert!(node.dirty.needs_paint);
1628    }
1629
1630    #[test]
1631    fn roots_returns_parentless_widgets() {
1632        let mut arena = WidgetArena::new();
1633        let root = arena.insert(Box::new(FillWidget::new()));
1634        let _child = arena.insert_child(root, Box::new(FillWidget::new()));
1635        let roots = arena.roots();
1636        assert_eq!(roots.len(), 1);
1637        assert_eq!(roots[0], root);
1638    }
1639
1640    #[test]
1641    fn content_transform_node_claims_viewport_in_parent_space() {
1642        // A content-transform node (the SceneView pattern) is a fixed
1643        // viewport: its bounds are tested in PARENT space and the transform
1644        // only positions its content, so the whole visible viewport stays
1645        // hittable regardless of the content pan/zoom. Before the fix, the
1646        // bounds were tested in content space, so a content pan shifted the
1647        // hittable region off the viewport.
1648        use teksilo_canvas::{Point, Rect, Transform2D};
1649        let mut arena = WidgetArena::new();
1650        let id = arena.insert(Box::new(FillWidget::new()));
1651        {
1652            let node = arena.get_mut(id).unwrap();
1653            node.bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
1654            node.clips_children = true;
1655            node.content_transform = true;
1656            // Content panned by (50, 30).
1657            node.transform_prop = Some(Prop::Static(Transform2D::translate(50.0, 30.0)));
1658        }
1659        // Points across the whole parent-space viewport hit, regardless of the
1660        // pan (these all missed before the fix).
1661        assert_eq!(arena.hit_test_at(Point::new(10.0, 10.0), None), Some(id));
1662        assert_eq!(arena.hit_test_at(Point::new(100.0, 50.0), None), Some(id));
1663        assert_eq!(arena.hit_test_at(Point::new(199.0, 99.0), None), Some(id));
1664        // Outside the viewport: miss.
1665        assert_eq!(arena.hit_test_at(Point::new(250.0, 50.0), None), None);
1666    }
1667
1668    #[test]
1669    fn self_transform_node_tests_bounds_in_local_space() {
1670        // Regression guard: a *self* transform wrapper (Scale / Rotate, NOT a
1671        // content transform) keeps the original semantics — its own bounds
1672        // move with the transform, so the point is inverse-transformed before
1673        // the bounds test. `clips_children` is irrelevant here (Scale clips
1674        // too); only `content_transform` selects the viewport path.
1675        use teksilo_canvas::{Point, Rect, Transform2D};
1676        let mut arena = WidgetArena::new();
1677        let id = arena.insert(Box::new(FillWidget::new()));
1678        {
1679            let node = arena.get_mut(id).unwrap();
1680            node.bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
1681            node.clips_children = true; // Scale clips, but is NOT content_transform.
1682            node.content_transform = false;
1683            // Visually scaled to 50x50 around the origin.
1684            node.transform_prop = Some(Prop::Static(Transform2D::scale(0.5, 0.5)));
1685        }
1686        // Inside the scaled-down 50x50 visual → hit.
1687        assert_eq!(arena.hit_test_at(Point::new(25.0, 25.0), None), Some(id));
1688        // Past the scaled-down visual (but inside the un-scaled 100x100 bounds
1689        // in parent space) → miss, because the bounds test is in local space.
1690        assert_eq!(arena.hit_test_at(Point::new(75.0, 75.0), None), None);
1691    }
1692
1693    #[test]
1694    fn nested_content_transform_nodes_each_claim_their_viewport() {
1695        // A content-transform node embedded inside another (the nested-
1696        // SceneView case): each level tests its own viewport bounds in its
1697        // parent's space, and only the transform is applied when descending.
1698        // The inner viewport stays hittable regardless of either node's pan.
1699        use teksilo_canvas::{Point, Rect, Transform2D};
1700        let mut arena = WidgetArena::new();
1701        let outer = arena.insert(Box::new(FillWidget::new()));
1702        let inner = arena.insert_child(outer, Box::new(FillWidget::new()));
1703        {
1704            let n = arena.get_mut(outer).unwrap();
1705            n.bounds = Rect::new(0.0, 0.0, 200.0, 200.0);
1706            n.clips_children = true;
1707            n.content_transform = true;
1708            n.transform_prop = Some(Prop::Static(Transform2D::translate(20.0, 20.0)));
1709        }
1710        {
1711            let n = arena.get_mut(inner).unwrap();
1712            // Inner viewport expressed in the OUTER's content space.
1713            n.bounds = Rect::new(10.0, 10.0, 50.0, 50.0);
1714            n.clips_children = true;
1715            n.content_transform = true;
1716            n.transform_prop = Some(Prop::Static(Transform2D::translate(5.0, 5.0)));
1717        }
1718        // Screen (40,40) → outer-content (20,20) ∈ inner viewport → reaches inner.
1719        assert_eq!(arena.hit_test_at(Point::new(40.0, 40.0), None), Some(inner));
1720        // Screen (5,5) → outer-content (-15,-15) ∉ inner viewport → reaches outer.
1721        assert_eq!(arena.hit_test_at(Point::new(5.0, 5.0), None), Some(outer));
1722    }
1723
1724    /// Accepts only the right half of its bounds via `hit_shape`; the left
1725    /// half is rejected so a click there falls through to a sibling beneath.
1726    #[derive(Debug)]
1727    struct RightHalfWidget;
1728
1729    impl crate::widget::Widget for RightHalfWidget {
1730        fn layout_response(
1731            &self,
1732            proposal: teksilo_canvas::SizeProposal,
1733            _ctx: &crate::widget::LayoutContext,
1734        ) -> crate::widget::LayoutResponse {
1735            proposal.resolve(0.0, 0.0).into()
1736        }
1737
1738        fn hit_shape(
1739            &self,
1740            local_point: teksilo_canvas::Point,
1741            bounds: teksilo_canvas::Rect,
1742        ) -> bool {
1743            local_point.x >= bounds.x + bounds.width / 2.0
1744        }
1745    }
1746
1747    #[test]
1748    fn hit_shape_rejection_falls_through_to_sibling_underneath() {
1749        // Two overlapping siblings under a common parent. `lower` is a
1750        // full-rect FillWidget; `upper` (inserted later → painted on top,
1751        // hit-tested first) rejects its left half via `hit_shape`. A click in
1752        // the rejected left half must reach `lower` underneath; a click in the
1753        // accepted right half must hit `upper`.
1754        use teksilo_canvas::{Point, Rect};
1755        let mut arena = WidgetArena::new();
1756        let parent = arena.insert(Box::new(FillWidget::new()));
1757        let lower = arena.insert_child(parent, Box::new(FillWidget::new()));
1758        let upper = arena.insert_child(parent, Box::new(RightHalfWidget));
1759        for id in [parent, lower, upper] {
1760            arena.get_mut(id).unwrap().bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
1761        }
1762        // Right half: upper accepts → hit upper.
1763        assert_eq!(arena.hit_test_at(Point::new(75.0, 50.0), None), Some(upper));
1764        // Left half: upper rejects via hit_shape → falls through to lower.
1765        assert_eq!(arena.hit_test_at(Point::new(25.0, 50.0), None), Some(lower));
1766    }
1767}