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