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