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}