Skip to main content

teksilo_core/
widget_tree.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use std::cell::RefCell;
5use std::rc::Rc;
6
7use crate::styles::Theme;
8use teksilo_canvas::{Canvas, Point, Rect, RenderFrame, SizeProposal};
9
10use crate::arena::WidgetArena;
11use crate::event::{EventResponse, Key, Modifiers, PointerButton, WidgetEvent};
12use crate::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
13use crate::widget_id::WidgetId;
14
15mod accessibility_description_impl;
16#[cfg(test)]
17mod accessibility_description_tests;
18mod accessibility_emit_impl;
19mod accessibility_impl;
20#[cfg(test)]
21mod accessibility_proxy_tests;
22#[cfg(test)]
23mod adapter_ids_tests;
24#[cfg(test)]
25mod announcement_ring_tests;
26#[cfg(test)]
27mod announcer_tests;
28#[cfg(test)]
29mod device_scale_tests;
30mod drag_drop_impl;
31mod focus_impl;
32#[cfg(test)]
33mod focus_request_tests;
34pub(crate) use focus_impl::FocusRequestMode;
35mod gesture_dispatch_impl;
36#[cfg(test)]
37mod hit_targeting_tests;
38mod layout_impl;
39#[cfg(test)]
40mod live_image_tests;
41#[cfg(test)]
42mod local_frame_tests;
43#[cfg(test)]
44mod modal_tests;
45mod overlay_impl;
46pub mod pan_arbiter;
47mod pointer_cancel;
48mod pointer_router;
49mod pointer_state;
50mod query_impl;
51#[cfg(test)]
52mod redraw_waker_tests;
53mod rendering_impl;
54#[cfg(test)]
55mod repaint_trigger_tests;
56mod test_api;
57pub mod touch_route;
58#[cfg(test)]
59mod window_name_tests;
60
61struct AnimatedRegistration {
62    weak: crate::signal::WeakAnimatedSignal,
63    owner: WidgetId,
64}
65
66impl AnimatedRegistration {
67    fn same_signal(&self, signal: &crate::signal::Signal<f32>) -> bool {
68        self.weak.same_signal(signal)
69    }
70
71    fn is_alive(&self) -> bool {
72        self.weak.upgrade().is_some()
73    }
74
75    fn take_pending_animation(
76        &self,
77    ) -> Option<(
78        crate::signal::Signal<f32>,
79        crate::animation::AnimationRequest,
80        WidgetId,
81    )> {
82        let signal = self.weak.upgrade()?;
83        let request = signal.take_pending_animation()?;
84        Some((signal, request, self.owner))
85    }
86
87    /// Non-consuming counterpart to [`take_pending_animation`](Self::take_pending_animation),
88    /// for [`WidgetTree::needs_reconcile`] — which must be able to ask
89    /// "is there work here?" without doing any.
90    fn has_pending_animation(&self) -> bool {
91        self.weak
92            .upgrade()
93            .is_some_and(|signal| signal.has_pending_animation())
94    }
95}
96
97// (line 33, above `struct AnimatedRegistration`) — remove entirely
98// (line 71, above `#[allow(clippy::type_complexity)]` / `pub struct WidgetTree`)
99/// The main widget tree orchestrating arena, layout, events, accessibility, and paint.
100/// Provides both the runtime API and the headless test API.
101#[allow(clippy::type_complexity)]
102pub struct WidgetTree {
103    pub(crate) arena: WidgetArena,
104    /// Current theme value cached for `&Theme` accessors used by layout/paint
105    /// contexts and by widgets that need an immediate read. The reactive source
106    /// of truth is `theme_signal`; both are updated in lockstep by `set_theme`.
107    theme: Theme,
108    /// Reactive theme signal. Widgets that want their visual or derived state
109    /// to track theme changes bind to this signal or build derived signals via
110    /// `zip`/`map`. `set_theme` updates the signal (firing observers) without
111    /// rebuilding the widget tree, so interaction state (focus, scroll, expanded
112    /// panels, …) survives theme switches.
113    theme_signal: crate::signal::Signal<Theme>,
114    /// How the active [`TargetDensity`](teksilo_tokens::TargetDensity) is chosen. `Fixed(Compact)` by default,
115    /// so nothing switches density unless the app asks.
116    ///
117    /// `FollowLastPointer` is **stored and read by nobody**. The pointer
118    /// ingress it was written for exists — the app event loop routes contacts
119    /// into `dispatch_pointer_with_ops` — but honouring the policy means
120    /// deciding what a stray tap costs (a density switch discards every widget
121    /// id in the tree), whether a pen counts as coarse, and how the hysteresis
122    /// commits when the user simply stops touching. None of that is settled, so
123    /// the policy is a declaration the framework does not yet act on.
124    density_policy: teksilo_tokens::DensityPolicy,
125    /// User-controlled global text-scale factor (`1.0` = 100 %). Layered on top
126    /// of the OS `text_scale_factor`: the two multiply. Set via
127    /// `set_user_text_scale`; persisted by the application through
128    /// `teksilo_settings::TEXT_SCALE_KEY`.
129    user_text_scale: f32,
130    /// Cached projection of `theme` whose `typography` is scaled by
131    /// `user_text_scale * text_scale_factor`. Recomputed by
132    /// `recompute_effective_theme` whenever the theme or either scale factor
133    /// changes; the layout and paint walkers read this instead of `theme` so
134    /// all text grows uniformly. Equal to `theme` when the combined factor is 1.
135    effective_theme: Theme,
136    /// The combined `user_text_scale * text_scale_factor`, cached so the
137    /// layout/paint context construction sites don't recompute it. The single
138    /// scalar published to widgets that size from a source *other* than
139    /// `Theme.typography` (icons, the rich-text engine, calendar constants,
140    /// scene text). Written alongside `effective_theme` in
141    /// `recompute_effective_theme`.
142    effective_text_scale: f32,
143    /// Reactive mirror of `effective_text_scale`, for build-time binders that
144    /// must react to a scale change without a rebuild path of their own (e.g.
145    /// `Calendar` binds this at `Rebuild` level). Fired by
146    /// `recompute_effective_theme`.
147    text_scale_signal: crate::signal::Signal<f32>,
148    /// Reactive window-active state (`focused AND not occluded`), the
149    /// occlusion-aware companion to `WindowState::focused` (which is raw OS
150    /// focus only). The single source of truth for "is this window active",
151    /// read by `is_window_active()` and published to widgets via
152    /// `window_active_signal()` / `BuildContext::window_active*` /
153    /// `PaintContext::window_active`. Drives caret hiding, selection
154    /// desaturation and `DimWhenInactive`. Starts `true` — winit may not send
155    /// `Focused(true)` for the first window, so a window must not be born
156    /// inactive. Mutated only by `set_window_active`.
157    window_active_signal: crate::signal::Signal<bool>,
158    text_backend: Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
159    focused: Option<WidgetId>,
160    /// Reactive mirror of `focused`. Same pattern as `hovered_signal`
161    /// — kept in sync via `set_focused`. Drives the inspector's Focus
162    /// tab without polling.
163    focused_signal: crate::signal::Signal<Option<WidgetId>>,
164    /// Every pointer the tree currently knows about, plus the primary and
165    /// hover-owner elections.
166    ///
167    /// Replaces the singular `hovered` / `last_pointer_position` /
168    /// `pointer_captured_by` this tree used to carry. Hover lives on the
169    /// **hover owner**'s entry (a contact never hovers), position and the
170    /// legacy singular accessors read the **primary**, and capture is per
171    /// pointer — two contacts hold independent captures, each released only by
172    /// its own Up or Cancel. See [`crate::pointer::table`].
173    pointers: crate::pointer::table::PointerTable,
174    /// Reactive mirror of the hover owner's hovered widget. Set whenever it
175    /// changes during dispatch / hit-test recovery so external observers
176    /// (notably the debug inspector's hover tooltip) can react without
177    /// polling. Held by handle so the field is a cheap clone.
178    hovered_signal: crate::signal::Signal<Option<WidgetId>>,
179    /// Reactive mirror of the kind of the pointer that most recently produced
180    /// a sample. Lets a widget switch an affordance between the mouse and the
181    /// touch form without a rebuild, and without every widget having to
182    /// remember an `on_pointer_event` of its own just to learn the modality.
183    last_pointer_kind_signal: crate::signal::Signal<teksilo_tokens::PointerKind>,
184    /// The pointer position *before* the move currently being
185    /// dispatched — i.e. the last sample that was still over the
186    /// previously-hovered widget. Read when arming an overlay's safe
187    /// triangle: the apex belongs at the point the pointer left the
188    /// anchor, not at the first sample past it (they coincide for a
189    /// real mouse, but the distinction is what keeps the cone from
190    /// degenerating to "the apex is wherever I am, so I am always
191    /// inside it").
192    previous_pointer_position: Option<teksilo_canvas::Point>,
193    /// A rebuild destroyed the focused widget: the ancestors it had inside the
194    /// subtree that owned focus, innermost first, remembered so the end of the
195    /// layout pass can land focus back inside the innermost one still alive.
196    ///
197    /// A rebuild allocates fresh `WidgetId`s for its children, so the focused
198    /// node dies and `revalidate_interaction_state` drops focus to `None`.
199    /// Leaving it there kicks the user out of the widget they were in — most
200    /// visibly, a popover that refreshes its content when it opens would throw
201    /// away the very row the popover had just focused, so the menu comes up with
202    /// no keyboard focus at all. Focus is re-entered *after* the layout walk
203    /// (see the tail of `layout_with_ops`), once the fresh children have real
204    /// bounds for the focus-driven scroll-into-view — the same shape as the
205    /// post-layout hover refresh next to it.
206    ///
207    /// The innermost survivor, not the rebuilt root: a reconciling rebuild
208    /// keeps the children it re-attaches, so the group that held focus is often
209    /// still there, and the root's first focusable descendant is the *first*
210    /// group, whichever one the user was in.
211    ///
212    /// Each ancestor carries the focused node's position among its focusable
213    /// descendants, in tree order. The restore lands on whatever holds that
214    /// position after the rebuild, so focus in the second of two fields comes
215    /// back to the second field, not the first.
216    pending_focus_restore: Vec<(WidgetId, usize)>,
217    /// A focus request whose target was **dormant** when it was made: a page
218    /// of a `Switcher`, a `visible_when` branch, that the same handler is
219    /// about to show. Focusing a dormant node outright does not survive the
220    /// next layout — `revalidate_interaction_state` runs before the pass wakes
221    /// the page, finds the target inactive, and drops focus — so the request
222    /// waits here instead, and the tail of `layout_with_ops` honours it once
223    /// activation has settled (see `settle_pending_focus_request`).
224    ///
225    /// One slot, last request wins. Any focus move that actually happens in
226    /// the meantime (`focus_with_origin_ops`, which every move goes through)
227    /// and [`WidgetTree::clear_focus`] cancel it; a target still dormant, or
228    /// gone, when the pass ends drops it.
229    pending_focus_request: Option<focus_impl::PendingFocusRequest>,
230    /// The interaction anchors as of the last layout walk — the focused node,
231    /// each live pointer's captor, and an in-flight drag's source.
232    ///
233    /// A `culls_children` parent is told to keep what the user is in the
234    /// middle of, and it is only asked during layout. So the anchor set is an
235    /// *input* to that decision, and when it changes the decision is stale —
236    /// even though nothing moved and nothing resized, which is what the
237    /// idle-pass early-return keys off. Kept here so the pass can notice the
238    /// change and re-ask the culling parents it affects; see
239    /// `invalidate_culls_for_moved_interaction`.
240    last_interaction_anchors: Vec<WidgetId>,
241    last_proposal: SizeProposal,
242    pending_modal_requests: Vec<crate::modal::QueuedModalRequest>,
243    pending_modal_dismissal: bool,
244    shortcut_registry: crate::shortcut::ShortcutRegistry,
245    /// Queue of intents awaiting dispatch. Populated either by the
246    /// keystroke interception path (`dispatch_event` for KeyDown) or
247    /// by handlers calling `ctx.send_intent(...)`. Drained between
248    /// event-handler calls by [`WidgetTree::drain_pending_intents`].
249    /// The tuple carries the source widget (dispatch anchor), the
250    /// intent itself, and the firing shortcut's
251    /// `propagate_when_disabled` policy.
252    pending_intents: Vec<(WidgetId, crate::intent::Intent, bool)>,
253    /// Window-global actions registered via
254    /// [`BuildContext::register_action_global`](crate::BuildContext::register_action_global).
255    /// Consulted as a fallback at the end of every intent dispatch — *after* the
256    /// source→root walk finds no consuming node action — so an app-global command
257    /// is reachable no matter where the intent originated (a menu-bar dropdown
258    /// overlay, deep content, a global shortcut anchored at the root). Each entry
259    /// is owned by the registering widget and torn down on its rebuild/destroy,
260    /// mirroring `register_shortcut_global`.
261    global_actions: Vec<(WidgetId, crate::action::Action)>,
262    /// The widgets that edit text, registered via
263    /// [`BuildContext::register_text_surface`](crate::BuildContext::register_text_surface).
264    ///
265    /// Owned by the registering widget and torn down on its rebuild/destroy,
266    /// exactly like `global_actions` above. Read through
267    /// [`WidgetTree::focused_text_surface`] by a host that has taken a text
268    /// chord — `Ctrl+Z`, `Ctrl+C` — for itself and owes every text widget in the
269    /// tree an answer about what happens to it. See
270    /// [`crate::text_surface`] for why the framework is the only place that
271    /// question can be answered completely.
272    text_surfaces: crate::text_surface::TextSurfaces,
273    /// Currently-armed key-capture slot. `Some` when
274    /// [`WidgetTree::begin_key_capture`] has been called and the
275    /// returned [`CaptureHandle`](crate::shortcut::CaptureHandle)
276    /// is still alive. The slot is shared (via `Rc`) with the handle
277    /// so dropping the handle cancels the capture, and calling
278    /// `begin_key_capture` again creates a fresh slot without
279    /// touching the previous one (whose handle, if dropped later,
280    /// only clears its own orphaned slot).
281    key_capture: Option<crate::shortcut::KeyCaptureSlot>,
282    binding_registry: crate::binding::BindingRegistry,
283    idle_queue: crate::idle::IdleQueue,
284    /// Simulated clock for deterministic time-dependent testing.
285    ///
286    /// Its initial value is the tree's **epoch**, and [`Self::input_clock`] is
287    /// seeded from the very same `Instant` — so `EventTime::ZERO` and this
288    /// field's starting value name one moment, and the input timeline and the
289    /// animation timeline are one axis. See
290    /// [`crate::pointer::clock`] for why that matters.
291    sim_clock: std::time::Instant,
292    /// The one source of [`EventTime`](crate::pointer::EventTime)s for this
293    /// tree. A [`MonotonicClock`](crate::pointer::clock::MonotonicClock)
294    /// anchored at the tree epoch by default; a test swaps in a
295    /// [`ManualClock`](crate::pointer::clock::ManualClock) via
296    /// [`set_input_clock`](Self::set_input_clock).
297    input_clock: std::rc::Rc<dyn crate::pointer::clock::InputClock>,
298    /// What is known about the sample currently being dispatched, snapshotted
299    /// onto every [`EventContext`] built while it
300    /// runs. Holds its default — a mouse at the epoch — outside a pointer or
301    /// scroll dispatch.
302    current_input: crate::pointer::InputSnapshot,
303    /// Whether simulated time is *currently* what this tree measures against —
304    /// i.e. whether [`Self::sim_clock`], rather than the wall clock, is what
305    /// [`Self::animation_clock`] answers with and what freezes the input
306    /// timeline.
307    ///
308    /// Set by `enter_simulated_mode`, which
309    /// [`advance_time`](Self::advance_time) calls before it moves anything, and
310    /// cleared by [`resume_real_time`](Self::resume_real_time). Both axes turn
311    /// on this one flag, so the tree has one notion of "is time simulated right
312    /// now" rather than two that can disagree.
313    ///
314    /// It is not a latch. A headless test never hands the clock back, so for a
315    /// test it behaves like one: an animation may then only progress by
316    /// advancing the clock, never by the test taking a long time. A host
317    /// sharing a live tree with a real event loop — the debug automation bridge
318    /// — hands it back after every operation, and real time drives the tree
319    /// again until the next advance.
320    ///
321    /// What each axis does across the two transitions differs, because the two
322    /// carry different state. The animation scheduler holds absolute instants,
323    /// so switching axes **rebases** them (`AnimationScheduler::rebase`) and the
324    /// reading itself is simply whichever clock is in charge. The input axis
325    /// holds none, so it is the *reading* that is carried: see
326    /// [`Self::sim_input_origin`] and [`Self::sim_input_offset`].
327    sim_time_frozen: bool,
328    /// Where the input timeline stood when this tree entered simulated mode,
329    /// as `(the reading then, the `sim_clock` then)`.
330    ///
331    /// The input axis cannot simply become `sim_clock - epoch`: samples
332    /// dispatched *before* the switch were stamped from the wall clock, which
333    /// by then is ahead of `sim_clock`, so every one of them would sit in the
334    /// virtual future and no interval measured from them would ever elapse.
335    /// Continuing the axis from where it stood instead makes every stamp taken
336    /// before the switch lie in the past and every interval after it exactly
337    /// the duration advanced.
338    ///
339    /// `None` while the tree runs on real time, and also under a clock that has
340    /// no wall-clock anchor — a
341    /// [`ManualClock`](crate::pointer::clock::ManualClock) *is* the virtual
342    /// axis already, and is moved directly by `advance_time`.
343    ///
344    /// Cleared by [`resume_real_time`](Self::resume_real_time), which hands the
345    /// axis back to the wall clock with what was advanced carried forward in
346    /// [`Self::sim_input_offset`].
347    sim_input_origin: Option<(crate::pointer::EventTime, std::time::Instant)>,
348    /// How far ahead of the raw input clock this tree's input timeline runs,
349    /// having been advanced and then handed back to real time.
350    ///
351    /// Added to every reading of the clock, and **re-measured** (not
352    /// accumulated) at each hand-back as `frozen reading − raw reading`, floored
353    /// at zero. That is what makes the axis monotone across the hand-back: the
354    /// reading at the instant of
355    /// [`resume_real_time`](Self::resume_real_time) is the later of the frozen
356    /// reading it had and the raw one, and it moves with the wall clock from
357    /// there. Without it
358    /// the axis would jump *backwards* by everything that was advanced, and a
359    /// monotone [`EventTime`](crate::pointer::EventTime) is a platform
360    /// conformance invariant.
361    ///
362    /// So it is not monotone in itself: a tree that spent longer on the wall
363    /// clock than it was ever advanced is already ahead of its frozen reading
364    /// and the right offset is then zero, which is what the floor is for. It is
365    /// also dropped outright by
366    /// [`set_input_clock`](Self::set_input_clock) — it is a distance measured
367    /// against one clock's readings and means nothing against another's.
368    ///
369    /// It is also what keeps a deadline schedulable: `instant_for` subtracts it
370    /// again, so a long press armed after an advance is reported to the event
371    /// loop at a *future* `Instant` rather than one in the past that can never
372    /// ripen.
373    sim_input_offset: std::time::Duration,
374    /// Overlay manager for tooltips, menus, popovers.
375    pub(crate) overlay_manager: crate::overlay::OverlayManager,
376    /// Re-entrancy guard for the dismissal-callback drain. A callback may
377    /// dismiss another overlay, whose teardown drains again; the inner drain
378    /// returns at once and the outer loop picks up whatever it parked, so the
379    /// recursion is one level deep by construction rather than by luck.
380    pub(crate) draining_dismiss: std::cell::Cell<bool>,
381    /// Tooltip attachments, one per anchor. See [`TooltipEntry`].
382    tooltips: Vec<TooltipEntry>,
383    /// Simulated-clock end of the tooltip "reshow session". While any tip is
384    /// visible, or until this instant after the last tip dismissed, subsequent
385    /// anchors use `MotionTokens::tooltip_reshow_delay` instead of the full
386    /// initial delay (Windows `TTDT_RESHOW` behaviour).
387    tooltip_session_until_sim: Option<std::time::Instant>,
388    /// Real-clock counterpart of [`Self::tooltip_session_until_sim`].
389    tooltip_session_until_real: Option<std::time::Instant>,
390    /// The tooltip currently surfaced by keyboard menu navigation
391    /// (`show_highlight_tooltip`): `(overlay_id, content_id)`. At most one
392    /// is shown at a time; moving the highlight or closing the menu clears
393    /// it. Distinct from the hover/focus tooltip paths — this one is a
394    /// `Manual`-dismiss child of the menu overlay so a single Escape closes
395    /// the menu (and cascades the tooltip) rather than only the tooltip.
396    highlight_tooltip: Option<(crate::overlay::OverlayId, WidgetId)>,
397    /// How the currently focused widget gained focus.
398    focus_origin: Option<crate::focus::FocusOrigin>,
399    /// Every live pointer press, keyed by the node whose gesture arena took it.
400    /// The framework's press visual — see [`crate::press`] for the four things
401    /// a widget's own `PointerDown`/`PointerUp` bookkeeping cannot see.
402    presses: crate::press::PressTable,
403    /// Input-modality "focus-visible" state: `true` after keyboard input,
404    /// `false` after pointer input. Focus rings (e.g. `StandardItem`'s current
405    /// row) show only while this is `true`, the standard `:focus-visible`
406    /// behaviour — so a mouse click selects without a ring, and keyboard
407    /// navigation reveals it.
408    focus_visible: crate::signal::Signal<bool>,
409    /// Active focus-scope stack during build. A data view pushes its scope
410    /// (`begin_view_focus`) around its row loop so each row reads *its view's*
411    /// focus deterministically — independent of arena parenting, which may not
412    /// be wired yet while rows build (docked / virtualized content). Drives
413    /// focus-aware selection + focus rings in `StandardItem`.
414    view_focus_stack: Vec<crate::signal::Signal<bool>>,
415    /// Layout direction for RTL/LTR support.
416    pub(crate) layout_direction: crate::environment::LayoutDirection,
417    /// Animation scheduler for smooth animated state and signal transitions.
418    animation_scheduler: crate::animation::AnimationScheduler,
419    /// Weakly tracked animated values from both state and signal APIs.
420    animated_values: Vec<AnimatedRegistration>,
421    /// Registry of shader-driven animated quads (opt-in alternative to
422    /// `Signal<f32>::animate_looping` for decorative motion — progress
423    /// sweeps, sprite-atlas frame cycling, future pulse/shimmer). The
424    /// scheduler-style signal path stays for everything else. Per-slot
425    /// `AnimParams` are ticked and attached to every `RenderFrame`
426    /// produced by `render()` — the renderer reads them from there.
427    animated_quads: crate::animated_quad::AnimatedQuadRegistry,
428    /// Per-frame-effect scheduler. Owns the registry of widgets that
429    /// asked for a frame-tick subscription (Pulse, Cycle, …). Sits
430    /// alongside `animation_scheduler` and `animated_quads` as the
431    /// third visibility-aware motion source — they all consult the
432    /// same [`motion_visibility`](crate::motion_visibility) helpers.
433    /// After every `render()` the tree calls
434    /// `FrameTickScheduler::should_arm_frame_tick` and re-arms
435    /// `frame_tick_armed_by_subscribers` if any subscriber's owner was
436    /// painted this frame.
437    pub(crate) frame_tick_scheduler: crate::frame_tick_scheduler::FrameTickScheduler,
438    /// Live pans, live coasts, the window's pinch, and the palm watches — the
439    /// whole touch-motion layer, in one field. See
440    /// [`pan_arbiter`].
441    touch_motion: pan_arbiter::TouchMotion,
442    /// The claimant chain the next synthesised scroll is to walk.
443    ///
444    /// Armed immediately before that scroll is pushed through
445    /// [`dispatch_scroll`](Self::dispatch_scroll) and taken by the router arm
446    /// that routes it, because the two producers know different things: a pan
447    /// has a live session to read, a coast has only the chain it was launched
448    /// with. `None` for every scroll from a backend, which derives its own
449    /// chain from the sample's position.
450    armed_chain: Option<Vec<(WidgetId, crate::pointer::touch_action::PanClaim)>>,
451    /// Monotonic counter bumped at the start of each `render()` call.
452    /// Each widget's `last_painted_epoch` is set to this value whenever
453    /// the paint pass (or the cache-hit early-out) confirms the widget
454    /// intersects the window viewport. The animation scheduler uses it
455    /// to detect and pause animations for widgets that have scrolled
456    /// off-screen. Starts at `0`, which serves as the "never painted"
457    /// sentinel; tests that only call `layout()` see the gate bypass.
458    pub(crate) paint_epoch: u64,
459    /// Cached accessibility tree update, rebuilt only when something that
460    /// changes the AT tree has happened, not on every layout.
461    cached_a11y: Option<accesskit::TreeUpdate>,
462    /// Whether the accessibility tree needs rebuilding. Set by focus moves,
463    /// overlay changes, widget rebuilds, active↔dormant transitions,
464    /// `AccessibilityOnly` binding flips and `request_accessibility_update()`;
465    /// a plain relayout does not set it.
466    a11y_dirty: bool,
467    /// Snapshot of `shortcut_registry.version()` at the last
468    /// `sync_accessibility` call. When the live version differs the
469    /// AT cache is dirtied, so widgets that bound their announced
470    /// shortcut via `access_shortcut_id(id)` track user rebinds
471    /// without any explicit signaling from the settings UI.
472    last_synced_shortcut_version: u64,
473    /// Snapshot of `locale_signal` at the last `sync_accessibility`
474    /// call. When the locale differs the AT cache is dirtied, so
475    /// `access_label(tr!(...))` (stored as a locale-bound
476    /// `Prop<String>`) re-resolves into the announced node — even on a
477    /// same-direction switch that doesn't rebuild the composite.
478    last_synced_locale: Option<String>,
479    /// The window title the last accessibility walk named the root after.
480    /// A new title re-walks the tree, as a locale switch does, so the
481    /// window's name follows it. See `accessibility_emit_impl`.
482    last_synced_window_title: Option<String>,
483    /// Reverse map from synthetic (widget-emitted) AccessKit NodeIds
484    /// to the WidgetId that owns them. Rebuilt on every full
485    /// accessibility walk. `handle_accessibility_actions` uses this
486    /// to route an `ActionRequest` targeting a TextRun child back
487    /// to the owning rich-text editor, since synthetic NodeIds
488    /// can't be decoded back to a WidgetId by value alone.
489    pub(crate) synthetic_parent_map: std::collections::HashMap<accesskit::NodeId, WidgetId>,
490    /// Where each synthetic child sits inside its owner, for the ones that
491    /// declared their bounds in the owner's own space (a label's line
492    /// boxes, an editor's rows). A pure translation re-places them from
493    /// here; a synthetic node absent from this map — a scene item under a
494    /// view transform, which reports absolute rects — is moved by the
495    /// owner's delta instead.
496    pub(crate) synthetic_local_bounds:
497        std::collections::HashMap<accesskit::NodeId, teksilo_canvas::Rect>,
498    /// Monotonic count of accessibility walks. A consumer that has not
499    /// seen the latest one is holding a tree whose *shape* may be stale,
500    /// not merely its geometry.
501    pub(crate) a11y_walk_generation: u64,
502    /// Cached full render frame — reused when no widget needs painting.
503    /// `Rc<RenderFrame>` rather than `RenderFrame` so cache-hit frames
504    /// cost an atomic refcount bump instead of a deep clone of every
505    /// draw-command Vec. `render()` uses `Rc::make_mut` to update
506    /// `anim_params` in place when the tree is the sole owner (the
507    /// common case — the caller usually drops the previous frame
508    /// before calling render() again).
509    cached_frame: Option<std::rc::Rc<RenderFrame>>,
510    /// Dispatches that arrived while another dispatch was in flight, replayed
511    /// once the outer one completes.
512    ///
513    /// A handler that dispatches (a synthetic click, an AT action re-entering
514    /// the door) must not observe half-updated pointer state, and must not be
515    /// able to unwind the sample the outer dispatch is still standing on. So a
516    /// nested dispatch is queued here rather than run inline, and drained —
517    /// faithfully, event *and* input snapshot — after the outer sample
518    /// completes. From the caller's side nothing changes: the queue is empty
519    /// again before the top-level `dispatch_*` call returns.
520    pending_dispatch: std::collections::VecDeque<pointer_router::QueuedDispatch>,
521    /// How many dispatches are on the stack. Non-zero means "queue, do not
522    /// re-enter"; see [`Self::pending_dispatch`].
523    dispatch_depth: u32,
524    /// Pointers whose current press was cancelled and which have not pressed
525    /// again since.
526    ///
527    /// `PointerCancel` is terminal: the interaction was taken away, so an `Up`
528    /// arriving for the same press afterwards must not complete it. A platform
529    /// can genuinely send both (Windows delivers a `WM_POINTERUP` after a
530    /// capture loss), so the `Up` is swallowed rather than asserted against.
531    /// An entry is dropped by that swallowed `Up`, or by the pointer's next
532    /// press — a fresh press is a fresh interaction. Bounded by the contact
533    /// cap plus the mouse.
534    cancelled_pointers: Vec<crate::pointer::PointerId>,
535    /// Current cursor selected by hover/interaction routing.
536    current_cursor: crate::widget::CursorIcon,
537    /// What the **node-declared** cursor mechanism last resolved for the
538    /// hovered chain — the value `PointerEnter` wrote, or `Default` after the
539    /// `PointerLeave` that undid it.
540    ///
541    /// Kept so a handler can hand the cursor back
542    /// ([`EventContext::release_cursor`]) after having overridden it, without
543    /// having to re-derive the chain's declaration and risk disagreeing with
544    /// the walk that produced it. It is exactly as fresh as `current_cursor`:
545    /// both move only when the enter/leave pair fires, so a node whose
546    /// `.cursor(..)` changes under a stationary pointer is stale in the same
547    /// way, and by the same mechanism.
548    ///
549    /// [`EventContext::release_cursor`]: crate::widget::EventContext::release_cursor
550    node_declared_cursor: crate::widget::CursorIcon,
551    /// Delayed overlay requests (e.g., submenu hover-open delay).
552    pending_delayed_overlays: Vec<PendingDelayedOverlay>,
553    /// Reusable scratch buffer for active-id snapshots taken on hot
554    /// paths that mutate per-widget state inside the loop
555    /// (`tick_gestures_with_ops`, post-render dirty-bit clear,
556    /// post-layout `needs_layout` clear). Cleared and refilled on
557    /// every use via `WidgetArena::fill_active_ids`. Previously these
558    /// sites called the allocating `arena.active_ids()` per frame,
559    /// which `perf record` ranked at ~13 % of CPU on the
560    /// `widget_catalog --tab animations` scene.
561    active_ids_scratch: Vec<WidgetId>,
562    /// Widgets currently carrying a non-`None` `EventHandlers::gesture_arena`.
563    /// Updated on attach (`ensure_gesture_arena` install) and on
564    /// teardown (rebuild / destroy / handler-clear). Every per-frame
565    /// gesture pass (`tick_gestures_with_ops`, `next_gesture_deadline`)
566    /// iterates this set instead of every active widget — most active
567    /// widgets have `gesture_arena = None`, so the savings come from
568    /// not even visiting them. Filtered by `arena.is_active(id)` at
569    /// iteration time so dormant entries don't fire (a widget can be
570    /// dormant while still holding its handlers).
571    gesture_owners: std::collections::HashSet<WidgetId>,
572    /// OS-level accessibility preferences (high contrast, reduced motion, text scale).
573    /// High contrast is a signal so a colour baked at build time (a recipe's
574    /// `ColorProp::Bound`) can follow it the way it follows window activation;
575    /// mutated only by `set_accessibility_preferences`.
576    prefers_high_contrast: crate::signal::Signal<bool>,
577    prefers_reduced_motion: bool,
578    text_scale_factor: f64,
579    /// Whether an assistive technology is reading the tree, as reported by the
580    /// *operating system* — not by AccessKit. See
581    /// [`Self::set_screen_reader_state`] for why the distinction matters.
582    screen_reader: crate::environment::ScreenReaderState,
583    /// The app's explore-by-touch policy. See [`Self::set_explore_by_touch`].
584    explore_by_touch: crate::environment::ExploreByTouch,
585    /// Whether an AccessKit client is currently attached to this window's
586    /// adapter. Written by `teksilo-app` from the platform adapter's
587    /// activation / deactivation handlers.
588    at_client_attached: bool,
589    /// How a density switch should be worded to a screen reader, if the
590    /// application wants one announced. See
591    /// [`Self::set_density_announcement`].
592    density_announcement: Option<std::rc::Rc<dyn Fn(teksilo_tokens::TargetDensity) -> String>>,
593    /// How "a context menu opened" should be worded to a screen reader when a
594    /// **hold** opened it, if the application wants one announced. See
595    /// [`Self::set_context_menu_announcement`].
596    context_menu_announcement: Option<std::rc::Rc<dyn Fn() -> String>>,
597    /// The tree-owned long press the router is waiting out, if any. One at a
598    /// time: two fingers holding two different controls is not a gesture any
599    /// desktop idiom assigns a meaning to, and the second contact's arrival
600    /// replaces the first's route rather than racing it. See
601    /// [`touch_route`].
602    pending_touch_route: Option<touch_route::PendingTouchRoute>,
603    /// Host window HiDPI device scale (physical px per logical px), fed by
604    /// `teksilo-app` before each layout. Surfaced to widgets via
605    /// `LayoutContext::scale_factor`. The widget tree is otherwise fully
606    /// logical (the renderer applies this scale at the vertex stage); this is
607    /// the escape hatch for widgets that must size a device-pixel OS resource
608    /// (e.g. a `WebView`'s native subview). 1.0 in headless / test contexts.
609    device_scale_factor: f32,
610    /// Reactive mirror of `device_scale_factor`, for the widgets whose layout
611    /// reads it: see [`Self::device_scale_signal`].
612    device_scale_signal: crate::signal::Signal<f32>,
613    /// What the tree's widgets attached for content updated off the UI
614    /// thread (`RepaintTrigger`), and the waker their requests wake. See
615    /// [`crate::off_thread`].
616    pub(crate) off_thread: crate::off_thread::OffThreadRegistry,
617    /// Platform safe-area insets for the host window — a notch, a rounded
618    /// corner, a home indicator — in logical pixels, fed by `teksilo-app`
619    /// after every window resize. Reaches overlay placement through
620    /// [`OverlayViewport::safe_area`](crate::overlay::OverlayViewport::safe_area).
621    /// `ZERO` on every platform that reports nothing, which is every desktop
622    /// platform but macOS.
623    safe_area: teksilo_canvas::EdgeInsets,
624    /// A rectangle of the window currently covered by something outside the
625    /// tree — a soft keyboard, a platform IME candidate window — in
626    /// window-logical pixels. Reaches overlay placement through
627    /// [`OverlayViewport::occluded`](crate::overlay::OverlayViewport::occluded).
628    /// `None` on every frame where nothing is covering the window.
629    occluded_inset: Option<Rect>,
630    /// A pending `EventContext::request_soft_keyboard` call, taken by the app
631    /// layer once per dispatch. `Some(true)` asks for the keyboard,
632    /// `Some(false)` asks it to go away.
633    soft_keyboard_request: Option<bool>,
634    /// Active drag-and-drop session, if any.
635    pub(crate) active_drag: Option<crate::drag_state::DragSession>,
636    /// Source widget of an in-flight OS (outbound) drag that escalated past
637    /// the window boundary. Set only on the window that *started* the drag.
638    /// The in-app `active_drag` session is torn down at escalation (the OS owns
639    /// the pointer); this remembers who started it so the eventual `DragEnded`
640    /// can fire the source's `on_drag_ended`.
641    pub(crate) outbound_drag_source: Option<WidgetId>,
642    /// True while *this* window currently holds the re-entered internal session
643    /// for an in-flight app-originated OS drag (the OS drag wandered back over
644    /// this window — possibly a different window than the source — and we
645    /// restored the original typed payload). Distinguishes that session from a
646    /// plain internal drag so leaving again re-stashes instead of starting a
647    /// second OS drag, and dropping doesn't double-fire `on_drag_ended`.
648    pub(crate) os_drag_reentered: bool,
649    /// The accept state last pushed to the platform for an inbound OS drag, so
650    /// the same answer is not re-sent on every motion sample.
651    ///
652    /// `None` outside an OS drag, and reset when one ends — a fresh drag must
653    /// push its first verdict even if it happens to match the last drag's.
654    pub(crate) os_drop_accepted: Option<bool>,
655    /// Optional platform host for custom window chrome (set when the
656    /// application opts in via `WindowConfig::custom_chrome(true)`). Stored
657    /// here so that the root-builder closure has access during widget
658    /// construction; the same `Rc` is also held by `WindowManager` so it
659    /// outlives the widget tree if needed.
660    title_bar_host: Option<Rc<dyn crate::PlatformTitleBarHost>>,
661    /// App-level subscription state: registered event source adapter,
662    /// proxy poster, UI-side subscription callbacks. Default is empty;
663    /// teksilo-app installs a populated context when an event source is
664    /// registered on the builder.
665    pub(crate) app_context: Rc<crate::event_source::TreeAppContext>,
666    /// Active locale identifier. Cached for `Option<&str>` accessors; the
667    /// reactive source of truth is `locale_signal`. Both are updated in
668    /// lockstep by `set_locale`.
669    pub(crate) locale: Option<String>,
670    /// Reactive locale signal. Widgets and `LocalizedString` adapters bind to
671    /// this signal to react to locale changes; `set_locale` updates the signal
672    /// without rebuilding the widget tree.
673    pub(crate) locale_signal: crate::signal::Signal<Option<String>>,
674    /// Per-frame delta-seconds signal, advanced by `layout()` **only when
675    /// a frame was asked for**: through `request_frame()`, or by the
676    /// render-end re-arm for a painted frame-tick subscriber. This
677    /// preserves Teksilo's draw-when-needed model: idle trees stay idle
678    /// even if widgets have registered observers on this signal.
679    pub(crate) frame_tick: crate::signal::Signal<f32>,
680    /// Set by `request_frame()`; consumed by `advance_frame_tick()` on
681    /// the next `layout()`. Observers that need another tick after the
682    /// current one must re-request. Stored as `Rc<Cell>` so observers
683    /// fired from inside the layout pass (`ctx.effect` closures on
684    /// `frame_tick`) can chain-request without needing &mut access
685    /// to the tree — see [`frame_request_handle`](Self::frame_request_handle).
686    pub(crate) frame_tick_requested: std::rc::Rc<std::cell::Cell<bool>>,
687    /// Set after a render in which a frame-tick subscriber's owner was
688    /// painted; consumed alongside `frame_tick_requested` by
689    /// `advance_frame_tick()`.
690    ///
691    /// A flag of its own so that [`frame_tick_deadline`](Self::frame_tick_deadline)
692    /// knows *why* a frame is wanted. A throttled subscriber may stretch the
693    /// wait for its own tick to its interval; a frame asked for through
694    /// `frame_tick_requested` (the announcer's follow-up syncs, a caret, a
695    /// drag auto-scroll) is due at 60 Hz whatever is on screen. When both
696    /// reasons shared one flag they could not be told apart, and a
697    /// once-a-minute clock anywhere in the window made every requested frame
698    /// wait a minute.
699    pub(crate) frame_tick_armed_by_subscribers: bool,
700    /// Debug-only re-entrancy flag: `true` while a focus-change dispatch
701    /// (`FocusGained` / `FocusLost` handlers) is running. Threaded into each
702    /// `EventContext` so `open_window` / `focus_window` can warn if a handler
703    /// changes context merely because a control gained focus (WCAG 3.2.1). A
704    /// shared `Rc<Cell<bool>>` (like `frame_tick_requested`) so the flag is
705    /// readable from an `EventContext` that holds no `&mut` to the tree.
706    pub(crate) in_focus_dispatch: std::rc::Rc<std::cell::Cell<bool>>,
707    /// Shared "accessibility re-walk requested" flag. Set via
708    /// [`request_accessibility_update`](Self::request_accessibility_update)
709    /// (or its `BuildContext` / `EventContext` wrappers) and drained at the
710    /// top of [`sync_accessibility`](Self::sync_accessibility) into
711    /// `a11y_dirty`. A relayout no longer re-walks the AT tree on its own, so
712    /// widgets that restructure their subtree in an AT-affecting way (e.g.
713    /// `SceneView` materialising / destroying scene widgets) need this lever.
714    /// `Rc<Cell>` so the shared `&self` paths can toggle it like
715    /// `frame_tick_requested`.
716    pub(crate) a11y_update_requested: std::rc::Rc<std::cell::Cell<bool>>,
717    /// Delayed frame wake-up deadline. Widgets that need to schedule
718    /// a future frame without pumping at full framerate (caret blink,
719    /// etc.) store the target instant here via
720    /// [`wake_at_handle`](Self::wake_at_handle). `next_timer_deadline`
721    /// rolls it into the event loop's WaitUntil; when reached, the
722    /// next `layout()` automatically re-arms `frame_tick_requested`
723    /// so the frame-tick effects run on the wake-up pass.
724    pub(crate) pending_wake_at: std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>,
725    /// One-shot post-mount actions enqueued during `build()` via
726    /// [`BuildContext::run_after_mount`](crate::BuildContext::run_after_mount), drained by the app loop (and by
727    /// tests) through [`WidgetTree::run_mount_actions`] with a real
728    /// [`EventContext`] — the only place a widget
729    /// can read the OS parent handle / app-state / poster together after it is
730    /// mounted. Used by widgets that own a native resource needing a window
731    /// handle to initialise (a `WebView`'s engine subview).
732    pub(crate) pending_mount_actions: Vec<Box<dyn FnOnce(&mut crate::widget::EventContext)>>,
733    /// Wall-clock time of the previous `layout()` call (for delta computation).
734    pub(crate) last_frame_time: Option<std::time::Instant>,
735    /// Set by [`EventContext::close_window`] during dispatch; drained
736    /// by the application event loop after each event via
737    /// [`WidgetTree::take_close_window_request`]. A *guarded* close
738    /// request — the app routes it through the window's close guard.
739    pub(crate) close_window_requested: bool,
740    /// Set by [`EventContext::close_window_forced`] during dispatch;
741    /// drained via [`WidgetTree::take_force_close_request`]. An
742    /// *unconditional* close request that bypasses the window's close
743    /// guard.
744    pub(crate) force_close_requested: bool,
745    /// Raised by [`EventContext::set_locale`] during dispatch; drained by
746    /// the application event loop (see
747    /// `WindowManager::drain_pending_locale_requests`) so the switch can be
748    /// routed through the `I18nManager` (active locale + version signal +
749    /// RTL direction). `WidgetTree::set_locale` alone would only update the
750    /// tree's local locale signal — the i18n thread-local would stay put
751    /// and `tr!` lookups would not re-resolve.
752    pub(crate) pending_locale_request: Option<String>,
753    /// Raised by [`EventContext::set_theme`] during dispatch; drained by
754    /// the application event loop (see
755    /// `WindowManager::drain_pending_theme_requests`) so the switch is
756    /// routed through `WindowManager::set_theme`, which fans the new theme
757    /// out to *every* window. Applying via `WidgetTree::set_theme` inline
758    /// would only re-theme the originating window — the rest of the app
759    /// would stay on the old theme. Mirrors `pending_locale_request`.
760    pub(crate) pending_theme_request: Option<crate::styles::Theme>,
761    /// Raised by [`EventContext::follow_system_theme`] during dispatch;
762    /// drained by the application event loop (see
763    /// `WindowManager::drain_pending_follow_system_requests`), which switches
764    /// the app to `ThemeMode::Native` and recomputes the theme from current
765    /// OS colours. Mirrors `pending_theme_request`.
766    pub(crate) pending_follow_system_request: bool,
767    /// Raised by [`EventContext::set_text_scale`] during dispatch; drained by
768    /// the application event loop (see
769    /// `WindowManager::drain_pending_text_scale_requests`) so the change is
770    /// routed through `WindowManager::set_text_scale`, fanning the new factor
771    /// out to *every* window. Mirrors `pending_theme_request`.
772    pub(crate) pending_text_scale_request: Option<f32>,
773    /// Monotonic version counter bumped after every *real* accessibility
774    /// rebuild in [`Self::sync_accessibility`] (cache hits don't bump).
775    /// Mirror of [`crate::shortcut::ShortcutRegistry::version`]: an
776    /// automation / test harness can poll it to know whether the AT tree
777    /// changed without diffing the whole `TreeUpdate`.
778    at_version: crate::signal::Signal<u64>,
779    /// The framework's own live regions, one per politeness level. See
780    /// [`crate::announcer`]: each speaks every message from a node the tree
781    /// has never had, which is the only mechanism all three platform adapters
782    /// agree announces, and never in an update that moves focus.
783    announcer_polite: crate::announcer::Announcer,
784    announcer_assertive: crate::announcer::Announcer,
785    /// Where both announcers draw the id of each message's node from, so no
786    /// message is spoken from an id the tree has used before.
787    announcer_ids: crate::announcer::AnnouncerIds,
788    /// The announcements the platform adapters would have made from the
789    /// updates `sync_accessibility` returned (see
790    /// [`crate::accessibility::Announcement`]), drained by
791    /// [`Self::announcements_since`], while the tree records them
792    /// ([`Self::set_records_announcements`]).
793    announcement_ring: crate::accessibility::announcements::AnnouncementRing,
794    /// The ids the window's platform adapter holds, which differ from the
795    /// tree's own for a node that left the tree a reader sees and came back.
796    /// See [`crate::accessibility::adapter_ids`] and
797    /// [`Self::deliver_accessibility`].
798    adapter_ids: crate::accessibility::adapter_ids::AdapterIds,
799    /// What the last delivered update said about descriptions and live
800    /// regions, which is what the next one's descriptions are measured
801    /// against. See [`accessibility_description_impl`].
802    description_memory: accessibility_description_impl::DescriptionMemory,
803    /// Whether the `WidgetEvent::AccessAction` currently being dispatched was
804    /// consumed by a handler. Written by the dispatcher's `AccessAction` arm,
805    /// read (and reset) by [`Self::dispatch_access_action`], which is the only
806    /// caller that can answer "did anything happen?" to its own caller.
807    ///
808    /// A side channel because `dispatch_event_with_ops` returns `()` for every
809    /// event kind, and routing AT actions through the *same* path as everything
810    /// else is load-bearing (it is what lets an action open a window). The
811    /// alternative — reporting success whenever a live widget merely *existed*
812    /// at the target — is how an unhandled action came to look like a
813    /// successful one to every automation client.
814    access_action_handled: bool,
815    /// The `WindowState` for this tree's hosting window. Populated
816    /// by the app-level window manager when the tree is registered;
817    /// `None` for standalone trees. Cloned into every `EventContext`
818    /// and `BuildContext` so widgets can bind to the current window's
819    /// signals via `ctx.window()`.
820    pub(crate) window_state: Option<crate::window::WindowState>,
821}
822
823/// How long the shortened reshow delay stays active after the last tooltip
824/// dismisses. Long enough to cover moving between adjacent toolbar icons;
825/// short enough that a later, deliberate hover still pays the full initial
826/// delay. Not a theme token — it is session bookkeeping, not a visual feel.
827const TOOLTIP_SESSION_GRACE: std::time::Duration = std::time::Duration::from_millis(1000);
828
829/// Number of visible steps a sticky-on-dwell tooltip's promotion window is
830/// divided into.
831///
832/// The tree uses this only to decide *how often to wake* while a dwell is
833/// running — one redraw per step boundary rather than a free-run — but it must
834/// match the step count the content widget actually renders, or the indicator
835/// would advance on a different beat from the wake-ups driving it.
836/// `teksilo-widgets`' `DWELL_STEPS` is defined *as* this constant, so the two
837/// cannot drift; the per-step *duration* is derived from each entry's own
838/// `sticky_after`, so a caller that picks a non-default promotion window still
839/// gets correctly-spaced wake-ups.
840pub const TOOLTIP_DWELL_STEPS: u32 = 4;
841
842/// Max pointer travel (logical px) from the hover-origin before a pending
843/// tooltip timer restarts. Mirrors Windows hover-tracking slop
844/// (`SPI_GETMOUSEHOVERWIDTH` / height, typically ~4 px): the tip waits for a
845/// *paused* pointer, not merely "entered the bounds."
846const TOOLTIP_STATIONARY_SLOP: f32 = 4.0;
847
848/// A tooltip attachment managed by the WidgetTree.
849struct TooltipEntry {
850    anchor_id: WidgetId,
851    content_id: WidgetId,
852    /// The widget whose accessibility node should carry this tooltip's
853    /// description, which is not always the node the overlay hangs off.
854    ///
855    /// A composing control anchors the *overlay* on an inner chrome node it
856    /// built -- the thing with the right bounds to open a tooltip against --
857    /// while its role, its name and its focusability live on its own outer
858    /// node. A description on the inner one is a description an assistive
859    /// technology never reads, because it never lands there.
860    ///
861    /// Recorded by `BuildContext`'s `attach_tooltip*` wrappers as the widget
862    /// that was building at the time. Defaults to `anchor_id`, which is both
863    /// the historic behaviour and the right answer for a widget that anchors
864    /// its tooltip on itself.
865    ///
866    /// Naming an owner is a *claim*, not a guarantee: the accessibility walk
867    /// honours it only where exactly one tooltip claims that node. See
868    /// `WidgetTree::build_accessibility_recursive`.
869    description_owner_id: WidgetId,
870    delay: std::time::Duration,
871    /// Simulated hover start (for deterministic tests via advance_time).
872    hover_start: Option<std::time::Instant>,
873    /// Real hover start (for windowed apps via layout).
874    real_hover_start: Option<std::time::Instant>,
875    /// Pointer position when the current pending hover started. Used to
876    /// restart the delay if the pointer keeps moving inside the anchor
877    /// (stationary-pointer intent filter).
878    hover_origin: Option<teksilo_canvas::Point>,
879    overlay_id: Option<crate::overlay::OverlayId>,
880    /// When set, the tooltip auto-promotes to "sticky" after this
881    /// much elapsed time since it was shown. The entry stays in the
882    /// table and is just flagged sticky — the difference is that
883    /// pointer-leave no longer dismisses it and the overlay's
884    /// dismiss behavior is swapped to `EscapeOrClickOutside`.
885    sticky_after: Option<std::time::Duration>,
886    /// True when the dwell timer reached `sticky_after`. Causes
887    /// `tooltip_pointer_leave` to skip the dismissal and lets the
888    /// overlay survive pointer-leave until the user explicitly
889    /// dismisses it via Escape or a click outside.
890    is_sticky: bool,
891    /// When the overlay was shown (simulated). Together with
892    /// `sticky_after` drives auto-promotion.
893    shown_at_sim: Option<std::time::Instant>,
894    /// When the overlay was shown (real).
895    shown_at_real: Option<std::time::Instant>,
896    /// Optional shared sink the tooltip widget can read from to
897    /// compute its own dwell progress. Mirrors `shown_at_real`:
898    /// set on show, cleared on dismissal. Used by `RichTooltipWidget`
899    /// to drive the dwell indicator without relying on a fragile
900    /// paint-gap heuristic.
901    shown_at_sink: Option<std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>>,
902    /// True when the tooltip was shown by the keyboard-focus path
903    /// rather than the pointer-hover path. Focus-promoted tooltips
904    /// dismiss when focus moves outside both the anchor and the
905    /// tooltip content subtree (preventing accumulation as the user
906    /// Tabs through a form); pointer-dwelled stickies survive
907    /// focus changes and only dismiss via Escape or click-outside.
908    promoted_by_focus: bool,
909    /// Set while this entry's pending delay was started by keyboard focus
910    /// rather than by the pointer. Decides, at show time, that the surface
911    /// dismisses on `Escape`/click-outside rather than on pointer-leave (there
912    /// is no pointer in the story), and that it counts as focus-shown.
913    armed_by_focus: bool,
914    /// Set while this entry's pending delay was started by a **hold** — the
915    /// tree-owned long-press route in [`touch_route`].
916    ///
917    /// Decides two things at show time that neither the hover nor the focus arm
918    /// wants: the surface dismisses on `Escape` / a press outside (a finger has
919    /// already lifted, so there is no pointer left to leave), and it carries an
920    /// auto-dismiss of [`touch_route::TOUCH_TOOLTIP_DISMISS`] — because with no
921    /// pointer to leave and no focus to move, nothing else would ever retire it.
922    armed_by_hold: bool,
923    /// Set when the tip was dismissed while the focus that summoned it is
924    /// still inside its anchor — i.e. Escape on a focus-promoted tooltip.
925    ///
926    /// Escape restores focus to the anchor, and that restore runs the ordinary
927    /// focus path, which ends in `tooltip_focus_enter`. Without this flag the
928    /// tip the user just dismissed re-opens on the same keystroke, because
929    /// `dormant_dismissed_content` has already cleared `overlay_id` by then and
930    /// the entry looks eligible again. Cleared when focus genuinely leaves the
931    /// anchor (`tooltip_focus_leave_outside`), so Tabbing away and back
932    /// re-summons it normally. The hover path needs no equivalent: a stationary
933    /// pointer never re-fires `tooltip_pointer_enter`.
934    suppressed_until_focus_leaves: bool,
935    /// Where the tooltip opens relative to its anchor. `Below` (default)
936    /// for the common case; `Side` for anchors stacked vertically (menu
937    /// items, a vertical tab strip, list/tree rows) so the tooltip does
938    /// not cover the next sibling. Consulted at show time in both the
939    /// hover (`process_tooltips_impl`) and focus (`tooltip_focus_enter`)
940    /// paths.
941    placement: crate::overlay::TooltipPlacement,
942}
943
944/// A delayed overlay request (e.g., submenu hover-open delay).
945struct PendingDelayedOverlay {
946    request: crate::overlay::OverlayRequest,
947    delay: std::time::Duration,
948    focus_target: Option<WidgetId>,
949    /// Dismiss the anchor's sibling overlays when this one finally
950    /// shows, instead of when it was requested. See
951    /// [`EventContext::show_overlay_after_replacing_siblings`](crate::widget::EventContext::show_overlay_after_replacing_siblings).
952    replace_siblings: bool,
953    /// When the request was made (real time, for windowed apps).
954    real_requested_at: std::time::Instant,
955    /// When the request was made (simulated time, for tests).
956    sim_requested_at: std::time::Instant,
957}
958
959impl WidgetTree {
960    pub fn new() -> Self {
961        let initial_theme = crate::presets::intui::light();
962        // One signal, shared: the field the tree writes and the registry the
963        // application reads must be the same one, or a host mirroring "is the
964        // caret in a text widget" would never see focus move.
965        let focused_signal = crate::signal::Signal::new(None);
966        // ONE epoch. `sim_clock` starts here and the input clock is anchored
967        // here, so a single `advance_time` moves gesture deadlines, tooltips,
968        // overlays and animations against the same origin.
969        let epoch = std::time::Instant::now();
970        // ONE scheduler. The fling pump shares the tree's per-frame table
971        // rather than making a second one, so a coast wakes the loop through
972        // the same path a `Pulse` does.
973        let scheduler = crate::frame_tick_scheduler::FrameTickScheduler::new();
974        Self {
975            arena: WidgetArena::new(),
976            theme: initial_theme.clone(),
977            theme_signal: crate::signal::Signal::new(initial_theme.clone()),
978            density_policy: teksilo_tokens::DensityPolicy::default(),
979            user_text_scale: 1.0,
980            effective_theme: initial_theme,
981            effective_text_scale: 1.0,
982            text_scale_signal: crate::signal::Signal::new(1.0),
983            // Starts active: winit may not send `Focused(true)` for the first
984            // window, so a window must not be born inactive (caret hidden,
985            // selection muted) before the first focus event arrives.
986            window_active_signal: crate::signal::Signal::new(true),
987            text_backend: None,
988            focused: None,
989            focused_signal: focused_signal.clone(),
990            pointers: crate::pointer::table::PointerTable::new(),
991            hovered_signal: crate::signal::Signal::new(None),
992            last_pointer_kind_signal: crate::signal::Signal::new(
993                teksilo_tokens::PointerKind::Mouse,
994            ),
995            focus_visible: crate::signal::Signal::new(false),
996            presses: crate::press::PressTable::default(),
997            view_focus_stack: Vec::new(),
998            previous_pointer_position: None,
999            pending_focus_restore: Vec::new(),
1000            pending_focus_request: None,
1001            last_interaction_anchors: Vec::new(),
1002            last_proposal: SizeProposal::exact(800.0, 600.0),
1003            pending_modal_requests: Vec::new(),
1004            pending_modal_dismissal: false,
1005            shortcut_registry: {
1006                let mut registry = crate::shortcut::ShortcutRegistry::new();
1007                crate::shortcut::register_standard_shortcuts(&mut registry);
1008                registry
1009            },
1010            pending_intents: Vec::new(),
1011            global_actions: Vec::new(),
1012            text_surfaces: crate::text_surface::TextSurfaces::new(focused_signal.clone()),
1013            key_capture: None,
1014            binding_registry: crate::binding::BindingRegistry::new(),
1015            idle_queue: crate::idle::IdleQueue::new(),
1016            sim_clock: epoch,
1017            input_clock: std::rc::Rc::new(crate::pointer::clock::MonotonicClock::new(epoch)),
1018            current_input: crate::pointer::InputSnapshot::default(),
1019            sim_time_frozen: false,
1020            sim_input_origin: None,
1021            sim_input_offset: std::time::Duration::ZERO,
1022            focus_origin: None,
1023            overlay_manager: crate::overlay::OverlayManager::new(),
1024            draining_dismiss: std::cell::Cell::new(false),
1025            tooltips: Vec::new(),
1026            tooltip_session_until_sim: None,
1027            tooltip_session_until_real: None,
1028            highlight_tooltip: None,
1029            layout_direction: crate::environment::LayoutDirection::default(),
1030            animation_scheduler: crate::animation::AnimationScheduler::new(),
1031            animated_values: Vec::new(),
1032            animated_quads: crate::animated_quad::AnimatedQuadRegistry::new(),
1033            frame_tick_scheduler: scheduler.clone(),
1034            touch_motion: pan_arbiter::TouchMotion::new(scheduler),
1035            armed_chain: None,
1036            paint_epoch: 0,
1037            cached_a11y: None,
1038            a11y_dirty: true,
1039            last_synced_shortcut_version: 0,
1040            last_synced_locale: None,
1041            last_synced_window_title: None,
1042            synthetic_parent_map: std::collections::HashMap::new(),
1043            synthetic_local_bounds: std::collections::HashMap::new(),
1044            a11y_walk_generation: 0,
1045            cached_frame: None,
1046            pending_dispatch: std::collections::VecDeque::new(),
1047            cancelled_pointers: Vec::new(),
1048            dispatch_depth: 0,
1049            current_cursor: crate::widget::CursorIcon::Default,
1050            node_declared_cursor: crate::widget::CursorIcon::Default,
1051            pending_delayed_overlays: Vec::new(),
1052            active_ids_scratch: Vec::new(),
1053            gesture_owners: std::collections::HashSet::new(),
1054            prefers_high_contrast: crate::signal::Signal::new(false),
1055            prefers_reduced_motion: false,
1056            text_scale_factor: 1.0,
1057            screen_reader: crate::environment::ScreenReaderState::default(),
1058            explore_by_touch: crate::environment::ExploreByTouch::default(),
1059            at_client_attached: false,
1060            density_announcement: None,
1061            context_menu_announcement: None,
1062            pending_touch_route: None,
1063            device_scale_factor: 1.0,
1064            device_scale_signal: crate::signal::Signal::new(1.0),
1065            off_thread: crate::off_thread::OffThreadRegistry::default(),
1066            safe_area: teksilo_canvas::EdgeInsets::ZERO,
1067            occluded_inset: None,
1068            soft_keyboard_request: None,
1069            active_drag: None,
1070            outbound_drag_source: None,
1071            os_drag_reentered: false,
1072            os_drop_accepted: None,
1073            title_bar_host: None,
1074            app_context: Rc::new(crate::event_source::TreeAppContext::empty()),
1075            locale: None,
1076            locale_signal: crate::signal::Signal::new(None),
1077            frame_tick: crate::signal::Signal::new(0.0_f32),
1078            frame_tick_requested: std::rc::Rc::new(std::cell::Cell::new(false)),
1079            frame_tick_armed_by_subscribers: false,
1080            in_focus_dispatch: std::rc::Rc::new(std::cell::Cell::new(false)),
1081            a11y_update_requested: std::rc::Rc::new(std::cell::Cell::new(false)),
1082            pending_wake_at: std::rc::Rc::new(std::cell::Cell::new(None)),
1083            pending_mount_actions: Vec::new(),
1084            last_frame_time: None,
1085            close_window_requested: false,
1086            force_close_requested: false,
1087            pending_locale_request: None,
1088            pending_theme_request: None,
1089            pending_follow_system_request: false,
1090            pending_text_scale_request: None,
1091            at_version: crate::signal::Signal::new(0),
1092            announcer_polite: crate::announcer::Announcer::new(
1093                crate::announcer::Politeness::Polite,
1094            ),
1095            announcer_assertive: crate::announcer::Announcer::new(
1096                crate::announcer::Politeness::Assertive,
1097            ),
1098            announcer_ids: crate::announcer::AnnouncerIds::new(),
1099            announcement_ring: crate::accessibility::announcements::AnnouncementRing::new(),
1100            adapter_ids: crate::accessibility::adapter_ids::AdapterIds::new(),
1101            description_memory: Default::default(),
1102            access_action_handled: false,
1103            window_state: None,
1104        }
1105    }
1106
1107    /// Construct an [`EventContext`]
1108    /// pre-populated with the tree's app-state registry, hosting
1109    /// `WindowState`, and a `&mut dyn WindowOps` handle so handlers
1110    /// can synchronously reach the multi-window API. Used by every
1111    /// dispatch site.
1112    pub(crate) fn make_event_context<'ops>(
1113        &self,
1114        ops: &'ops mut dyn crate::window::WindowOps,
1115    ) -> crate::widget::EventContext<'ops> {
1116        let drag_is_external = self.active_drag.as_ref().is_some_and(|d| d.is_external);
1117        // Read-only snapshot of tree query state that handlers may
1118        // need synchronously: the last pointer position and a
1119        // (content_id, bounds) slice of open overlays, both read by the
1120        // safe-triangle submenu hover gate, and the focused widget, read
1121        // by a container deciding whether a shortcut of its own should
1122        // yield to the widget the user is standing on.
1123        let overlay_snapshot: Vec<(
1124            crate::widget_id::WidgetId,
1125            teksilo_canvas::Rect,
1126            Option<teksilo_canvas::Point>,
1127        )> = self
1128            .overlay_manager
1129            .active_content_ids()
1130            .into_iter()
1131            .filter_map(|cid| {
1132                self.overlay_manager
1133                    .bounds_for_content(cid)
1134                    .map(|r| (cid, r, self.unexpired_safe_apex_for_content(cid)))
1135            })
1136            .collect();
1137        crate::widget::EventContext::new()
1138            .with_app_context(self.app_context.clone())
1139            .with_window_context(ops, self.window_state.clone())
1140            .with_drag_external(drag_is_external)
1141            .with_query_snapshot(
1142                self.last_pointer_position(),
1143                overlay_snapshot,
1144                self.focused(),
1145            )
1146            .with_pointer_captor(self.current_pointer_capture())
1147            .with_layout_direction(self.layout_direction)
1148            .with_window_active(self.is_window_active())
1149            .with_input_snapshot(self.current_input.clone())
1150            .with_touch_action(self.current_frozen_touch_action())
1151            .with_press(self.current_press_snapshot())
1152            .with_focus_dispatch_flag(self.in_focus_dispatch.clone())
1153    }
1154
1155    /// Run a closure with a fresh [`EventContext`] anchored at this
1156    /// tree, then collect any pending operations queued through the
1157    /// context. Intents are dispatched before returning; modal requests,
1158    /// frame requests and idle callbacks are collected for the event loop.
1159    ///
1160    /// Used by the `teksilo-app` event-loop dispatcher to deliver
1161    /// async-result callbacks (file dialogs, future background
1162    /// tasks) on the main thread with full handler-equivalent
1163    /// semantics. There is no source widget for app-level events,
1164    /// so intents are anchored at the tree's first root id (or
1165    /// silently dropped when the tree is empty).
1166    pub fn run_with_event_context<F>(&mut self, ops: &mut dyn crate::window::WindowOps, f: F)
1167    where
1168        F: FnOnce(&mut crate::widget::EventContext),
1169    {
1170        let mut ctx = self.make_event_context(ops);
1171        f(&mut ctx);
1172        let anchor = self.arena.roots().first().copied();
1173        if let Some(anchor_id) = anchor {
1174            self.collect_from_ctx(ctx, anchor_id);
1175            // App callbacks (including close guards) have no enclosing input
1176            // dispatch to drain this queue. Waiting for another key or pointer
1177            // event can leave a vetoed close with no confirmation dialog.
1178            self.drain_pending_intents(ops);
1179        } else {
1180            // Empty tree — nothing to anchor intents on. Drop ctx;
1181            // its only side effects (frame requests, cursor) are
1182            // not meaningful for an empty tree.
1183            drop(ctx);
1184        }
1185    }
1186
1187    /// Enqueue a one-shot action to run with a real
1188    /// [`EventContext`] after the current build,
1189    /// once the tree is mounted under its window. Used via
1190    /// [`BuildContext::run_after_mount`](crate::BuildContext::run_after_mount). Drained by
1191    /// [`Self::run_mount_actions`].
1192    pub(crate) fn queue_mount_action(
1193        &mut self,
1194        action: Box<dyn FnOnce(&mut crate::widget::EventContext)>,
1195    ) {
1196        self.pending_mount_actions.push(action);
1197    }
1198
1199    /// Whether any post-mount actions are waiting to run.
1200    pub fn has_pending_mount_actions(&self) -> bool {
1201        !self.pending_mount_actions.is_empty()
1202    }
1203
1204    /// Drain and run every queued post-mount action with a fresh
1205    /// [`EventContext`] built over `ops`. The app
1206    /// loop calls this each iteration with a real `WindowOps` sink (so
1207    /// `ctx.parent_window_handle()` resolves); headless tests call it with a
1208    /// `NoopWindowOps`. Actions enqueued *by* an action (rare) are left for the
1209    /// next drain rather than run re-entrantly.
1210    pub fn run_mount_actions(&mut self, ops: &mut dyn crate::window::WindowOps) {
1211        if self.pending_mount_actions.is_empty() {
1212            return;
1213        }
1214        let actions = std::mem::take(&mut self.pending_mount_actions);
1215        self.run_with_event_context(ops, move |ctx| {
1216            for action in actions {
1217                action(ctx);
1218            }
1219        });
1220    }
1221
1222    /// Attach the [`WindowState`](crate::window::WindowState) for this
1223    /// tree's hosting window. Called by `WindowManager::create_window`.
1224    pub fn set_window_state(&mut self, state: crate::window::WindowState) {
1225        self.window_state = Some(state);
1226    }
1227
1228    pub fn window_state(&self) -> Option<&crate::window::WindowState> {
1229        self.window_state.as_ref()
1230    }
1231
1232    /// The window's title as its accessible name: `None` without a window
1233    /// state or with a blank title, since a name that is empty is still a
1234    /// name to every adapter.
1235    pub(crate) fn window_title_for_accessibility(&self) -> Option<String> {
1236        let title = self.window_state.as_ref()?.title().get();
1237        (!title.trim().is_empty()).then_some(title)
1238    }
1239
1240    /// Clone the shared "frame requested" flag. Widgets stash this
1241    /// in their state and call `.set(true)` from inside frame-tick
1242    /// closures to chain-request another frame without needing
1243    /// mutable access to the tree. See `RichTextEditor` for the
1244    /// canonical use (caret blink, drag-select auto-scroll).
1245    pub fn frame_request_handle(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
1246        self.frame_tick_requested.clone()
1247    }
1248
1249    /// Clone the shared wake-at deadline cell. Widgets stash this in
1250    /// their state and call `request_wake_at` from frame-tick effects
1251    /// to schedule a one-shot deadline without keeping the event loop
1252    /// in `Poll` mode. On the next `layout()` at or past the deadline,
1253    /// the tree auto-arms `frame_tick_requested` so the effect runs on
1254    /// the wake-up pass. Canonical use: the rich text editor's caret
1255    /// blink schedules a 500 ms wake instead of pumping every frame.
1256    pub fn wake_at_handle(&self) -> std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>> {
1257        self.pending_wake_at.clone()
1258    }
1259
1260    /// Schedule a one-shot frame wake at `at`. Merges with any existing
1261    /// deadline — keeps the earlier instant so the most urgent wake
1262    /// wins.
1263    pub fn request_wake_at(&self, at: std::time::Instant) {
1264        let current = self.pending_wake_at.get();
1265        let merged = match current {
1266            Some(existing) if existing <= at => existing,
1267            _ => at,
1268        };
1269        self.pending_wake_at.set(Some(merged));
1270    }
1271
1272    /// The per-frame delta-seconds signal. Observers fire **only on frames
1273    /// the tree was asked to pump** via [`request_frame`](Self::request_frame);
1274    /// merely observing the signal does not keep the event loop awake.
1275    /// See `BuildContext::frame_tick` for widget-side access and
1276    /// `BuildContext::request_frame` for the opt-in request side.
1277    pub fn frame_tick(&self) -> crate::signal::Signal<f32> {
1278        self.frame_tick.clone()
1279    }
1280
1281    /// Ask the tree to pump exactly one more frame. `needs_redraw()`
1282    /// returns true until the request is consumed by the next
1283    /// `layout()` call, which fires the per-frame tick signal and
1284    /// clears the flag. Observers that still need more frames (drag
1285    /// auto-scroll, caret blink, pending document events) must call
1286    /// `request_frame()` again from inside their tick closure.
1287    ///
1288    /// Takes `&self` on purpose: widget handlers and per-frame effects
1289    /// receive a shared reference to the tree via `EventContext` /
1290    /// `BuildContext`, and the request flag is a `Cell` specifically so
1291    /// those shared paths can toggle it without ceremony.
1292    pub fn request_frame(&self) {
1293        self.frame_tick_requested.set(true);
1294    }
1295
1296    /// Request that the AccessKit tree be re-walked on the next
1297    /// [`sync_accessibility`](Self::sync_accessibility). Takes `&self` (the
1298    /// flag is a `Cell`) so handlers and `build()` closures reaching the tree
1299    /// through a shared reference can request a re-walk without `&mut` access.
1300    /// The drain at the top of `sync_accessibility` flips `a11y_dirty`.
1301    pub fn request_accessibility_update(&self) {
1302        self.a11y_update_requested.set(true);
1303    }
1304
1305    /// Speak `message` to the screen reader, politely.
1306    ///
1307    /// For anything the user needs told that is not the name of a widget: a
1308    /// completed action, a changed count, the result of an undo. The message is
1309    /// delivered on the next two accessibility syncs, which this schedules.
1310    ///
1311    /// Prefer `EventContext::announce` inside a handler and
1312    /// `BuildContext::announce` inside a build; this is the tree-level entry
1313    /// point both of those reach.
1314    ///
1315    /// Takes `impl Into<String>`, so `tr!(…)` works directly. See
1316    /// [`crate::announcer`] for why it is a `String` and not a
1317    /// `LocalizedString`, and for why an announcement beside a `Toast` says
1318    /// everything twice.
1319    pub fn announce(&mut self, message: impl Into<String>) {
1320        self.announce_with(message, crate::announcer::Politeness::Polite);
1321    }
1322
1323    /// Speak `message` to the screen reader at the given urgency.
1324    ///
1325    /// [`Politeness::Assertive`](crate::announcer::Politeness::Assertive)
1326    /// interrupts whatever is being spoken; reserve it for something the user
1327    /// must not miss and cannot recover by re-reading the screen.
1328    pub fn announce_with(
1329        &mut self,
1330        message: impl Into<String>,
1331        politeness: crate::announcer::Politeness,
1332    ) {
1333        match politeness {
1334            crate::announcer::Politeness::Polite => self.announcer_polite.push(message.into()),
1335            crate::announcer::Politeness::Assertive => {
1336                self.announcer_assertive.push(message.into())
1337            }
1338        }
1339        // A message is put in the tree by a sync, and a sync only happens on
1340        // a frame. Without both of these a message queued from a handler that
1341        // changed nothing visible would sit unspoken until something else
1342        // happened to redraw.
1343        self.request_accessibility_update();
1344        self.request_frame();
1345    }
1346
1347    /// Clone the shared "accessibility re-walk requested" flag, for the same
1348    /// stash-and-toggle pattern as [`frame_request_handle`](Self::frame_request_handle).
1349    pub fn a11y_request_handle(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
1350        self.a11y_update_requested.clone()
1351    }
1352
1353    /// Whether the next `layout()` will fire the per-frame tick: a frame
1354    /// was requested, or a render painted a frame-tick subscriber's owner
1355    /// and re-armed the chain. Exposed for tests and for the event-loop
1356    /// driver that decides when to schedule the next wake-up; *when* that
1357    /// frame is due is [`frame_tick_deadline`](Self::frame_tick_deadline).
1358    pub fn frame_requested(&self) -> bool {
1359        self.frame_tick_requested.get() || self.frame_tick_armed_by_subscribers
1360    }
1361
1362    /// Consume both reasons for a frame tick at once, returning whether
1363    /// there was one. Every path that fires `frame_tick` goes through here,
1364    /// so neither flag can outlive the tick it asked for.
1365    pub(crate) fn take_frame_tick_request(&mut self) -> bool {
1366        let requested = self.frame_tick_requested.replace(false);
1367        let armed = std::mem::take(&mut self.frame_tick_armed_by_subscribers);
1368        requested || armed
1369    }
1370
1371    /// The next wake-up deadline for the per-frame-effect path, or
1372    /// `None` when no per-frame effect is armed.
1373    ///
1374    /// This is the **60 Hz cap** for continuous per-frame animations
1375    /// (`Pulse`, caret blink, drag auto-scroll, `--cycle` drivers). The
1376    /// per-frame-effect path used to force `ControlFlow::Poll`, which
1377    /// free-runs at the display's refresh rate — so on a 300 Hz panel a
1378    /// single `Pulse`/`Cycle` rendered at 300 fps (measured ~45 % CPU) for
1379    /// motion that looks identical at 60 fps. Routing it through a fixed
1380    /// 16.667 ms deadline (folded into
1381    /// [`next_timer_deadline`](Self::next_timer_deadline)) makes it pace at
1382    /// 60 Hz regardless of refresh rate, matching the signal-tween
1383    /// [`AnimationScheduler`](crate::animation::AnimationScheduler) and
1384    /// shader-quad [`AnimatedQuadRegistry`](crate::animated_quad::AnimatedQuadRegistry),
1385    /// which already share the same interval.
1386    ///
1387    /// A **throttled** subscriber (registered via
1388    /// [`FrameTickScheduler::subscribe_throttled`](crate::frame_tick_scheduler::FrameTickScheduler::subscribe_throttled),
1389    /// e.g. `Cycle`, whose visible child only changes once per period)
1390    /// stretches the wait for **its own** tick to its interval: the loop
1391    /// then sleeps to the period instead of rendering identical 60 fps
1392    /// frames in between. The interval for the subscribers' re-arm is the
1393    /// **minimum across all currently-visible subscribers**, so a `Cycle`
1394    /// next to a `Pulse` still ticks at 60 Hz while a lone `Cycle` sleeps to
1395    /// its period.
1396    ///
1397    /// A frame asked for through [`request_frame`](Self::request_frame) or
1398    /// [`frame_request_handle`](Self::frame_request_handle) is due at 60 Hz
1399    /// whatever subscribers are visible, and the earlier of the two
1400    /// deadlines wins. The announcer, a caret, a drag auto-scroll and the
1401    /// bootstrap frame of a new subscription all ask that way, and none of
1402    /// them can be made to wait on somebody else's clock: an announcement
1403    /// queued behind a once-a-minute clock was spoken a minute late, or at
1404    /// the user's next key press.
1405    ///
1406    /// Paces from `last_frame_time` so the cadence is drift-free; before
1407    /// the first render it fires on the next loop turn.
1408    pub fn frame_tick_deadline(&self) -> Option<std::time::Instant> {
1409        const DEFAULT_INTERVAL: std::time::Duration = std::time::Duration::from_micros(16_667);
1410        // Each reason for a frame brings its own pace. A subscriber re-arm
1411        // with no visible subscriber left keeps the 60 Hz fallback, so the
1412        // one frame that finds nothing to re-arm still runs and the flag
1413        // does not linger.
1414        let requested = self.frame_tick_requested.get().then_some(DEFAULT_INTERVAL);
1415        let subscribed = self.frame_tick_armed_by_subscribers.then(|| {
1416            self.frame_tick_scheduler
1417                .min_visible_interval(&self.arena, self.paint_epoch)
1418                .unwrap_or(DEFAULT_INTERVAL)
1419        });
1420        let interval = requested.into_iter().chain(subscribed).min()?;
1421        Some(match self.last_frame_time {
1422            Some(prev) => prev + interval,
1423            None => std::time::Instant::now(),
1424        })
1425    }
1426
1427    /// Subscribe `owner` to the per-frame-effect scheduler. The
1428    /// returned [`FrameTickSubscription`](crate::frame_tick_scheduler::FrameTickSubscription)
1429    /// is an RAII guard — drop it (typically by replacing the field on
1430    /// the owning widget on rebuild, or letting the widget's `Drop`
1431    /// run) to remove the subscription. While the guard is alive, the
1432    /// tree will keep re-arming the frame tick after every render
1433    /// in which `owner` was painted, and stop on frames where it
1434    /// wasn't — so a subscribed widget hidden inside a non-selected
1435    /// `Switcher` branch contributes zero idle frames.
1436    ///
1437    /// Apps should not call this directly — use
1438    /// [`BuildContext::subscribe_frame_tick`](crate::build_context::BuildContext::subscribe_frame_tick)
1439    /// from inside `Widget::build`.
1440    pub fn subscribe_frame_tick(
1441        &self,
1442        owner: WidgetId,
1443    ) -> crate::frame_tick_scheduler::FrameTickSubscription {
1444        self.frame_tick_scheduler.subscribe(owner)
1445    }
1446
1447    /// Like [`subscribe_frame_tick`](Self::subscribe_frame_tick), but the
1448    /// owner only needs to wake **at most once per `interval`** while
1449    /// visible. Same visibility gate; between wakes the event loop sleeps
1450    /// to the interval deadline instead of rendering identical 60 fps
1451    /// frames. Use for effects whose visible output changes far less often
1452    /// than 60 Hz — e.g. `Cycle`'s once-per-period index advance.
1453    ///
1454    /// Apps should not call this directly — use
1455    /// [`BuildContext::subscribe_frame_tick_throttled`](crate::build_context::BuildContext::subscribe_frame_tick_throttled)
1456    /// from inside `Widget::build`.
1457    pub fn subscribe_frame_tick_throttled(
1458        &self,
1459        owner: WidgetId,
1460        interval: std::time::Duration,
1461    ) -> crate::frame_tick_scheduler::FrameTickSubscription {
1462        self.frame_tick_scheduler
1463            .subscribe_throttled(owner, interval)
1464    }
1465
1466    /// Advance the frame tick signal when (and only when) a frame was
1467    /// requested. Called by `layout()` before the scheduler tick so the
1468    /// per-frame observers fire on the same frame they asked for.
1469    pub(crate) fn advance_frame_tick(&mut self, now: std::time::Instant) {
1470        if !self.take_frame_tick_request() {
1471            self.last_frame_time = Some(now);
1472            return;
1473        }
1474        let delta = match self.last_frame_time {
1475            Some(prev) => {
1476                let d = now.saturating_duration_since(prev).as_secs_f32();
1477                // Clamp absurd deltas (pause/breakpoint) so observers never see a spike.
1478                d.clamp(0.0, 0.1)
1479            }
1480            None => 0.0,
1481        };
1482        self.last_frame_time = Some(now);
1483        self.frame_tick.set(delta);
1484    }
1485
1486    /// Replace the per-tree app context. Called by `teksilo-app` when
1487    /// constructing a window so the widget tree can reach the registered
1488    /// event source adapter and post subscription events through the
1489    /// event-loop proxy.
1490    pub fn set_app_context(&mut self, app_context: Rc<crate::event_source::TreeAppContext>) {
1491        self.app_context = app_context;
1492    }
1493
1494    /// Get the per-tree app context. Used by `BuildContext::subscribe_event`
1495    /// and by the event-loop handler when dispatching incoming
1496    /// `AppEvent::SubscriptionEvent`.
1497    pub fn app_context(&self) -> &Rc<crate::event_source::TreeAppContext> {
1498        &self.app_context
1499    }
1500
1501    /// Switch the tree-level locale at runtime.
1502    ///
1503    /// Updates `locale_signal` (a reactive `Signal<Option<String>>`) and marks
1504    /// all widgets dirty for relayout and repaint. Widgets are **not** rebuilt:
1505    /// per-string reactivity flows through `LocalizedString::to_signal()` which
1506    /// observes the teksilo-i18n manager, and anything else that depends on the
1507    /// tree-level locale can bind to `locale_signal()`.
1508    pub fn set_locale(&mut self, locale: String) {
1509        if self.locale.as_deref() == Some(locale.as_str()) {
1510            return;
1511        }
1512        let new = Some(locale);
1513        self.locale = new.clone();
1514        self.locale_signal.set(new);
1515        self.arena.mark_all_dirty();
1516    }
1517
1518    /// Currently active locale identifier, if any.
1519    pub fn locale(&self) -> Option<&str> {
1520        self.locale.as_deref()
1521    }
1522
1523    /// Reactive handle on the current locale. Mirrors `locale()` but updates
1524    /// observers when `set_locale` is called.
1525    pub fn locale_signal(&self) -> &crate::signal::Signal<Option<String>> {
1526        &self.locale_signal
1527    }
1528
1529    fn pointer_inside_overlay_region(
1530        &self,
1531        overlay_id: crate::overlay::OverlayId,
1532        position: Point,
1533    ) -> bool {
1534        let Some(overlay) = self
1535            .overlay_manager
1536            .stack
1537            .iter()
1538            .find(|overlay| overlay.id == overlay_id)
1539        else {
1540            return false;
1541        };
1542
1543        if self.arena.is_active(overlay.anchor)
1544            && self.arena.bounds(overlay.anchor).contains(position)
1545        {
1546            return true;
1547        }
1548
1549        self.overlay_manager.stack.iter().any(|candidate| {
1550            (candidate.id == overlay_id
1551                || self
1552                    .overlay_manager
1553                    .is_descendant_of(candidate.id, overlay_id))
1554                && candidate.bounds.contains(position)
1555        })
1556    }
1557
1558    /// Whether the overlay's safe region is armed and still within
1559    /// [`SAFE_REGION_BUDGET`](crate::overlay::SAFE_REGION_BUDGET).
1560    ///
1561    /// Both clocks are consulted and either expiring is enough: the real
1562    /// one drives a running app, the simulated one drives headless tests
1563    /// (`advance_time`), and an overlay armed in one and aged in the
1564    /// other must still expire.
1565    fn safe_region_unexpired(
1566        &self,
1567        overlay_id: crate::overlay::OverlayId,
1568        real_now: std::time::Instant,
1569        sim_now: std::time::Instant,
1570    ) -> bool {
1571        let Some(overlay) = self
1572            .overlay_manager
1573            .stack
1574            .iter()
1575            .find(|overlay| overlay.id == overlay_id)
1576        else {
1577            return false;
1578        };
1579        if overlay.safe_apex.is_none() {
1580            return false;
1581        }
1582        let budget = crate::overlay::SAFE_REGION_BUDGET;
1583        let real_ok = overlay
1584            .safe_apex_started_real
1585            .is_none_or(|started| real_now.saturating_duration_since(started) < budget);
1586        let sim_ok = overlay
1587            .safe_apex_started_sim
1588            .is_none_or(|started| sim_now.saturating_duration_since(started) < budget);
1589        real_ok && sim_ok
1590    }
1591
1592    /// The armed safe-triangle apex of the overlay rooted at
1593    /// `content_id`, or `None` when no region is armed or its budget is
1594    /// spent.
1595    ///
1596    /// This is the form the per-dispatch `EventContext` snapshot carries,
1597    /// because a widget asking "is a traversal toward that submenu still
1598    /// live?" must get the same answer the dismissal path acts on — an
1599    /// unfiltered apex outlives the region by up to a frame and makes a
1600    /// sibling row stand aside for a submenu the framework has already
1601    /// stopped protecting.
1602    fn unexpired_safe_apex_for_content(
1603        &self,
1604        content_id: crate::widget_id::WidgetId,
1605    ) -> Option<teksilo_canvas::Point> {
1606        // Cheap short-circuit first: nothing is armed in the common case,
1607        // and this runs once per node per pointer dispatch.
1608        let apex = self.overlay_manager.safe_apex_for_content(content_id)?;
1609        let id = self.overlay_manager.find_by_content(content_id)?;
1610        self.safe_region_unexpired(id, std::time::Instant::now(), self.sim_clock)
1611            .then_some(apex)
1612    }
1613
1614    fn update_pointer_leave_overlays(
1615        &mut self,
1616        position: Point,
1617        ops: &mut dyn crate::window::WindowOps,
1618    ) {
1619        let overlay_ids: Vec<crate::overlay::OverlayId> = self
1620            .overlay_manager
1621            .stack
1622            .iter()
1623            .filter(|overlay| {
1624                matches!(
1625                    overlay.dismiss,
1626                    crate::overlay::DismissBehavior::PointerLeave { .. }
1627                )
1628            })
1629            .map(|overlay| overlay.id)
1630            .collect();
1631
1632        let real_now = std::time::Instant::now();
1633        let sim_now = self.sim_clock;
1634
1635        for overlay_id in overlay_ids {
1636            let inside = self.pointer_inside_overlay_region(overlay_id, position);
1637            // A pointer travelling the safe triangle toward this overlay
1638            // is still "inside" as far as dismissal is concerned — that
1639            // is the whole point of the triangle. Without this the
1640            // grace period runs for the entire diagonal and closes the
1641            // submenu mid-flight, no matter what the hover handlers do.
1642            // See `overlay::safe_triangle`.
1643            let armed = self.safe_region_unexpired(overlay_id, real_now, sim_now);
1644            let travelling = !inside
1645                && armed
1646                && self
1647                    .overlay_manager
1648                    .point_in_safe_region(overlay_id, position);
1649            // Arrived, or returned to the trigger row: the traversal is
1650            // over and the ordinary rules apply again.
1651            //
1652            // Straying out of the cone deliberately does NOT disarm. The
1653            // cone is a needle at its apex — the first sample after the
1654            // pointer leaves the trigger row is a pixel or two away, so
1655            // whether it lands inside is one quantized step's direction
1656            // rather than the user's intent. Disarming there made that
1657            // sample final, because a region is only ever armed as the
1658            // pointer leaves the row and the pointer has already left:
1659            // nothing could re-arm it. Keeping it armed makes the cone a
1660            // per-sample hold that the grace below re-evaluates instead,
1661            // so a wobble mid-diagonal costs nothing while a real change
1662            // of mind still closes the submenu one close-delay later. The
1663            // budget is what retires a region for good; the frame pass
1664            // spends it even for a pointer that stopped moving entirely.
1665            if armed && inside {
1666                self.overlay_manager.clear_safe_region(overlay_id);
1667            }
1668            if let Some(overlay) = self
1669                .overlay_manager
1670                .stack
1671                .iter_mut()
1672                .find(|overlay| overlay.id == overlay_id)
1673            {
1674                if inside || travelling {
1675                    overlay.pointer_leave_started_real = None;
1676                    overlay.pointer_leave_started_sim = None;
1677                } else if overlay.pointer_leave_started_real.is_none() {
1678                    overlay.pointer_leave_started_real = Some(real_now);
1679                    overlay.pointer_leave_started_sim = Some(sim_now);
1680                    self.arena.mark_needs_paint(overlay.anchor);
1681                }
1682            }
1683        }
1684
1685        self.process_pointer_leave_overlays_real(&mut *ops);
1686    }
1687
1688    fn process_pointer_leave_overlays(&mut self) {
1689        let sim_now = self.sim_clock;
1690        let mut noop = crate::window::NoopWindowOps;
1691        self.process_pointer_leave_overlays_impl(
1692            |overlay| {
1693                overlay
1694                    .pointer_leave_started_sim
1695                    .map(|started| sim_now.saturating_duration_since(started))
1696            },
1697            &mut noop,
1698        );
1699    }
1700
1701    fn process_pointer_leave_overlays_real(&mut self, ops: &mut dyn crate::window::WindowOps) {
1702        let real_now = std::time::Instant::now();
1703        self.process_pointer_leave_overlays_impl(
1704            |overlay| {
1705                overlay
1706                    .pointer_leave_started_real
1707                    .map(|started| real_now.saturating_duration_since(started))
1708            },
1709            &mut *ops,
1710        );
1711    }
1712
1713    fn process_auto_dismiss_overlays(&mut self) {
1714        let sim_now = self.sim_clock;
1715        let mut noop = crate::window::NoopWindowOps;
1716        self.process_auto_dismiss_overlays_impl(
1717            |overlay| {
1718                overlay
1719                    .auto_dismiss_after
1720                    .map(|_| sim_now.saturating_duration_since(overlay.shown_at_sim))
1721            },
1722            &mut noop,
1723        );
1724    }
1725
1726    fn process_auto_dismiss_overlays_real(&mut self, ops: &mut dyn crate::window::WindowOps) {
1727        let real_now = std::time::Instant::now();
1728        self.process_auto_dismiss_overlays_impl(
1729            |overlay| {
1730                overlay
1731                    .auto_dismiss_after
1732                    .map(|_| real_now.saturating_duration_since(overlay.shown_at_real))
1733            },
1734            &mut *ops,
1735        );
1736    }
1737
1738    /// Drain overlays whose fade-out tween has completed (set up by
1739    /// `OverlayRequest::with_fade`). Parks the content as the normal
1740    /// dismiss path does, and hands focus back only when that leaves it
1741    /// nowhere ([`restore_focus_left_behind`](Self::restore_focus_left_behind)):
1742    /// the dismissal that started the fade may have meant to leave focus
1743    /// alone, and the user may have moved it on since; called once per layout pass
1744    /// after `process_auto_dismiss_overlays_real` so an overlay that
1745    /// hits its auto-dismiss deadline kicks off its fade-out tween
1746    /// in the same pass.
1747    pub(crate) fn process_overlay_fade_dismissals_real(
1748        &mut self,
1749        ops: &mut dyn crate::window::WindowOps,
1750    ) {
1751        let now = std::time::Instant::now();
1752        let pending = self.overlay_manager.process_pending_fade_dismissals(now);
1753        for (_id, dismissed, focus_restore) in pending {
1754            self.dormant_dismissed_content(&dismissed, &mut *ops);
1755            self.restore_focus_left_behind(focus_restore, &mut *ops);
1756        }
1757    }
1758
1759    /// Sim-clock variant for headless tests. Same shape as
1760    /// [`process_overlay_fade_dismissals_real`](Self::process_overlay_fade_dismissals_real)
1761    /// but reads `dismissing_started_sim`.
1762    pub(crate) fn process_overlay_fade_dismissals_sim(&mut self) {
1763        let mut noop = crate::window::NoopWindowOps;
1764        let pending = self
1765            .overlay_manager
1766            .process_pending_fade_dismissals_sim(self.sim_clock);
1767        for (_id, dismissed, focus_restore) in pending {
1768            self.dormant_dismissed_content(&dismissed, &mut noop);
1769            self.restore_focus_left_behind(focus_restore, &mut noop);
1770        }
1771    }
1772
1773    /// Hand focus to `restore` after an overlay has gone of its own accord
1774    /// (its auto-dismiss timer ran out, or its fade-out ended), but only when
1775    /// focus is left nowhere: the overlay held it and
1776    /// [`dormant_dismissed_content`](Self::dormant_dismissed_content) has just
1777    /// taken it away with the content, or nothing held it. Focus the user has
1778    /// moved elsewhere since the overlay opened stays where they put it. A tip
1779    /// that focus summoned holds its anchor as `restore`, and restoring that
1780    /// whatever held focus sent a reader who had Tabbed on back to the control
1781    /// they had just left.
1782    fn restore_focus_left_behind(
1783        &mut self,
1784        restore: Option<WidgetId>,
1785        ops: &mut dyn crate::window::WindowOps,
1786    ) {
1787        if let Some(restore) = restore
1788            && self.arena.is_active(restore)
1789            && self.focused.is_none_or(|id| !self.arena.is_active(id))
1790        {
1791            self.focus_ops(restore, ops);
1792        }
1793    }
1794
1795    fn process_auto_dismiss_overlays_impl(
1796        &mut self,
1797        elapsed_fn: impl Fn(&crate::overlay::ActiveOverlay) -> Option<std::time::Duration>,
1798        ops: &mut dyn crate::window::WindowOps,
1799    ) {
1800        let mut to_dismiss = Vec::new();
1801
1802        for overlay in self.overlay_manager.stack.iter().rev() {
1803            let Some(delay) = overlay.auto_dismiss_after else {
1804                continue;
1805            };
1806
1807            if to_dismiss
1808                .iter()
1809                .any(|ancestor| self.overlay_manager.is_descendant_of(overlay.id, *ancestor))
1810            {
1811                continue;
1812            }
1813
1814            if let Some(elapsed) = elapsed_fn(overlay)
1815                && elapsed >= delay
1816            {
1817                to_dismiss.push(overlay.id);
1818            }
1819        }
1820
1821        for overlay_id in to_dismiss {
1822            let (dismissed, focus_restore) =
1823                self.overlay_manager.dismiss_with_focus_restore(overlay_id);
1824            self.dormant_dismissed_content(&dismissed, &mut *ops);
1825            self.restore_focus_left_behind(focus_restore, &mut *ops);
1826        }
1827    }
1828
1829    fn process_pointer_leave_overlays_impl(
1830        &mut self,
1831        elapsed_fn: impl Fn(&crate::overlay::ActiveOverlay) -> Option<std::time::Duration>,
1832        ops: &mut dyn crate::window::WindowOps,
1833    ) {
1834        // Retire safe regions whose budget is spent. `update_pointer_leave_overlays`
1835        // does this too, but only when the pointer moves — and a pointer parked
1836        // inside the cone generates no moves at all, so without this pass the
1837        // submenu would stay open indefinitely. Expiring here also starts the
1838        // ordinary grace, so the overlay closes `delay` later exactly as if the
1839        // triangle had never been armed.
1840        let real_now = std::time::Instant::now();
1841        let sim_now = self.sim_clock;
1842        let expired: Vec<crate::overlay::OverlayId> = self
1843            .overlay_manager
1844            .stack
1845            .iter()
1846            .filter(|overlay| overlay.safe_apex.is_some())
1847            .map(|overlay| overlay.id)
1848            .filter(|id| !self.safe_region_unexpired(*id, real_now, sim_now))
1849            .collect();
1850        let budget = crate::overlay::SAFE_REGION_BUDGET;
1851        for id in expired {
1852            if let Some(overlay) = self
1853                .overlay_manager
1854                .stack
1855                .iter_mut()
1856                .find(|overlay| overlay.id == id)
1857                && overlay.pointer_leave_started_real.is_none()
1858            {
1859                // Backdate to the instant the budget ran out: the
1860                // pointer stopped counting as "inside" then, not when a
1861                // frame got around to noticing. Otherwise the grace
1862                // silently restarts from zero and the total wait
1863                // depends on the frame rate.
1864                let backdate = |started: Option<std::time::Instant>, now: std::time::Instant| {
1865                    started
1866                        .and_then(|s| s.checked_add(budget))
1867                        .map(|deadline| deadline.min(now))
1868                        .unwrap_or(now)
1869                };
1870                overlay.pointer_leave_started_real =
1871                    Some(backdate(overlay.safe_apex_started_real, real_now));
1872                overlay.pointer_leave_started_sim =
1873                    Some(backdate(overlay.safe_apex_started_sim, sim_now));
1874            }
1875            self.overlay_manager.clear_safe_region(id);
1876        }
1877
1878        let mut to_dismiss = Vec::new();
1879
1880        for overlay in self.overlay_manager.stack.iter().rev() {
1881            let crate::overlay::DismissBehavior::PointerLeave { delay } = overlay.dismiss else {
1882                continue;
1883            };
1884
1885            if to_dismiss
1886                .iter()
1887                .any(|ancestor| self.overlay_manager.is_descendant_of(overlay.id, *ancestor))
1888            {
1889                continue;
1890            }
1891
1892            if let Some(elapsed) = elapsed_fn(overlay)
1893                && elapsed >= delay
1894            {
1895                to_dismiss.push(overlay.id);
1896            }
1897        }
1898
1899        for overlay_id in to_dismiss {
1900            let (dismissed, focus_restore) =
1901                self.overlay_manager.dismiss_with_focus_restore(overlay_id);
1902            self.dormant_dismissed_content(&dismissed, &mut *ops);
1903            if let Some(restore_id) = focus_restore
1904                && self.arena.is_active(restore_id)
1905            {
1906                self.focus_ops(restore_id, &mut *ops);
1907            }
1908        }
1909    }
1910
1911    pub fn with_theme(mut self, theme: Theme) -> Self {
1912        // Update both the cached `Theme` AND the reactive
1913        // `theme_signal` — widgets that observe the signal (e.g.
1914        // `TextInputField` resetting the rich-text engine's default
1915        // text colour) would otherwise see the constructor's
1916        // `presets::intui::light()` initial value forever, even when
1917        // `TeksiloAppBuilder.theme(crate::presets::intui::dark())` was used.
1918        // `set_theme` already does this; `with_theme` was the
1919        // builder-time analogue that forgot to keep them aligned.
1920        self.theme = theme.clone();
1921        self.theme_signal.set(theme);
1922        self.recompute_effective_theme();
1923        self
1924    }
1925
1926    pub fn with_text_backend(
1927        mut self,
1928        backend: Rc<RefCell<dyn teksilo_canvas::TextBackend>>,
1929    ) -> Self {
1930        self.text_backend = Some(backend);
1931        self
1932    }
1933
1934    /// Attach a platform host for custom window chrome. Set by the
1935    /// `WindowManager` when the application opts in via
1936    /// `WindowConfig::custom_chrome(true)`. Widgets like `TitleBar` retrieve
1937    /// it from inside the root-builder closure via [`Self::title_bar_host`].
1938    pub fn with_title_bar_host(mut self, host: Rc<dyn crate::PlatformTitleBarHost>) -> Self {
1939        self.title_bar_host = Some(host);
1940        self
1941    }
1942
1943    pub fn set_title_bar_host(&mut self, host: Rc<dyn crate::PlatformTitleBarHost>) {
1944        self.title_bar_host = Some(host);
1945    }
1946
1947    /// Get the platform title bar host, if one was attached. Returns `None`
1948    /// when the application did not opt into custom chrome, or when the
1949    /// platform does not support it (X11 without an EWMH-capable window
1950    /// manager, or a headless build).
1951    pub fn title_bar_host(&self) -> Option<Rc<dyn crate::PlatformTitleBarHost>> {
1952        self.title_bar_host.clone()
1953    }
1954
1955    pub fn theme(&self) -> &Theme {
1956        &self.theme
1957    }
1958
1959    /// Reactive handle on the current theme. Updates fire when `set_theme`
1960    /// is called; widgets that want theme-derived values to stay live should
1961    /// build derived signals via `theme_signal.map(...)` or combine with
1962    /// other inputs using `.zip(...)`.
1963    pub fn theme_signal(&self) -> &crate::signal::Signal<Theme> {
1964        &self.theme_signal
1965    }
1966
1967    /// Whether any widget needs layout or paint (i.e., a redraw would be useful).
1968    ///
1969    /// Uses `has_running` rather than `has_active` so that animations
1970    /// parked by the window-inactive gate stop forcing the event loop
1971    /// into `ControlFlow::WaitUntil`. Without this, an unfocused window
1972    /// would still wake at the animation frame interval and the
1973    /// pause would save nothing. Both the signal scheduler AND the
1974    /// shader-driven animated-quad registry are consulted — a
1975    /// ProgressBar::indeterminate whose widget has no pending paint
1976    /// dirt still needs the loop to keep waking at the animation
1977    /// frame interval so its phase advances.
1978    pub fn needs_redraw(&self) -> bool {
1979        self.arena.any_needs_layout()
1980            || self.arena.any_needs_paint()
1981            || self.animation_scheduler.has_running()
1982            || self.animated_quads.has_running()
1983            || self.frame_requested()
1984    }
1985
1986    /// Whether a render pass is needed (any widget needs layout or paint).
1987    pub fn needs_render(&self) -> bool {
1988        self.arena.any_needs_layout() || self.arena.any_needs_paint()
1989    }
1990
1991    /// Whether this tree has reactive work that only a `layout()` pass can
1992    /// turn into arena dirt — i.e. whether reconciling it right now could
1993    /// change what [`needs_render`](Self::needs_render) reports.
1994    ///
1995    /// Read-only and cheap: `O(unique bound sources)` `u64` comparisons
1996    /// plus one peek per registered animated signal. No arena walk, no
1997    /// rebuilds, no geometry. Asking does not consume the answer, so it
1998    /// can be asked every dispatch.
1999    ///
2000    /// # Why this is exactly the right question, and no broader
2001    ///
2002    /// `teksilo_app::WindowManager::request_redraw_needing_render` exists
2003    /// for ONE case: a handler in window A wrote a `Signal` that window
2004    /// B's widgets also bind, and B — which never saw the event — must be
2005    /// reconciled before anyone can tell it needs repainting. Every OTHER
2006    /// thing `layout_with_ops` drives already has its own scheduling
2007    /// path and does not need this sweep:
2008    ///
2009    /// - tooltip dwell + sticky steps, delayed overlays, auto-dismiss,
2010    ///   overlay fades, the animation scheduler, animated quads,
2011    ///   gestures, `wake_at` and the 60 Hz frame tick are all timing
2012    ///   driven, and every one of them contributes to
2013    ///   [`next_timer_deadline`](Self::next_timer_deadline) — which
2014    ///   `request_redraw_due` polls to wake precisely the due windows;
2015    /// - drag ticks follow that window's own pointer stream;
2016    /// - a handler that called `request_rebuild` marked the arena
2017    ///   directly, so `needs_render()` is already true without any
2018    ///   reconcile.
2019    ///
2020    /// So the two terms below are what the sweep uniquely covers:
2021    /// binding-registry staleness (the whole point), and a *pending*
2022    /// `animate_to` — which the scheduler has not started yet, so it
2023    /// contributes no deadline, and which only `process_pending_animations`
2024    /// (inside `layout`) can promote into one. The second term is
2025    /// belt-and-braces: arming an animation also advances the signal's
2026    /// generation, so a bound animated signal is already covered by the
2027    /// first — but an animated signal registered without being bound
2028    /// would not be, and this makes that impossible to get wrong.
2029    pub fn needs_reconcile(&self) -> bool {
2030        self.binding_registry.any_dirty()
2031            || self
2032                .animated_values
2033                .iter()
2034                .any(AnimatedRegistration::has_pending_animation)
2035    }
2036
2037    /// Register a `Signal<f32>` for animation support. The framework
2038    /// checks registered signals each frame for pending `animate_to`
2039    /// requests. Called automatically by `BuildContext::animated_signal()`
2040    /// — `owner` is `ctx.self_id()` of the widget whose `build()` created
2041    /// the signal. Used by the scheduler to pause/cancel animations when
2042    /// the owning widget is offscreen, dormant, or destroyed.
2043    pub fn register_animated_signal(
2044        &mut self,
2045        signal: &crate::signal::Signal<f32>,
2046        owner: WidgetId,
2047    ) {
2048        self.animated_values
2049            .retain(|registration| registration.is_alive());
2050        if let Some(existing) = self
2051            .animated_values
2052            .iter_mut()
2053            .find(|registration| registration.same_signal(signal))
2054        {
2055            // Signal may have been registered earlier with a placeholder
2056            // owner (e.g. a widget field constructed pre-build and
2057            // re-registered during build()) — prefer the latest owner.
2058            existing.owner = owner;
2059            return;
2060        }
2061        if let Some(weak_signal) = signal.weak_handle() {
2062            self.animated_values.push(AnimatedRegistration {
2063                weak: weak_signal,
2064                owner,
2065            });
2066        }
2067    }
2068
2069    /// Whether any animation is currently running.
2070    pub fn has_active_animations(&self) -> bool {
2071        self.animation_scheduler.has_active()
2072    }
2073
2074    /// Pick up pending `animate_to` requests from registered signals
2075    /// and start them on the animation scheduler.
2076    fn process_pending_animations(&mut self) {
2077        let now = self.animation_clock();
2078        self.process_pending_animations_at(now);
2079    }
2080
2081    /// Pick up pending animations using the given time (for sim clock).
2082    fn process_pending_animations_at(&mut self, now: std::time::Instant) {
2083        let mut pending = Vec::new();
2084        self.animated_values.retain(|registration| {
2085            if let Some(animation) = registration.take_pending_animation() {
2086                pending.push(animation);
2087                true
2088            } else {
2089                registration.is_alive()
2090            }
2091        });
2092
2093        for (signal, req, owner) in pending {
2094            if req.looping {
2095                let start = signal.get();
2096                self.animation_scheduler.animate_looping(
2097                    &signal,
2098                    owner,
2099                    start,
2100                    req.target,
2101                    req.duration,
2102                    req.easing,
2103                    req.frame_interval,
2104                    req.epsilon,
2105                    req.max_duration,
2106                    now,
2107                );
2108            } else {
2109                self.animation_scheduler.animate_with_options(
2110                    &signal,
2111                    owner,
2112                    req.target,
2113                    req.duration,
2114                    req.easing,
2115                    req.frame_interval,
2116                    req.epsilon,
2117                    req.max_duration,
2118                    now,
2119                );
2120            }
2121        }
2122    }
2123
2124    /// Mark every node paint-dirty (no relayout, no rebuild) so the next
2125    /// render re-runs their `paint()`. What teksilo-app does on receiving the
2126    /// deprecated [`RepaintWindowRequest`](crate::RepaintWindowRequest); a
2127    /// widget whose content changes off the UI thread attaches a
2128    /// [`RepaintTrigger`](crate::RepaintTrigger) instead, which repaints that
2129    /// widget alone.
2130    pub fn mark_all_needs_paint_only(&mut self) {
2131        self.arena.mark_all_needs_paint_only();
2132    }
2133
2134    /// Mark a widget as needing repaint.
2135    pub fn mark_needs_paint(&mut self, id: WidgetId) {
2136        self.arena.mark_needs_paint(id);
2137    }
2138
2139    /// Invalidate all per-widget paint caches (paint AND post-paint) and
2140    /// the assembled frame cache. Forces every widget to repaint on the
2141    /// next `render()` call. Used by the glyph-atlas eviction recovery:
2142    /// after an eviction, any retained frame may hold quads whose atlas
2143    /// UVs now point at recycled slots.
2144    pub fn invalidate_all_paints(&mut self) {
2145        for id in self.arena.active_ids() {
2146            if let Some(node) = self.arena.get_mut(id) {
2147                node.dirty.needs_paint = true;
2148                node.cached_paint = None;
2149                node.cached_post_paint = None;
2150            }
2151        }
2152        self.cached_frame = None;
2153    }
2154
2155    /// Mark the owning window as active (focused AND not occluded) or
2156    /// inactive. Propagates to the animation scheduler and to the
2157    /// animated-quad registry, which do not pause the same things:
2158    ///
2159    /// - The scheduler pauses only its **looping** animations: they stop
2160    ///   ticking and stop waking frames, and resume where they were when the
2161    ///   window is active again. A one-shot tween keeps running to its end,
2162    ///   and keeps the frame clock awake until it does, so whatever it
2163    ///   drives (a `Collapse` opened by an assistive technology, say) does
2164    ///   not stop partway. See
2165    ///   [`AnimationScheduler::set_window_active`](crate::animation::AnimationScheduler::set_window_active).
2166    /// - The animated-quad registry pauses all of its shader loops: no ticks,
2167    ///   no frame wakes and no GPU submits until the window is active again.
2168    ///
2169    /// On an actual state change it also fires `window_active_signal`
2170    /// (so build-time binders and `DimWhenInactive` react) and issues a
2171    /// global paint-only dirty mark, so every widget that reads
2172    /// `PaintContext::window_active` (caret gates, selection bands) repaints
2173    /// once. This is a repaint, not a relayout: geometry never changes when
2174    /// the window's active state flips (the caret keeps its space). Window
2175    /// focus changes are rare and user-driven, so the O(n) mark is cheap and
2176    /// strictly lighter than `set_theme`'s `mark_all_dirty` (layout + paint).
2177    pub fn set_window_active(&mut self, active: bool) {
2178        let mut noop = crate::window::NoopWindowOps;
2179        self.set_window_active_with_ops(active, &mut noop);
2180    }
2181
2182    /// [`set_window_active`](Self::set_window_active) with the caller's
2183    /// app-level [`WindowOps`](crate::window::WindowOps) sink, so the
2184    /// `on_pointer_cancel` handlers a deactivation fires can reach the
2185    /// multi-window API like any other handler.
2186    pub fn set_window_active_with_ops(
2187        &mut self,
2188        active: bool,
2189        ops: &mut dyn crate::window::WindowOps,
2190    ) {
2191        // The scheduler is measured on the tree's animation axis, so its pause
2192        // mark has to be taken there too — a pause stamped on the wall clock
2193        // while time is simulated would rebase every animation by the gap
2194        // between the axes on resume. The shader-driven quad registry has no
2195        // simulated door at all (it is ticked from `render()`), so it keeps the
2196        // wall clock.
2197        self.animation_scheduler
2198            .set_window_active(active, self.animation_clock());
2199        self.animated_quads
2200            .set_window_active(active, std::time::Instant::now());
2201        if self.window_active_signal.get() != active {
2202            self.window_active_signal.set(active);
2203            self.arena.mark_all_needs_paint_only();
2204            if !active {
2205                // A held pointer first, before any other state is cleared: a
2206                // widget that captured the pointer for a drag (a column-resize
2207                // grip, a splitter divider, a scrollbar thumb, a slider) will
2208                // never see the matching `PointerUp` — the user releases the
2209                // button over the window that took focus, and this window is
2210                // told nothing. Releasing the capture silently, which is what
2211                // this used to do, strands the widget instead: it keeps the
2212                // half of the interaction it owns, with no event left that
2213                // could clear it. The cancel funnel releases the capture *and*
2214                // tells it, so it can let go.
2215                self.cancel_all_pointers(
2216                    crate::pointer::CancelReason::WindowDeactivated,
2217                    &mut *ops,
2218                );
2219                // The pointer has left for another window; the OS sends no
2220                // leave event we can rely on, so a tooltip shown at the moment
2221                // of the switch would float over the newly-focused window's
2222                // chrome with nothing left to dismiss it. Retire tips and
2223                // cancel pending dwells — but leave *sticky* ones, which the
2224                // user pinned deliberately and expects to find on return.
2225                self.tooltip_window_deactivated();
2226            }
2227        }
2228    }
2229
2230    /// Whether the owning window is currently active (`focused AND not
2231    /// occluded`). The reactive companion is [`Self::window_active_signal`].
2232    pub fn is_window_active(&self) -> bool {
2233        self.window_active_signal.get()
2234    }
2235
2236    /// Reactive handle on window-active state. Fires when the window gains or
2237    /// loses active status. Bind at [`BindingLevel::RepaintOnly`] — an
2238    /// active-state flip never affects geometry. Starts `true`.
2239    ///
2240    /// [`BindingLevel::RepaintOnly`]: crate::binding::BindingLevel::RepaintOnly
2241    pub fn window_active_signal(&self) -> crate::signal::Signal<bool> {
2242        self.window_active_signal.clone()
2243    }
2244
2245    /// Register a new animated quad for the currently-building widget.
2246    /// Called by [`crate::build_context::BuildContext::animated_quad`];
2247    /// returns an opaque handle the widget stashes for its `paint()`
2248    /// call.
2249    pub fn register_animated_quad(
2250        &mut self,
2251        owner: WidgetId,
2252        kind: crate::animated_quad::AnimatedQuadKind,
2253    ) -> crate::animated_quad::AnimatedQuadHandle {
2254        self.animated_quads
2255            .register(owner, kind, std::time::Instant::now())
2256    }
2257
2258    /// Active animated-quad slot count. Test / debug helper.
2259    pub fn animated_quad_count(&self) -> usize {
2260        self.animated_quads.active_count()
2261    }
2262
2263    /// Advance animations by simulated time (for deterministic testing).
2264    ///
2265    /// An **alias** of [`advance_time`](Self::advance_time), not a second door.
2266    /// It was one once, and the two moved disjoint halves of the tree from
2267    /// clocks they each advanced independently: a caller that wanted both had
2268    /// to call both, which advanced simulated time twice, and a caller that
2269    /// wanted one silently froze the other — an animation and the fling it was
2270    /// racing could not be moved to the same instant by any sequence of calls.
2271    /// Kept as a name rather than folded away because it reads correctly at
2272    /// its ~120 call sites, all of which mean "advance the clock".
2273    pub fn tick_animations(&mut self, duration: std::time::Duration) {
2274        self.advance_time(duration);
2275    }
2276
2277    /// Switch the tree-level theme at runtime.
2278    ///
2279    /// Updates `theme_signal` (a reactive `Signal<Theme>`) and marks all widgets
2280    /// dirty for relayout and repaint. Widgets are **not** rebuilt: the
2281    /// `LayoutContext` and `PaintContext` already resolve the current theme on
2282    /// every pass, and any widget that derives state from theme tokens should
2283    /// do so through a `theme_signal()` subscription rather than a build-time
2284    /// capture. Preserves focus, scroll offsets, and other interaction state.
2285    pub fn set_theme(&mut self, theme: Theme) {
2286        self.theme = theme.clone();
2287        self.theme_signal.set(theme);
2288        self.recompute_effective_theme();
2289        self.arena.mark_all_dirty();
2290        // A theme change re-resolves typography, so every label re-shapes —
2291        // inside bounds the layout pass may leave untouched, which means no
2292        // resize is recorded and nothing else would dirty the tree. The
2293        // label's lines, and therefore its text runs, can be a different
2294        // set at the same size.
2295        self.a11y_dirty = true;
2296    }
2297
2298    /// The [`TargetDensity`] the active theme was projected onto.
2299    ///
2300    /// [`TargetDensity`]: teksilo_tokens::TargetDensity
2301    pub fn input_density(&self) -> teksilo_tokens::TargetDensity {
2302        self.theme.input.density
2303    }
2304
2305    /// Project the active theme onto another density and **rebuild** the tree.
2306    ///
2307    /// A rebuild, not [`Self::set_theme`]'s `mark_all_dirty()`: a target size is
2308    /// baked in `build()` (a `MinSize` wrapper, a recipe's `Rc<dyn FooStyle>`,
2309    /// the number of `Toolbar` items that fit), and marking layout + paint
2310    /// cannot re-bake it. This reuses the exact path a
2311    /// `BindingLevel::Rebuild` binding takes — `mark_needs_rebuild` on each
2312    /// root plus `mark_ancestors_need_layout` — so the next layout pass drains
2313    /// it through `process_rebuilds`, which already handles focus restoration,
2314    /// the a11y re-walk and interaction-state revalidation.
2315    ///
2316    /// A no-op when the density is already the requested one: a density switch
2317    /// throws away every widget id in the tree, so it must not fire on a
2318    /// repeated set.
2319    ///
2320    /// [`TargetDensity`]: teksilo_tokens::TargetDensity
2321    pub fn set_input_density(&mut self, density: teksilo_tokens::TargetDensity) {
2322        if self.theme.input.density == density {
2323            return;
2324        }
2325        // Exactly one utterance per switch, guaranteed by the guard above:
2326        // every widget id in the tree is about to be thrown away, so a screen
2327        // reader that was reading one is about to lose its place and needs to
2328        // be told what happened.
2329        if let Some(wording) = self.density_announcement.clone() {
2330            self.announce(wording(density));
2331        }
2332        self.set_theme(self.theme.with_density(density));
2333        // The `BindingLevel::Rebuild` arm of `process_state_changes`
2334        // (`widget_tree/layout_impl.rs`), applied at every root.
2335        for root in self.arena.roots() {
2336            self.arena.mark_needs_rebuild(root);
2337            self.arena.mark_ancestors_need_layout(root);
2338        }
2339    }
2340
2341    /// Announce density switches to a screen reader, in the application's own
2342    /// words.
2343    ///
2344    /// A density switch rebuilds the entire tree, so a screen reader loses its
2345    /// place and the user hears no explanation for it. Registering a wording
2346    /// makes [`Self::set_input_density`] speak once — and only once — per real
2347    /// switch, through the same [`Self::announce`] path everything else uses.
2348    ///
2349    /// The wording is the application's because it cannot be the framework's:
2350    /// `teksilo-i18n` depends on this crate, so nothing here can name
2351    /// `LocalizedString` or reach a translation bundle, and a hardcoded English
2352    /// sentence spoken into a French screen reader is worse than silence. Pass
2353    /// a closure that resolves `tr!(…)`:
2354    ///
2355    /// ```ignore
2356    /// tree.set_density_announcement(Some(std::rc::Rc::new(|d| match d {
2357    ///     TargetDensity::Compact => tr!(layout_compact()).into(),
2358    ///     TargetDensity::Comfortable => tr!(layout_comfortable()).into(),
2359    ///     TargetDensity::Touch => tr!(layout_touch()).into(),
2360    /// })));
2361    /// ```
2362    ///
2363    /// `None` — the default — announces nothing.
2364    pub fn set_density_announcement(
2365        &mut self,
2366        wording: Option<std::rc::Rc<dyn Fn(teksilo_tokens::TargetDensity) -> String>>,
2367    ) {
2368        self.density_announcement = wording;
2369    }
2370
2371    /// How to word "a context menu opened" for a screen reader, when the menu
2372    /// was opened by a **hold**.
2373    ///
2374    /// The other three routes need nothing: a secondary press, `Shift+F10` and
2375    /// the AccessKit `ShowContextMenu` action are all deliberate, and the menu
2376    /// takes focus, which is announcement enough. A hold is the one route whose
2377    /// user cannot see the menu appear — a finger is on top of where it opens —
2378    /// and which they may not have meant.
2379    ///
2380    /// The wording is the application's for the same reason
2381    /// [`Self::set_density_announcement`]'s is: `teksilo-i18n` depends on this
2382    /// crate, so nothing here can name a `LocalizedString`, and a hardcoded
2383    /// English sentence spoken into a French screen reader is worse than
2384    /// silence.
2385    ///
2386    /// ```ignore
2387    /// tree.set_context_menu_announcement(Some(std::rc::Rc::new(|| {
2388    ///     tr!(context_menu_opened()).into()
2389    /// })));
2390    /// ```
2391    ///
2392    /// `None` — the default — announces nothing. Where it is set, the
2393    /// announcement is still suppressed if the **pressed node's** own subtree
2394    /// already carries a live region — the widget speaking for itself, so the
2395    /// framework does not speak over it — through
2396    /// [`Self::announce_unless_widget_speaks`]. The menu's own subtree is not
2397    /// consulted: it is raised by this very call and has not been walked yet.
2398    pub fn set_context_menu_announcement(
2399        &mut self,
2400        wording: Option<std::rc::Rc<dyn Fn() -> String>>,
2401    ) {
2402        self.context_menu_announcement = wording;
2403    }
2404
2405    /// Speak `message`, unless `widget` already speaks for itself.
2406    ///
2407    /// A framework announcement that lands beside a widget's own live region
2408    /// says everything twice — the failure mode [`crate::announcer`] warns
2409    /// about for `Toast`. This is the check that avoids it: if the last
2410    /// accessibility tree carried a live region *inside* `widget`'s subtree
2411    /// that the platform adapters would speak for (in the filtered tree, with
2412    /// a name), the widget is already talking and this stays quiet. A live
2413    /// region that is hidden, or that carries its text where its role takes
2414    /// no name from, is silent on every platform and does not count.
2415    /// Returns whether the message was queued.
2416    ///
2417    /// It necessarily reads **one tree behind**. An announcement is queued
2418    /// during event dispatch; the live-region text it would duplicate is
2419    /// whatever the *last* built update carried, because the next one has not
2420    /// been built yet. A widget that speaks for the first time in the same
2421    /// dispatch is therefore not yet visible here — which is the right bias:
2422    /// it errs toward saying something rather than toward silence.
2423    pub fn announce_unless_widget_speaks(
2424        &mut self,
2425        widget: WidgetId,
2426        message: impl Into<String>,
2427    ) -> bool {
2428        if self.widget_subtree_speaks(widget) {
2429            return false;
2430        }
2431        self.announce(message);
2432        true
2433    }
2434
2435    /// Whether the last delivered accessibility tree had a live node in
2436    /// `widget`'s subtree that the platform adapters would speak for.
2437    ///
2438    /// Read through `accesskit_consumer`, so by the adapters' rules, whether
2439    /// or not this tree records announcements: the node is in the filtered
2440    /// tree (not hidden, not inside a hidden subtree), it is live, itself or
2441    /// through an ancestor, and it has a name, which for a `Status` is its
2442    /// label and not its value.
2443    /// A live region no platform can hear does not speak, and must not keep
2444    /// the framework quiet. The politeness must be set inside the subtree,
2445    /// too: a widget that is live only because it sits in somebody else's
2446    /// live region is not the one speaking.
2447    ///
2448    /// Synthetic children count too, resolved to their owning widget through
2449    /// the same parent map the AccessKit action router uses. `teksilo-scene`
2450    /// can mark a scene item as a live region, and a live region is a live
2451    /// region wherever it was emitted from.
2452    fn widget_subtree_speaks(&self, widget: WidgetId) -> bool {
2453        let mut wanted: std::collections::HashSet<accesskit::NodeId> =
2454            std::collections::HashSet::new();
2455        let mut stack = vec![widget];
2456        while let Some(id) = stack.pop() {
2457            if self.arena.get(id).is_none() {
2458                continue;
2459            }
2460            wanted.insert(crate::accessibility::widget_id_to_node_id(id));
2461            stack.extend_from_slice(self.arena.children(id));
2462        }
2463        let Some(update) = &self.cached_a11y else {
2464            return false;
2465        };
2466        crate::accessibility::announcements::speaks_within(update, |node_id| {
2467            wanted.contains(&node_id)
2468                || self
2469                    .synthetic_parent_map
2470                    .get(&node_id)
2471                    .is_some_and(|owner| {
2472                        wanted.contains(&crate::accessibility::widget_id_to_node_id(*owner))
2473                    })
2474        })
2475    }
2476
2477    /// How the active density is chosen. See [`DensityPolicy`].
2478    ///
2479    /// [`DensityPolicy`]: teksilo_tokens::DensityPolicy
2480    pub fn density_policy(&self) -> teksilo_tokens::DensityPolicy {
2481        self.density_policy
2482    }
2483
2484    /// Set the density-selection policy.
2485    ///
2486    /// Storing a `Fixed(d)` policy does **not** by itself switch the density —
2487    /// call [`Self::set_input_density`] for that. `FollowLastPointer` is not
2488    /// acted on either: it is state and an accessor, and the field's own
2489    /// documentation says what is still undecided about honouring it.
2490    pub fn set_density_policy(&mut self, policy: teksilo_tokens::DensityPolicy) {
2491        self.density_policy = policy;
2492    }
2493
2494    /// Whether touch input is accepted. See
2495    /// [`InputTokens::touch_enabled`](teksilo_tokens::InputTokens::touch_enabled).
2496    pub fn touch_enabled(&self) -> bool {
2497        self.theme.input.touch_enabled
2498    }
2499
2500    /// The runtime touch kill switch. With `false`, the platform translator
2501    /// drops touch input and the router installs no touch-only recognizers,
2502    /// so an app can fall back to mouse-only behaviour at runtime.
2503    ///
2504    /// Repaint-level only: turning touch off changes which events are accepted,
2505    /// never a dimension, so nothing is rebuilt or relaid out here. (The
2506    /// translator and router honour it from P08 / P15; this is the state.)
2507    pub fn set_touch_enabled(&mut self, enabled: bool) {
2508        if self.theme.input.touch_enabled == enabled {
2509            return;
2510        }
2511        let mut theme = self.theme.clone();
2512        theme.input.touch_enabled = enabled;
2513        self.theme = theme.clone();
2514        self.theme_signal.set(theme);
2515        self.recompute_effective_theme();
2516    }
2517
2518    /// Recompute [`Self::effective_theme`] from the current `theme` and the
2519    /// combined text scale (`user_text_scale * text_scale_factor`). Callers
2520    /// that change either input are responsible for `mark_all_dirty()`.
2521    fn recompute_effective_theme(&mut self) {
2522        let combined = (self.user_text_scale as f64 * self.text_scale_factor) as f32;
2523        self.effective_theme = if (combined - 1.0).abs() < f32::EPSILON {
2524            self.theme.clone()
2525        } else {
2526            let mut t = self.theme.clone();
2527            t.typography = t.typography.scaled(combined);
2528            t
2529        };
2530        // Single source: every downstream consumer (the layout/paint context
2531        // `text_scale` field, the reactive `text_scale_signal`) reads from here.
2532        self.effective_text_scale = combined;
2533        self.text_scale_signal.set(combined);
2534    }
2535
2536    /// The combined effective text scale (`user_text_scale * OS text_scale_factor`).
2537    /// Read by the layout/paint walkers to populate `ctx.text_scale` for widgets
2538    /// that size from a source other than `Theme.typography`.
2539    pub fn effective_text_scale(&self) -> f32 {
2540        self.effective_text_scale
2541    }
2542
2543    /// Reactive handle on [`Self::effective_text_scale`]. Build-time binders that
2544    /// must react to a scale change without their own rebuild path bind this
2545    /// (e.g. `Calendar` binds it at `Rebuild` level so its fixed cell constants
2546    /// recompute). Fires on `set_user_text_scale` / theme / OS-pref change.
2547    pub fn text_scale_signal(&self) -> crate::signal::Signal<f32> {
2548        self.text_scale_signal.clone()
2549    }
2550
2551    /// Set the user-controlled global text-scale factor (`1.0` = 100 %).
2552    ///
2553    /// The factor multiplies with the OS accessibility text-scale preference to
2554    /// produce the rendered scale. Recomputes the effective theme and marks all
2555    /// widgets dirty so every text widget grows on the next pass; no rebuild,
2556    /// so focus/scroll/interaction state survive. Values outside `[0.25, 8.0]`
2557    /// are clamped. Persisted by the application via
2558    /// `teksilo_settings::TEXT_SCALE_KEY`.
2559    pub fn set_user_text_scale(&mut self, factor: f32) {
2560        let clamped = factor.clamp(0.25, 8.0);
2561        if (self.user_text_scale - clamped).abs() < f32::EPSILON {
2562            return;
2563        }
2564        self.user_text_scale = clamped;
2565        self.recompute_effective_theme();
2566        self.arena.mark_all_dirty();
2567        // Same reasoning as `set_theme`: bigger text re-wraps inside a box
2568        // whose size the parent may hold fixed.
2569        self.a11y_dirty = true;
2570    }
2571
2572    /// The current user-controlled text-scale factor (`1.0` = 100 %).
2573    pub fn user_text_scale(&self) -> f32 {
2574        self.user_text_scale
2575    }
2576
2577    /// After a rebuild that destroyed subtrees — or after a `visible_when` /
2578    /// `Switcher` pass parks the focused widget dormant — drop any interaction
2579    /// state (focus, hover) whose target `WidgetId` is no longer active.
2580    ///
2581    /// **FocusLost is load-bearing.** Clearing `self.focused` alone leaves the
2582    /// widget's own `on_focus` / `has_focus` / caret-blink state thinking it is
2583    /// still focused. A rich-text editor in that state keeps scheduling
2584    /// `wake_at` caret toggles and re-arming `frame_request` from its tick
2585    /// effect — and because `frame_tick` observers are **not** gated on
2586    /// dormancy, every open tab's editor (TabWidget mounts them all) still runs
2587    /// on those wakes. Rapid tab switches that park a focused editor without a
2588    /// real focus move (programmatic selection, race with pointer focus) used
2589    /// to accumulate stuck "focused" editors and unbounded frame work. Dispatch
2590    /// `FocusLost` first so widgets clear that state, then drop the tree's
2591    /// focus pointer.
2592    ///
2593    /// Preserves state when the target still exists *and* is active. Called from
2594    /// data-driven rebuild paths (`process_state_changes`) and after every
2595    /// rebuild drain; theme and locale switches no longer rebuild.
2596    pub(crate) fn revalidate_interaction_state(&mut self, ops: &mut dyn crate::window::WindowOps) {
2597        if let Some(id) = self.focused
2598            && !self.arena.is_active(id)
2599        {
2600            let old = self.focused;
2601            // Deliver FocusLost while the node still exists (dormant or about to
2602            // be torn down). Skip if the node is already gone — destroy paths
2603            // take care of bookkeeping without a deliverable target.
2604            if self.arena.get(id).is_some() {
2605                // Direct: no bubble through dormant ancestors, no overlay
2606                // dismiss side-effects — this is a teardown signal, not a
2607                // user-driven focus move.
2608                self.dispatch_to_widget_direct(
2609                    id,
2610                    &crate::event::WidgetEvent::FocusLost,
2611                    &mut *ops,
2612                );
2613            }
2614            self.set_focused(None);
2615            self.focus_origin = None;
2616            self.update_focus_within_signals(old, None);
2617            self.update_view_focus_signals(old, None);
2618            self.a11y_dirty = true;
2619        }
2620        if self.focused.is_none() {
2621            self.focus_origin = None;
2622        }
2623        if let Some(id) = self.hovered_id()
2624            && !self.arena.is_active(id)
2625        {
2626            let old = self.hovered_id();
2627            self.set_hovered(None);
2628            self.update_hover_within_signals(old, None);
2629        }
2630        // Pointer capture anchored at a destroyed widget would otherwise
2631        // swallow every subsequent Move/Up — dispatch_to_widget rejects
2632        // inactive targets. Drop the capture so events resume normal
2633        // hit-test dispatch. Same for any in-flight drag session whose
2634        // source was torn down: the user sees the drag "stick".
2635        self.pointers.retain_active(&self.arena);
2636        // External (OS) drags have no in-app source widget, so they are never
2637        // torn down by source destruction — only internal drags are salvaged.
2638        let source_gone = self
2639            .active_drag
2640            .as_ref()
2641            .and_then(|s| s.source_widget)
2642            .is_some_and(|sw| !self.arena.is_active(sw));
2643        if source_gone {
2644            // `cancel_active_drag` fires on_drag_leave on the current
2645            // target before cleanup — the same contract as Escape.
2646            self.cancel_active_drag(&mut *ops);
2647        } else {
2648            // The drag *source* survived but its current hover **target** was
2649            // torn down by the rebuild (e.g. a side disabled / collapsed
2650            // mid-drag destroyed the panel under the pointer). Clear the stale
2651            // target id so a subsequent drop doesn't resolve to a destroyed
2652            // widget (and silently vanish); the next move — or the drop's own
2653            // re-hit-test — re-engages a live target.
2654            let stale_target = self
2655                .active_drag
2656                .as_ref()
2657                .and_then(|d| d.current_target)
2658                .is_some_and(|t| !self.arena.is_active(t));
2659            if stale_target && let Some(drag) = self.active_drag.as_mut() {
2660                drag.current_target = None;
2661            }
2662        }
2663    }
2664
2665    /// Rebuild a single composite widget: destroy old children, re-run `build()`,
2666    /// and wire up new children. Called from `process_state_changes()` when a
2667    /// binding at `BindingLevel::Rebuild` fires (data-driven rebuild). Theme
2668    /// and locale changes do **not** rebuild — they update reactive signals
2669    /// that widgets bind to via `theme_signal()` / `locale_signal()`.
2670    /// Test-only: force-mark a widget for rebuild on the next layout
2671    /// pass. Lets regression tests exercise the rebuild path without
2672    /// needing to trip a Signal binding. Exposed cross-crate (not
2673    /// `#[cfg(test)]`-gated) so widget-crate tests in `teksilo-widgets`
2674    /// and elsewhere can also drive rebuilds; the `_for_testing`
2675    /// suffix marks it as not intended for application code.
2676    pub fn arena_mark_needs_rebuild_for_testing(&mut self, id: WidgetId) {
2677        self.arena.mark_needs_rebuild(id);
2678    }
2679
2680    /// Force a [`DeferredSubtree`](crate::deferred_subtree::DeferredSubtree) at
2681    /// `id` to build its content now. A no-op for any other widget.
2682    ///
2683    /// The framework's own door into deferred content, for the case where the
2684    /// decision to show is the tree's rather than a widget's: a tooltip whose
2685    /// dwell has just matured has no open signal anyone could have handed over.
2686    pub(crate) fn materialize_deferred(&mut self, id: WidgetId) {
2687        let forced = self
2688            .arena
2689            .get_mut(id)
2690            .and_then(|n| n.widget.as_any_mut())
2691            .and_then(|any| any.downcast_mut::<crate::deferred_subtree::DeferredSubtree>())
2692            .map(|deferred| {
2693                let needed = !deferred.is_materialized();
2694                deferred.force();
2695                needed
2696            })
2697            .unwrap_or(false);
2698        if forced {
2699            self.rebuild_single_widget(id);
2700        }
2701    }
2702
2703    pub(crate) fn rebuild_single_widget(&mut self, widget_id: WidgetId) {
2704        // Per §9.4.5, drop the source handle first (stops further source-side
2705        // dispatch) and then remove the UI-side callback. Either order gives
2706        // the same user-visible outcome for events that get posted between
2707        // the two steps, but dropping the source handle first stops the
2708        // publisher thread's work sooner.
2709        //
2710        // ⚠ That reasoning is about the two steps below, and it used to be
2711        // read as covering the whole problem. It does not. The dangerous gap
2712        // is not the microseconds between these two lines, it is the whole
2713        // span from *publish* to *dispatch*: a backend event is posted with
2714        // the id its publisher captured and is handled by the UI thread
2715        // frames later, so any rebuild in between used to strand it. The ids
2716        // are therefore carried across into the new build (see
2717        // `BuildContext::reusable_sub_ids`) rather than being retired here.
2718        // Skribisto's Analysis pane hit this every time it was the restored
2719        // view at project open: it starts a long operation in `build()` and
2720        // sets its own `Rebuild`-bound state signal, the operation finished
2721        // inside its own rebuild, and the pane sat on "Reading the
2722        // manuscript…" for the rest of the session with nothing logged.
2723        // Cancel any looping/one-shot animations owned by this widget
2724        // before build() runs. Without this, a widget that creates a
2725        // fresh `animated_signal` in build() would leak the previous
2726        // instance's scheduler entry: the old Signal<f32> clone lives
2727        // in `animations` forever, ticking against an orphaned signal
2728        // (silent CPU waste) and, for looping animations, doubling up
2729        // when the new one registers.
2730        self.animation_scheduler.cancel_by_widget(widget_id);
2731        // Same pattern for shader-driven animated quads: free the
2732        // widget's slot(s) so `build()` can allocate fresh handles.
2733        // The old cached_paint (if any) carries stale slot indices —
2734        // clear it so paint() re-runs and re-emits DrawCommands with
2735        // the newly-allocated slot.
2736        self.animated_quads.cancel_by_widget(widget_id);
2737        // Its off-thread attachments: `build()` attaches its triggers and
2738        // sets its hook again. The wake state stays, with whatever was
2739        // requested and not yet taken: a request is posted with nothing that
2740        // names the build it was meant for, as a subscription id is above.
2741        self.off_thread.begin_rebuild(widget_id);
2742
2743        let drained_subs = if let Some(node) = self.arena.get_mut(widget_id) {
2744            node.effect_handles.clear();
2745            node.actions.clear();
2746            node.dirty.needs_rebuild = false;
2747            node.cached_paint = None;
2748            node.dirty.needs_paint = true;
2749            // Reset only the OWN handler bucket so `apply_self_handlers`
2750            // during this build's fresh build() starts from empty and
2751            // doesn't stack N-fold handler chains across rebuilds.
2752            // `external_handlers` — set by the `WidgetBuilder` chain at
2753            // creation time or by a composing parent's
2754            // `apply_handlers(child_id, ...)` — persists: those handlers
2755            // come from outside the widget and aren't re-emitted by its
2756            // own `build()`.
2757            //
2758            // `node_focusable` / `node_tab_index` / `node_cursor` /
2759            // `clips_children` / `context_menu_factory` are simple
2760            // values, not accumulating closures. Leave them alone —
2761            // apply_self_handlers rewrites them if the new build
2762            // specifies non-None values; otherwise values from the
2763            // creation site survive the rebuild.
2764            node.handlers = crate::event_handlers::EventHandlers::new();
2765            std::mem::take(&mut node.subscription_handles)
2766        } else {
2767            Vec::new()
2768        };
2769        // Rebuild wiped the OWN handler bucket above (the gesture arena
2770        // lived there), so the widget no longer owns any recognizers.
2771        // The next pointer hit re-runs `ensure_gesture_arena` and
2772        // re-inserts if the new build still wires gesture handlers.
2773        // External handlers (set via the builder chain at creation time)
2774        // persist, but `external_handlers` never carries a gesture arena
2775        // directly — it's always built by `ensure_gesture_arena` into
2776        // the OWN bucket.
2777        self.gesture_owners.remove(&widget_id);
2778        // Shortcuts the widget declared are torn down too — they will
2779        // be re-registered during the upcoming `build()` call. User
2780        // overrides live in a separate map keyed by id, so user
2781        // rebindings survive this round-trip (see ShortcutRegistry
2782        // graveyard semantics).
2783        self.shortcut_registry.unregister_all_for_owner(widget_id);
2784        self.global_actions.retain(|(owner, _)| *owner != widget_id);
2785        self.text_surfaces.remove(widget_id);
2786        // Re-apply `Widget::declare_shortcuts` so the static metadata
2787        // survives the rebuild (the unregister above wiped both
2788        // declared and build-registered entries; build() will refill
2789        // the handler-bearing ones, but it can't be relied on to
2790        // refill the metadata-only declarations).
2791        self.apply_declared_shortcuts(widget_id);
2792        // Drop any signal→widget bindings from the previous build
2793        // cycle so `build()` can re-register a fresh set without
2794        // accumulating duplicates across rebuilds.
2795        self.binding_registry.unregister_for_widget(widget_id);
2796        // Kept, in order, and handed to the upcoming `build()` so it re-subscribes under
2797        // the same ids. A subscription's id is what a publisher captured and posted with;
2798        // minting new ones here would leave every event already queued for this widget
2799        // naming an id nothing answers to. See `BuildContext::reusable_sub_ids`.
2800        let mut reusable_sub_ids = Vec::with_capacity(drained_subs.len());
2801        for (sub_id, handle) in drained_subs {
2802            drop(handle);
2803            self.app_context
2804                .subscription_callbacks
2805                .borrow_mut()
2806                .remove(&sub_id);
2807            self.app_context
2808                .subscription_ctx_callbacks
2809                .borrow_mut()
2810                .remove(&sub_id);
2811            reusable_sub_ids.push(sub_id);
2812        }
2813
2814        // Decide how to treat the existing children. Two modes:
2815        //
2816        // * Default (`preserves_children_on_rebuild() == false`): the widget
2817        //   re-derives its whole subtree, so tear down every old child up
2818        //   front and let `build()` produce a fresh set.
2819        //
2820        // * Reconcile (`preserves_children_on_rebuild() == true`): the widget
2821        //   re-attaches the children it keeps (by id) and drops the rest. We
2822        //   snapshot the old children, run `build()`, then destroy only the
2823        //   old children the new build did NOT re-attach and did NOT re-parent
2824        //   elsewhere. Re-attached children keep their state (focus, scroll,
2825        //   text, subscriptions); dropped children are reaped rather than left
2826        //   as stranded, still-active orphans.
2827        let preserve_children = self
2828            .arena
2829            .get(widget_id)
2830            .map(|n| n.widget.preserves_children_on_rebuild())
2831            .unwrap_or(false);
2832        let old_children: Vec<WidgetId> = self.arena.children(widget_id).to_vec();
2833        if !preserve_children {
2834            // The widget being rebuilt is marked for layout and paint
2835            // already, which covers what its old children drew.
2836            for child_id in &old_children {
2837                self.destroy_subtree_inner(*child_id, false);
2838            }
2839        }
2840
2841        // The parentless nodes the *previous* build owned. Taken now so
2842        // `build()` records its new set into an empty list, and destroyed after
2843        // it returns — by then the widget's own fields point at the new nodes,
2844        // so tearing the old ones down cannot strand a live id in the widget.
2845        // Both the `preserve_children` reconcile and the plain path want this:
2846        // detached content is rebuilt wholesale either way (it is not addressed
2847        // by id from the outside, so there is nothing to preserve).
2848        let old_detached: Vec<WidgetId> = self
2849            .arena
2850            .get_mut(widget_id)
2851            .map(|node| std::mem::take(&mut node.detached))
2852            .unwrap_or_default();
2853
2854        let mut widget_box = match self.arena.take_widget(widget_id) {
2855            Some(widget) => widget,
2856            None => {
2857                self.finish_off_thread_rebuild(widget_id);
2858                return;
2859            }
2860        };
2861
2862        let mut build_ctx = crate::build_context::BuildContext {
2863            tree: self,
2864            composite_id: Some(widget_id),
2865            effect_handles: Vec::new(),
2866            subscription_handles: Vec::new(),
2867            reusable_sub_ids,
2868        };
2869        let new_children = widget_box.build(&mut build_ctx);
2870        let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
2871        let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);
2872
2873        self.arena.restore_widget(widget_id, widget_box);
2874        self.finish_off_thread_rebuild(widget_id);
2875
2876        for &child_id in &new_children {
2877            if let Some(child_node) = self.arena.get_mut(child_id) {
2878                child_node.parent = Some(widget_id);
2879            }
2880        }
2881
2882        // Reconcile the preserve path: reap any old child the new build
2883        // dropped (not in `new_children`) and did not re-parent elsewhere
2884        // (its `parent` still points here). Authoritative parent pointers mean
2885        // a kept subtree re-parented out of a dropped sibling survives. Runs
2886        // before `node.children` is overwritten so the destroy walk can't see
2887        // the new list. Re-parented survivors already have their new parent by
2888        // now (`ctx.add` builds nested widgets synchronously and re-homes their
2889        // children), so the `parent == widget_id` test correctly excludes them.
2890        if preserve_children {
2891            let new_set: std::collections::HashSet<WidgetId> =
2892                new_children.iter().copied().collect();
2893            for &old_c in &old_children {
2894                if !new_set.contains(&old_c) && self.arena.parent(old_c) == Some(widget_id) {
2895                    self.destroy_subtree_inner(old_c, true);
2896                }
2897            }
2898        }
2899
2900        if let Some(node) = self.arena.get_mut(widget_id) {
2901            node.children = new_children;
2902            node.effect_handles = effect_handles;
2903            node.subscription_handles = subscription_handles;
2904        }
2905
2906        // Reap the previous build's parentless content, now that the fresh set
2907        // is recorded and the widget points at it.
2908        for id in old_detached {
2909            self.destroy_subtree_inner(id, false);
2910        }
2911    }
2912
2913    /// Record that `owner` created and owns the parentless node `detached` —
2914    /// pre-built overlay content that is deliberately not a child. See
2915    /// [`BuildContext::add_detached`](crate::build_context::BuildContext::add_detached)
2916    /// and [`WidgetNode::detached`](crate::arena::WidgetNode).
2917    pub(crate) fn record_detached(&mut self, owner: WidgetId, detached: WidgetId) {
2918        if let Some(node) = self.arena.get_mut(owner) {
2919            node.detached.push(detached);
2920        }
2921    }
2922
2923    /// Destroy every parentless node `owner` owns, and forget them.
2924    ///
2925    /// Taken out of the node first: the destroy walk below can re-enter this
2926    /// function (a detached node may own detached nodes of its own — a rich
2927    /// tooltip's cascade children each pre-build their own), and it must not
2928    /// see a list it is halfway through consuming.
2929    fn destroy_detached_of(&mut self, owner: WidgetId) {
2930        let detached = self
2931            .arena
2932            .get_mut(owner)
2933            .map(|node| std::mem::take(&mut node.detached))
2934            .unwrap_or_default();
2935        for id in detached {
2936            self.destroy_subtree_inner(id, false);
2937        }
2938    }
2939
2940    /// Recursively destroy a subtree, dropping per-widget subscription
2941    /// handles and removing their UI-side callbacks. Use this in place of
2942    /// `arena.destroy()` whenever a widget that may have subscribed to
2943    /// events is being torn down.
2944    ///
2945    /// What the subtree drew is in the composed frame, and its ancestors'
2946    /// layouts made room for it: both are recomputed, as for a subtree made
2947    /// dormant. Without that the next frame, where nothing else changed,
2948    /// replayed the destroyed widgets' drawing, and a live picture among
2949    /// them kept its texture until something repainted their parent.
2950    pub(crate) fn destroy_subtree(&mut self, widget_id: WidgetId) {
2951        self.arena.mark_ancestors_need_layout(widget_id);
2952        self.cached_frame = None;
2953        self.a11y_dirty = true;
2954        self.destroy_subtree_inner(widget_id, false);
2955    }
2956
2957    /// Shared teardown for [`destroy_subtree`](Self::destroy_subtree) and the
2958    /// reconciling rebuild path. When `reparent_aware` is `true`, recursion
2959    /// descends into a child only if that child's `parent` still points at
2960    /// `widget_id`.
2961    ///
2962    /// A reconciling rebuild (a [`preserves_children_on_rebuild`] widget) may
2963    /// re-parent a kept subtree *out* of a dropped sibling and *into* the new
2964    /// tree. The dropped sibling's `children` list still lists that subtree
2965    /// (stale), so following it would tear down a node that is actually alive
2966    /// elsewhere. Following the authoritative `parent` pointer instead stops
2967    /// at the boundary of what genuinely still belongs to the node being
2968    /// destroyed. The per-node teardown ends with `arena.remove_node` (a
2969    /// single-node removal), NOT `arena.destroy` (which would re-recurse the
2970    /// stale `children` list and undo the skip).
2971    ///
2972    /// [`preserves_children_on_rebuild`]: crate::widget::Widget::preserves_children_on_rebuild
2973    fn destroy_subtree_inner(&mut self, widget_id: WidgetId, reparent_aware: bool) {
2974        // See the matching cancel in `rebuild_single_widget` — the
2975        // scheduler holds strong Signal<f32> clones, so the animation
2976        // would outlive its widget without this explicit cancellation.
2977        self.animation_scheduler.cancel_by_widget(widget_id);
2978        // Release the animated-quad slot(s) too.
2979        self.animated_quads.cancel_by_widget(widget_id);
2980        // And what it attached for off-thread content.
2981        self.off_thread.cancel_by_widget(widget_id);
2982        // A tooltip's content widget is parentless (`ctx.add`), so the child
2983        // walk below never reaches it — reap it explicitly or the entry and
2984        // its node outlive the anchor for the lifetime of the tree.
2985        self.retire_tooltips_of_destroyed_anchor(widget_id);
2986        // Same reasoning, one level up: every *other* parentless node this
2987        // widget built (a dropdown menu, a calendar, a tooltip's nested
2988        // cascade children) is unreachable from the child walk and dies here
2989        // or never.
2990        self.destroy_detached_of(widget_id);
2991
2992        let children: Vec<WidgetId> = self.arena.children(widget_id).to_vec();
2993        for child in children {
2994            if reparent_aware && self.arena.parent(child) != Some(widget_id) {
2995                // Re-parented into the surviving tree by this rebuild — leave it.
2996                continue;
2997            }
2998            self.destroy_subtree_inner(child, reparent_aware);
2999        }
3000        let drained_subs = self
3001            .arena
3002            .get_mut(widget_id)
3003            .map(|node| std::mem::take(&mut node.subscription_handles))
3004            .unwrap_or_default();
3005        for (sub_id, handle) in drained_subs {
3006            drop(handle);
3007            self.app_context
3008                .subscription_callbacks
3009                .borrow_mut()
3010                .remove(&sub_id);
3011            self.app_context
3012                .subscription_ctx_callbacks
3013                .borrow_mut()
3014                .remove(&sub_id);
3015        }
3016        // Drop any shortcuts the destroyed widget owned. Unlike
3017        // `rebuild_single_widget`, destruction is permanent; if the
3018        // user had overrides, they stay in the graveyard.
3019        self.shortcut_registry.unregister_all_for_owner(widget_id);
3020        self.global_actions.retain(|(owner, _)| *owner != widget_id);
3021        self.text_surfaces.remove(widget_id);
3022        // Bindings from this widget stop being relevant; clean them
3023        // up so the registry doesn't leak dead entries for the
3024        // lifetime of the app.
3025        self.binding_registry.unregister_for_widget(widget_id);
3026        // Keep `gesture_owners` honest — destroying the widget tears
3027        // down its handlers, so the per-frame gesture pass must stop
3028        // visiting it.
3029        self.gesture_owners.remove(&widget_id);
3030        // If focus pointed at the widget about to disappear, drop it
3031        // so later dispatch doesn't anchor intent walks at a dead id
3032        // (which would silently swallow the intent).
3033        if self.focused == Some(widget_id) {
3034            let old = self.focused;
3035            self.set_focused(None);
3036            self.focus_origin = None;
3037            self.update_focus_within_signals(old, None);
3038            self.update_view_focus_signals(old, None);
3039        }
3040        if self.hovered_id() == Some(widget_id) {
3041            let old = self.hovered_id();
3042            self.set_hovered(None);
3043            self.update_hover_within_signals(old, None);
3044        }
3045        // Symmetric with focus/hover above: a pointer capture anchored at the
3046        // widget about to disappear would otherwise swallow every subsequent
3047        // Move/Up (dispatch rejects inactive targets) until the next layout
3048        // pass runs `revalidate_interaction_state`. Drop it eagerly so capture
3049        // never outlives its owner, even when a destroy happens mid-gesture.
3050        self.pointers.release_captures_of(widget_id);
3051        // Single-node removal: this function already recursed into the
3052        // children above (honouring re-parenting when `reparent_aware`).
3053        // `arena.destroy` would re-recurse the now-stale `children` list and
3054        // tear down a survivor re-homed out of this subtree.
3055        self.arena.remove_node(widget_id);
3056    }
3057
3058    /// Set the layout direction (LTR/RTL). Marks all widgets as needing layout.
3059    pub fn set_layout_direction(&mut self, direction: crate::environment::LayoutDirection) {
3060        self.layout_direction = direction;
3061        self.arena.mark_all_dirty();
3062    }
3063
3064    /// The current layout direction.
3065    pub fn layout_direction(&self) -> crate::environment::LayoutDirection {
3066        self.layout_direction
3067    }
3068
3069    /// Set OS-level accessibility preferences.
3070    ///
3071    /// Called by `teksilo-app` after querying the platform layer. Updates the
3072    /// values fed into `PaintContext` and `Environment` on subsequent frames.
3073    /// Marks all widgets dirty so the new preferences take effect immediately.
3074    pub fn set_accessibility_preferences(
3075        &mut self,
3076        high_contrast: bool,
3077        reduced_motion: bool,
3078        text_scale_factor: f64,
3079    ) {
3080        let changed = self.prefers_high_contrast.get() != high_contrast
3081            || self.prefers_reduced_motion != reduced_motion
3082            || (self.text_scale_factor - text_scale_factor).abs() > f64::EPSILON;
3083
3084        if changed {
3085            if self.prefers_high_contrast.get() != high_contrast {
3086                self.prefers_high_contrast.set(high_contrast);
3087            }
3088            self.prefers_reduced_motion = reduced_motion;
3089            self.text_scale_factor = text_scale_factor;
3090            // The OS factor feeds the effective text scale (multiplied with the
3091            // user factor), so refresh the cached scaled typography.
3092            self.recompute_effective_theme();
3093            self.arena.mark_all_dirty();
3094        }
3095    }
3096
3097    /// Whether the OS has requested high-contrast mode.
3098    pub fn prefers_high_contrast(&self) -> bool {
3099        self.prefers_high_contrast.get()
3100    }
3101
3102    /// Reactive handle on the high-contrast preference. Fires when
3103    /// [`set_accessibility_preferences`](Self::set_accessibility_preferences)
3104    /// changes it. Bind at [`BindingLevel::RepaintOnly`]: the preference only
3105    /// changes colours. Starts `false`.
3106    ///
3107    /// [`BindingLevel::RepaintOnly`]: crate::binding::BindingLevel::RepaintOnly
3108    pub fn high_contrast_signal(&self) -> crate::signal::Signal<bool> {
3109        self.prefers_high_contrast.clone()
3110    }
3111
3112    /// The [`PaintState`](crate::styles::PaintState) this tree paints under:
3113    /// its window-active state and the high-contrast preference. The paint
3114    /// walker hands it to [`Theme::for_paint_state`](crate::styles::Theme::for_paint_state).
3115    pub fn paint_state(&self) -> crate::styles::PaintState {
3116        crate::styles::PaintState {
3117            window_active: self.is_window_active(),
3118            high_contrast: self.prefers_high_contrast(),
3119        }
3120    }
3121
3122    /// Whether the OS has requested reduced motion.
3123    pub fn prefers_reduced_motion(&self) -> bool {
3124        self.prefers_reduced_motion
3125    }
3126
3127    /// OS text scaling factor (1.0 = normal).
3128    pub fn text_scale_factor(&self) -> f64 {
3129        self.text_scale_factor
3130    }
3131
3132    /// Report whether the *operating system* says an assistive technology is
3133    /// reading the screen.
3134    ///
3135    /// Fed by `teksilo-app` from `teksilo_platform::AccessibilityPreferences`,
3136    /// which asks Windows for `SPI_GETSCREENREADER`, AT-SPI for
3137    /// `org.a11y.Status.ScreenReaderEnabled`, and macOS for
3138    /// `NSWorkspace::isVoiceOverEnabled`. A platform that cannot answer leaves
3139    /// it [`ScreenReaderState::Unknown`], which behaves as "no".
3140    ///
3141    /// [`ScreenReaderState::Unknown`]: crate::environment::ScreenReaderState::Unknown
3142    ///
3143    /// **Not** AccessKit activation. An AccessKit adapter activates for
3144    /// anything that walks the tree — a screen magnifier, a voice-control front
3145    /// end, a UI-automation inspector, a tree browser like Accerciser — none of
3146    /// which want a touch to become a probe. Treating activation as evidence of
3147    /// a screen reader is how explore-by-touch gets switched on under an
3148    /// inspector and makes the app untouchable.
3149    ///
3150    /// [`ScreenReaderState`]: crate::environment::ScreenReaderState
3151    pub fn set_screen_reader_state(&mut self, state: crate::environment::ScreenReaderState) {
3152        self.screen_reader = state;
3153    }
3154
3155    /// The screen-reader state most recently reported by the platform.
3156    ///
3157    /// [`ScreenReaderState::Unknown`] until something reports one.
3158    ///
3159    /// [`ScreenReaderState::Unknown`]: crate::environment::ScreenReaderState::Unknown
3160    pub fn screen_reader_state(&self) -> crate::environment::ScreenReaderState {
3161        self.screen_reader
3162    }
3163
3164    /// Choose how explore-by-touch is decided for this window.
3165    ///
3166    /// [`ExploreByTouch::Off`] is the default and today's behaviour;
3167    /// [`ExploreByTouch::Auto`] follows [`Self::screen_reader_state`];
3168    /// [`ExploreByTouch::On`] forces it regardless. Read the resolved answer
3169    /// with [`Self::explore_by_touch_active`].
3170    ///
3171    /// [`ExploreByTouch::Off`]: crate::environment::ExploreByTouch::Off
3172    /// [`ExploreByTouch::Auto`]: crate::environment::ExploreByTouch::Auto
3173    /// [`ExploreByTouch::On`]: crate::environment::ExploreByTouch::On
3174    pub fn set_explore_by_touch(&mut self, mode: crate::environment::ExploreByTouch) {
3175        self.explore_by_touch = mode;
3176    }
3177
3178    /// The explore-by-touch policy most recently set.
3179    pub fn explore_by_touch(&self) -> crate::environment::ExploreByTouch {
3180        self.explore_by_touch
3181    }
3182
3183    /// Whether explore-by-touch is in force right now.
3184    ///
3185    /// `On` is unconditional; `Auto` requires the platform to have reported an
3186    /// active screen reader; `Off` is never in force. Nothing in the framework
3187    /// consumes this yet — the touch-as-probe interaction it gates has no
3188    /// owner — so it is a supply, a policy and a query, and no pointer path
3189    /// branches on it.
3190    pub fn explore_by_touch_active(&self) -> bool {
3191        use crate::environment::{ExploreByTouch, ScreenReaderState};
3192        match self.explore_by_touch {
3193            ExploreByTouch::Off => false,
3194            ExploreByTouch::On => true,
3195            ExploreByTouch::Auto => self.screen_reader == ScreenReaderState::Active,
3196        }
3197    }
3198
3199    /// Report whether an AccessKit client is attached to this window.
3200    ///
3201    /// Written by `teksilo-app` from the platform adapter's activation and
3202    /// deactivation handlers, and used **asymmetrically** on purpose:
3203    ///
3204    /// * Attaching proves nothing. Magnifier, Voice Access and a UI-automation
3205    ///   inspector all activate the adapter, so this never sets
3206    ///   [`ScreenReaderState::Active`].
3207    /// * Detaching proves something. When the last client goes away there is
3208    ///   no screen reader either, so a `true` → `false` transition forces
3209    ///   [`ScreenReaderState::Inactive`] and an
3210    ///   [`ExploreByTouch::Auto`](crate::environment::ExploreByTouch::Auto)
3211    ///   window stops exploring immediately, without waiting for the next OS
3212    ///   query. A later OS query is free to say `Active` again.
3213    ///
3214    /// [`ScreenReaderState::Active`]: crate::environment::ScreenReaderState::Active
3215    /// [`ScreenReaderState::Inactive`]: crate::environment::ScreenReaderState::Inactive
3216    pub fn set_at_client_attached(&mut self, attached: bool) {
3217        if self.at_client_attached && !attached {
3218            self.screen_reader = crate::environment::ScreenReaderState::Inactive;
3219        }
3220        self.at_client_attached = attached;
3221    }
3222
3223    /// Whether an AccessKit client is currently attached to this window.
3224    pub fn at_client_attached(&self) -> bool {
3225        self.at_client_attached
3226    }
3227
3228    /// Set the host window HiDPI device scale (physical px per logical px).
3229    /// Written by `teksilo-app` when the window is created and again on
3230    /// `WindowEvent::ScaleFactorChanged`. Surfaced to widgets via
3231    /// `LayoutContext::scale_factor` and [`Self::device_scale_signal`].
3232    ///
3233    /// A scale change relayouts nothing by itself: the tree is logical, and
3234    /// when the window's logical size stays the same the next layout pass
3235    /// finds nothing to do. A widget whose layout reads the scale binds
3236    /// [`Self::device_scale_signal`] at `Relayout`, which this sets. The
3237    /// accessibility tree is marked here, because the scale is the root
3238    /// node's transform (AccessKit wants physical coordinates, the tree emits
3239    /// logical ones): dragging a window between a 1x and a 2x monitor would
3240    /// otherwise leave every reported rectangle at the old display's scale.
3241    pub fn set_device_scale_factor(&mut self, scale_factor: f32) {
3242        if self.device_scale_factor != scale_factor {
3243            self.device_scale_factor = scale_factor;
3244            self.device_scale_signal.set(scale_factor);
3245            self.a11y_dirty = true;
3246        }
3247    }
3248
3249    /// The host window HiDPI device scale most recently set (1.0 by default).
3250    pub fn device_scale_factor(&self) -> f32 {
3251        self.device_scale_factor
3252    }
3253
3254    /// Reactive handle on [`Self::device_scale_factor`]. A scale change
3255    /// relayouts nothing by itself; a widget whose layout or placement reads
3256    /// `LayoutContext::scale_factor` binds this at `BindingLevel::Relayout`.
3257    pub fn device_scale_signal(&self) -> crate::signal::Signal<f32> {
3258        self.device_scale_signal.clone()
3259    }
3260
3261    /// Set the waker content updated off the UI thread uses to wake this
3262    /// tree's window (see [`teksilo_canvas::wake`]). teksilo-app installs each
3263    /// window's before its root widget is built, so a source attached in
3264    /// `build()` already has it. `None`, the default, wakes nobody: a headless
3265    /// tree renders when its owner says so.
3266    pub fn set_redraw_waker(
3267        &mut self,
3268        waker: Option<std::sync::Arc<dyn teksilo_canvas::wake::RedrawWaker>>,
3269    ) {
3270        self.off_thread.set_waker(waker);
3271    }
3272
3273    /// The waker [`set_redraw_waker`](Self::set_redraw_waker) installed.
3274    #[doc(hidden)]
3275    pub fn redraw_waker(&self) -> Option<&std::sync::Arc<dyn teksilo_canvas::wake::RedrawWaker>> {
3276        self.off_thread.waker()
3277    }
3278
3279    /// Report the host window's platform safe-area insets — the region the
3280    /// window owns but a person cannot fully see or touch (a display cutout, a
3281    /// rounded corner, a home indicator).
3282    ///
3283    /// Fed by `teksilo-app` from
3284    /// `teksilo_platform::safe_area`, which reads the window on macOS and
3285    /// answers `ZERO` everywhere else because no other desktop platform
3286    /// reports one. Overlays clamp into what is left; the root layout
3287    /// proposal is deliberately **not** shrunk — a safe area moves what floats
3288    /// over the content, not the content.
3289    pub fn set_safe_area(&mut self, insets: teksilo_canvas::EdgeInsets) {
3290        if self.safe_area != insets {
3291            self.safe_area = insets;
3292            // Overlay placement is recomputed from scratch by every layout
3293            // pass, so the change reaches the screen as soon as one runs; the
3294            // frame request is what guarantees one does.
3295            self.request_frame();
3296        }
3297    }
3298
3299    /// The safe-area insets most recently set (`ZERO` by default).
3300    pub fn safe_area(&self) -> teksilo_canvas::EdgeInsets {
3301        self.safe_area
3302    }
3303
3304    /// Report a rectangle of the window currently covered from outside the
3305    /// tree — a soft keyboard, a platform IME candidate window — in
3306    /// window-logical pixels, or `None` when nothing covers it.
3307    ///
3308    /// A **rectangle**, not a named edge, because that is what a platform
3309    /// reports and because the placement code resolves it by keeping the
3310    /// largest free slab rather than by insetting an edge: a keyboard at the
3311    /// bottom gives the band above it, a candidate window at a side gives the
3312    /// band beside it, and neither needs the platform to say which edge it
3313    /// came from.
3314    ///
3315    /// Scope: like the safe area, this reaches **overlay placement only**. The
3316    /// root layout proposal keeps the whole window, so a scroll container
3317    /// still extends behind the keyboard and nothing reflows when one rises —
3318    /// which is what the desktop convention wants, and what keeps a keyboard
3319    /// appearing from being a full relayout of the document. Bringing a
3320    /// focused field out from behind the band is a scroll, against
3321    /// [`usable_viewport`](Self::usable_viewport), not a resize.
3322    pub fn set_occluded_inset(&mut self, occluded: Option<Rect>) {
3323        if self.occluded_inset != occluded {
3324            self.occluded_inset = occluded;
3325            self.request_frame();
3326        }
3327    }
3328
3329    /// The occluding rectangle most recently set (`None` by default).
3330    pub fn occluded_inset(&self) -> Option<Rect> {
3331        self.occluded_inset
3332    }
3333
3334    /// The viewport overlays are placed into: the last laid-out window size,
3335    /// less the safe area, less anything covering it.
3336    ///
3337    /// The same rectangle `position_overlays` clamps into, exposed so a
3338    /// consumer that must put something *in front of* a keyboard — a
3339    /// scroll-into-view for the focused field — can ask for it rather than
3340    /// re-deriving it.
3341    pub fn usable_viewport(&self) -> Rect {
3342        let proposal = self.last_proposal();
3343        let size = teksilo_canvas::Size::new(
3344            proposal.width.unwrap_or(0.0),
3345            proposal.height.unwrap_or(0.0),
3346        );
3347        self.overlay_viewport_for(size)
3348            .usable(self.layout_direction)
3349    }
3350
3351    /// Build the overlay viewport for a window of `size`, folding in the
3352    /// safe area and the occluding rectangle.
3353    pub(crate) fn overlay_viewport_for(
3354        &self,
3355        size: teksilo_canvas::Size,
3356    ) -> crate::overlay::OverlayViewport {
3357        crate::overlay::OverlayViewport::new(size)
3358            .with_safe_area(self.safe_area)
3359            .with_occluded(self.occluded_inset)
3360    }
3361
3362    /// Ask the platform to show (`true`) or hide (`false`) its on-screen
3363    /// keyboard.
3364    ///
3365    /// Recorded here rather than pushed straight at the window because the one
3366    /// thing the request must not do is re-assert IME allowance while a
3367    /// composition is live, and the IME-allowance state lives in the app
3368    /// layer's per-window reconcile. `teksilo-app` takes the request once per
3369    /// dispatch, after that reconcile, and applies it against the platform's
3370    /// [`SoftKeyboardSupport`](crate::window::SoftKeyboardSupport) answer.
3371    pub fn request_soft_keyboard(&mut self, visible: bool) {
3372        self.soft_keyboard_request = Some(visible);
3373    }
3374
3375    /// Take the pending soft-keyboard request, if any. Called by the app layer
3376    /// once per dispatch.
3377    pub fn take_soft_keyboard_request(&mut self) -> Option<bool> {
3378        self.soft_keyboard_request.take()
3379    }
3380
3381    /// Mark a widget as clipping its children to its bounds (scroll areas).
3382    pub fn set_clips_children(&mut self, id: WidgetId, clips: bool) {
3383        self.arena.set_clips_children(id, clips);
3384    }
3385
3386    /// Apply a `HandlerSet` to an existing node in the arena, routed
3387    /// into the rebuild-cleared `handlers` slot (the widget's own
3388    /// self-applied handlers).
3389    /// Register any *bound* builder-level accessibility Props
3390    /// (`access_hidden` / `access_label` / `access_description` /
3391    /// `access_value`) at `BindingLevel::AccessibilityOnly`, so that a change
3392    /// to the underlying signal flips `a11y_dirty` and the AccessKit tree
3393    /// re-walks — re-resolving the announced hidden-state / name / description
3394    /// / value — without a visual relayout. Static Props are ignored by
3395    /// `register_if_bound`. Takes the registry explicitly (rather than `&self`)
3396    /// so insertion-path callers can keep a disjoint `&mut self.arena` borrow
3397    /// on the node alive.
3398    fn register_access_prop_bindings(
3399        access: &crate::widget_builder::AccessibilityOverrides,
3400        id: WidgetId,
3401        registry: &crate::binding::BindingRegistry,
3402    ) {
3403        use crate::binding::BindingLevel::AccessibilityOnly;
3404        if let Some(p) = access.hidden.as_ref() {
3405            p.register_if_bound(id, registry, AccessibilityOnly);
3406        }
3407        if let Some(p) = access.label.as_ref() {
3408            p.register_if_bound(id, registry, AccessibilityOnly);
3409        }
3410        if let Some(p) = access.description.as_ref() {
3411            p.register_if_bound(id, registry, AccessibilityOnly);
3412        }
3413        if let Some(p) = access.value.as_ref() {
3414            p.register_if_bound(id, registry, AccessibilityOnly);
3415        }
3416    }
3417
3418    pub(crate) fn apply_self_handler_set(
3419        &mut self,
3420        id: WidgetId,
3421        mut handler_set: crate::widget_builder::HandlerSet,
3422    ) {
3423        // `visible_when` needs the binding registry (which the arena lacks), so
3424        // pull it out here and apply it via `self.visible_when` after.
3425        let visible_when = handler_set.visible_when.take();
3426        // Same reason for the reactive access Props: the arena can't reach the
3427        // registry, so register them here before handing the set to the arena.
3428        if let Some(access) = handler_set.access.as_ref() {
3429            Self::register_access_prop_bindings(access, id, &self.binding_registry);
3430        }
3431        self.arena
3432            .apply_handler_set(id, handler_set, crate::arena::HandlerScope::Own);
3433        if let Some(prop) = visible_when {
3434            self.visible_when(id, prop);
3435        }
3436    }
3437
3438    /// Apply a `HandlerSet` to an existing node as *external* handlers —
3439    /// the kind attached by a composing parent via
3440    /// `BuildContext::apply_handlers(child_id, ...)` or by the
3441    /// `WidgetBuilder` chain at insertion time. These persist across
3442    /// the target widget's own rebuilds.
3443    pub(crate) fn apply_external_handler_set(
3444        &mut self,
3445        id: WidgetId,
3446        mut handler_set: crate::widget_builder::HandlerSet,
3447    ) {
3448        // See `apply_self_handler_set`: route `visible_when` and the reactive
3449        // access Props through the registry (the arena can't reach it).
3450        let visible_when = handler_set.visible_when.take();
3451        if let Some(access) = handler_set.access.as_ref() {
3452            Self::register_access_prop_bindings(access, id, &self.binding_registry);
3453        }
3454        self.arena
3455            .apply_handler_set(id, handler_set, crate::arena::HandlerScope::External);
3456        if let Some(prop) = visible_when {
3457            self.visible_when(id, prop);
3458        }
3459    }
3460
3461    /// Append an accessibility `labelled_by` relation onto an already-mounted
3462    /// node, *preserving* any overrides the widget already carries — unlike
3463    /// `apply_external_handler_set`, which replaces the whole override struct.
3464    /// Used by container widgets (e.g. `FormLayout`) to name a field after its
3465    /// label once both ids are known. Idempotent: re-adding the same target is
3466    /// a no-op, so a composite may re-register the relation on every rebuild.
3467    pub(crate) fn push_access_labelled_by(&mut self, id: WidgetId, label_id: WidgetId) {
3468        if let Some(node) = self.arena.get_mut(id) {
3469            let overrides = node.access_overrides.get_or_insert_with(|| {
3470                Box::new(crate::widget_builder::AccessibilityOverrides::default())
3471            });
3472            // A composite that names itself from its content re-registers the
3473            // relation on every rebuild. Appending blindly would concatenate the
3474            // title into the name once per rebuild.
3475            if overrides.labelled_by.contains(&label_id) {
3476                return;
3477            }
3478            overrides.labelled_by.push(label_id);
3479            self.a11y_dirty = true;
3480        }
3481    }
3482
3483    /// Append an accessibility `described_by` relation onto an already-mounted
3484    /// node, preserving existing overrides (the `described_by` counterpart of
3485    /// [`push_access_labelled_by`](Self::push_access_labelled_by)).
3486    pub(crate) fn push_access_described_by(&mut self, id: WidgetId, target_id: WidgetId) {
3487        if let Some(node) = self.arena.get_mut(id) {
3488            let overrides = node.access_overrides.get_or_insert_with(|| {
3489                Box::new(crate::widget_builder::AccessibilityOverrides::default())
3490            });
3491            if overrides.described_by.contains(&target_id) {
3492                return;
3493            }
3494            overrides.described_by.push(target_id);
3495            self.a11y_dirty = true;
3496        }
3497    }
3498
3499    /// Set a per-child alignment override on a widget.
3500    pub fn set_alignment(&mut self, id: WidgetId, alignment: teksilo_tokens::Alignment) {
3501        self.arena.set_alignment_override(id, alignment);
3502    }
3503
3504    /// Get the binding registry for registering State→Widget bindings.
3505    pub fn binding_registry(&self) -> &crate::binding::BindingRegistry {
3506        &self.binding_registry
3507    }
3508
3509    /// Shared access to the shortcut registry. Widgets register their
3510    /// default shortcuts through here during `build()` (via
3511    /// `BuildContext::register_shortcut`); settings UIs and
3512    /// persistence layers read and mutate overrides directly.
3513    pub fn shortcut_registry(&self) -> &crate::shortcut::ShortcutRegistry {
3514        &self.shortcut_registry
3515    }
3516
3517    pub fn shortcut_registry_mut(&mut self) -> &mut crate::shortcut::ShortcutRegistry {
3518        &mut self.shortcut_registry
3519    }
3520
3521    /// Install a one-shot key-capture callback, returning a
3522    /// [`CaptureHandle`](crate::shortcut::CaptureHandle) whose `Drop`
3523    /// cancels the capture if it hasn't already fired. The next
3524    /// `KeyDown` the tree receives bypasses shortcut-registry lookup
3525    /// and invokes the callback with:
3526    /// - the captured [`KeyStroke`](crate::shortcut::KeyStroke)
3527    /// - mutable access to the registry (rebind in-place)
3528    /// - a mutable [`EventContext`] (so the handler can also emit
3529    ///   commands, send intents, dismiss overlays, …)
3530    ///
3531    /// Calling this while a previous capture is armed creates a
3532    /// **separate** slot; the prior handle, when eventually dropped,
3533    /// cancels only its own (now-orphaned) slot. The new capture
3534    /// wins.
3535    pub fn begin_key_capture(
3536        &mut self,
3537        callback: impl FnOnce(
3538            crate::shortcut::KeyStroke,
3539            &mut crate::shortcut::ShortcutRegistry,
3540            &mut EventContext,
3541        ) + 'static,
3542    ) -> crate::shortcut::CaptureHandle {
3543        let slot: crate::shortcut::KeyCaptureSlot =
3544            std::rc::Rc::new(std::cell::RefCell::new(Some(Box::new(callback))));
3545        self.key_capture = Some(slot.clone());
3546        crate::shortcut::CaptureHandle::new(slot)
3547    }
3548
3549    /// Cancel any currently-armed key capture without invoking it.
3550    /// Equivalent to dropping the [`CaptureHandle`](crate::shortcut::CaptureHandle),
3551    /// but exposed here so callers that lost the handle (or never
3552    /// kept one) can still bail out.
3553    pub fn cancel_key_capture(&mut self) {
3554        if let Some(slot) = self.key_capture.take() {
3555            slot.borrow_mut().take();
3556        }
3557    }
3558
3559    /// Whether a key-capture callback is currently armed.
3560    pub fn is_capturing_keys(&self) -> bool {
3561        self.key_capture
3562            .as_ref()
3563            .map(|slot| slot.borrow().is_some())
3564            .unwrap_or(false)
3565    }
3566
3567    /// Consume any pending key-capture callback. Used internally by
3568    /// the dispatch path — returns the boxed closure so the caller
3569    /// can invoke it once the KeyStroke has been constructed. Also
3570    /// drops the outer `Option<Rc<...>>` so `is_capturing_keys` goes
3571    /// back to `false`.
3572    pub(crate) fn take_key_capture(&mut self) -> Option<crate::shortcut::KeyCaptureCallback> {
3573        let slot = self.key_capture.take()?;
3574        slot.borrow_mut().take()
3575    }
3576
3577    /// Append an [`Action`](crate::action::Action) to a widget's arena
3578    /// node. Invoked by `BuildContext::register_action`; not meant
3579    /// to be called directly.
3580    /// Record that `widget_id` edits text. Replaces any previous registration
3581    /// from the same widget, so a rebuild re-points rather than accumulating.
3582    pub(crate) fn push_text_surface(
3583        &mut self,
3584        widget_id: WidgetId,
3585        surface: std::rc::Rc<dyn crate::text_surface::TextSurface>,
3586    ) {
3587        self.text_surfaces.insert(widget_id, surface);
3588    }
3589
3590    /// A cloneable view of this tree's text surfaces, for a caller that must ask
3591    /// the question later, without a `&WidgetTree` in hand.
3592    pub fn text_surfaces(&self) -> crate::text_surface::TextSurfaces {
3593        self.text_surfaces.clone()
3594    }
3595
3596    /// The text-editing widget that currently holds the keyboard focus.
3597    ///
3598    /// `None` when focus is elsewhere — or nowhere — which is exactly what a
3599    /// host needs in order to know that a text chord is safe to route itself.
3600    pub fn focused_text_surface(
3601        &self,
3602    ) -> Option<std::rc::Rc<dyn crate::text_surface::TextSurface>> {
3603        self.text_surfaces.focused()
3604    }
3605
3606    /// Is the keyboard focus inside a widget that edits text?
3607    ///
3608    /// The cheap half of [`focused_text_surface`](Self::focused_text_surface),
3609    /// for a host that only needs to decide whether to step aside.
3610    pub fn focused_is_text_surface(&self) -> bool {
3611        self.text_surfaces.focused_is_text_surface()
3612    }
3613
3614    pub(crate) fn push_action(&mut self, widget_id: WidgetId, action: crate::action::Action) {
3615        if let Some(node) = self.arena.get_mut(widget_id) {
3616            node.actions.push(action);
3617        }
3618    }
3619
3620    /// Telemetry dispatch tap. Looks up a registered
3621    /// [`crate::telemetry::TelemetryContext`] in `app_state` and emits
3622    /// an `intent.dispatched` event with the intent's name. No-op when
3623    /// no telemetry is configured. Errors and consent gating are
3624    /// handled inside the reporter — this site only needs to call
3625    /// `record`.
3626    fn tap_intent_dispatched(&self, intent: &crate::intent::Intent) {
3627        let Some(tcx) = self
3628            .app_context()
3629            .app_state::<crate::telemetry::TelemetryContext>()
3630        else {
3631            return;
3632        };
3633        let install_id = tcx.reporter.install_id();
3634        let props = [
3635            crate::telemetry::Prop {
3636                key: "name",
3637                value: crate::telemetry::PropValue::StaticStr(intent.name),
3638            },
3639            crate::telemetry::Prop {
3640                key: "source",
3641                value: crate::telemetry::PropValue::Enum {
3642                    variant: intent.source.as_str(),
3643                },
3644            },
3645        ];
3646        let event = crate::telemetry::Event {
3647            name: "intent.dispatched",
3648            category: crate::telemetry::EventCategory::Intent,
3649            timestamp: std::time::SystemTime::now(),
3650            install_id,
3651            session_id: &tcx.session_id,
3652            schema_version: tcx.schema_version,
3653            props: &props,
3654        };
3655        tcx.reporter.record(&event);
3656    }
3657
3658    /// Histogram of widget concrete-type names across the active
3659    /// arena. Used by the `widget.census` telemetry emitter to surface
3660    /// "which widgets does this app actually use" data back to the
3661    /// framework. Keyed by
3662    /// `std::any::type_name::<T>()` of the concrete widget — a
3663    /// dotted, fully-qualified path like
3664    /// `teksilo_widgets::button::Button`.
3665    ///
3666    /// `&'static str` keys: `type_name_of_val` returns a
3667    /// compile-time string, so the histogram preserves the static
3668    /// lifetime all the way to the wire-format prop. This avoids
3669    /// any allocation for the type-name strings themselves.
3670    ///
3671    /// Cost: one `Box<dyn Widget>` indirection per active node plus
3672    /// a `HashMap` insert. Sub-millisecond on arenas with thousands
3673    /// of widgets. Safe to call every frame in tests; in production
3674    /// gate behind a periodic ticker (hourly or on-idle).
3675    pub fn widget_type_histogram(&self) -> std::collections::HashMap<&'static str, u32> {
3676        let mut out = std::collections::HashMap::<&'static str, u32>::new();
3677        for id in self.arena.active_ids_iter() {
3678            if let Some(node) = self.arena.get(id) {
3679                // `Widget::type_name` is monomorphized per impl, so
3680                // calling through the vtable correctly resolves to
3681                // the concrete type — `type_name_of_val(&*widget)`
3682                // alone would collapse to `"dyn teksilo_core::widget::Widget"`.
3683                let name: &'static str = node.widget.type_name();
3684                *out.entry(name).or_insert(0) += 1;
3685            }
3686        }
3687        out
3688    }
3689
3690    /// Number of active widgets in the arena. Cheap; matches the
3691    /// totals returned by `widget_type_histogram` when summed.
3692    pub fn active_widget_count(&self) -> usize {
3693        self.arena.active_ids_iter().count()
3694    }
3695
3696    /// Enqueue an intent for dispatch from `source`. Called from
3697    /// `collect_from_ctx` after a handler runs `ctx.send_intent(...)`
3698    /// and from the KeyDown shortcut-interception path.
3699    pub(crate) fn enqueue_intent(
3700        &mut self,
3701        source: WidgetId,
3702        intent: crate::intent::Intent,
3703        propagate_when_disabled: bool,
3704    ) {
3705        self.pending_intents
3706            .push((source, intent, propagate_when_disabled));
3707    }
3708
3709    /// Dispatch every queued intent. Handlers may call
3710    /// `ctx.send_intent(...)` to enqueue more; the loop consumes
3711    /// those too until the queue drains. No ordering guarantee
3712    /// beyond "first-enqueued is first-dispatched"; the `pop` path
3713    /// uses `remove(0)` to keep that FIFO behavior.
3714    pub(crate) fn drain_pending_intents(&mut self, ops: &mut dyn crate::window::WindowOps) {
3715        while !self.pending_intents.is_empty() {
3716            let (source, intent, propagate) = self.pending_intents.remove(0);
3717            self.dispatch_intent(source, intent, propagate, &mut *ops);
3718        }
3719    }
3720
3721    /// Walk `source → root` invoking any [`Action`](crate::action::Action)
3722    /// whose `intent` name matches. The first enabled, `Handled`
3723    /// response stops the walk. A `Propagated` or disabled action
3724    /// (when the shortcut's `propagate_when_disabled` is true) lets
3725    /// the walk continue. A disabled action with
3726    /// `propagate_when_disabled == false` consumes the intent at that
3727    /// level without invoking a handler.
3728    ///
3729    /// Returns whether the intent was consumed: `false` when the walk and the
3730    /// window-global fallback both ran out, which is what lets a shortcut's
3731    /// key reach the focused widget after all.
3732    pub(crate) fn dispatch_intent(
3733        &mut self,
3734        source: WidgetId,
3735        intent: crate::intent::Intent,
3736        propagate_when_disabled: bool,
3737        ops: &mut dyn crate::window::WindowOps,
3738    ) -> bool {
3739        // Telemetry tap. Single insertion point catches every intent
3740        // — shortcut-driven, programmatic via `send_intent`, etc. —
3741        // because every dispatch funnels through here. A no-op when
3742        // no `TelemetryContext` is registered.
3743        self.tap_intent_dispatched(&intent);
3744
3745        // Pre-compute the source → root chain so the walk doesn't
3746        // need to hold any arena borrow while invoking handlers.
3747        let chain: Vec<WidgetId> = {
3748            let mut v = vec![source];
3749            let mut current = self.arena.parent(source);
3750            while let Some(id) = current {
3751                v.push(id);
3752                current = self.arena.parent(id);
3753            }
3754            v
3755        };
3756
3757        for id in chain {
3758            if !self.arena.is_active(id) || !self.arena.is_enabled(id) {
3759                continue;
3760            }
3761
3762            // Take out the first matching action by intent name so
3763            // we can invoke its FnMut handler without holding an
3764            // arena-wide borrow. The action is reinserted at its
3765            // original position so declaration order is preserved
3766            // for any follow-on dispatch.
3767            let Some((mut action, idx, enabled)) = self.arena.get_mut(id).and_then(|node| {
3768                let idx = node.actions.iter().position(|a| a.intent == intent.name)?;
3769                let enabled = node.actions[idx].is_enabled();
3770                Some((node.actions.remove(idx), idx, enabled))
3771            }) else {
3772                continue;
3773            };
3774
3775            if !enabled {
3776                // Return the action untouched.
3777                if let Some(node) = self.arena.get_mut(id) {
3778                    node.actions.insert(idx, action);
3779                }
3780                if propagate_when_disabled {
3781                    continue;
3782                }
3783                return true;
3784            }
3785
3786            let mut ctx = self.make_event_context(&mut *ops);
3787            let response = (action.handler)(&intent, &mut ctx);
3788            if let Some(node) = self.arena.get_mut(id) {
3789                node.actions.insert(idx, action);
3790            }
3791            self.collect_from_ctx(ctx, id);
3792
3793            match response {
3794                crate::intent::IntentResponse::Handled => return true,
3795                crate::intent::IntentResponse::Propagated => continue,
3796            }
3797        }
3798
3799        // Fallback: window-global actions (registered via
3800        // `register_action_global`). The source→root walk found no consuming
3801        // node action, so consult app-global commands — reachable regardless of
3802        // where the intent originated (menu-bar overlay, content, shortcut).
3803        let mut i = 0;
3804        while i < self.global_actions.len() {
3805            let matches = {
3806                let (_, action) = &self.global_actions[i];
3807                action.intent == intent.name && action.is_enabled()
3808            };
3809            if !matches {
3810                i += 1;
3811                continue;
3812            }
3813            // Take the action out so the FnMut handler can run without holding a
3814            // borrow on `self`; reinsert at its slot afterwards.
3815            let (owner, mut action) = self.global_actions.remove(i);
3816            let mut ctx = self.make_event_context(&mut *ops);
3817            let response = (action.handler)(&intent, &mut ctx);
3818            self.global_actions.insert(i, (owner, action));
3819            self.collect_from_ctx(ctx, owner);
3820            match response {
3821                crate::intent::IntentResponse::Handled => return true,
3822                crate::intent::IntentResponse::Propagated => {
3823                    i += 1;
3824                    continue;
3825                }
3826            }
3827        }
3828        false
3829    }
3830
3831    /// Register a window-global [`Action`](crate::action::Action) owned by
3832    /// `owner`. Consulted as a dispatch fallback (see [`Self::dispatch_intent`]);
3833    /// torn down when `owner` rebuilds or is destroyed. Backs
3834    /// [`BuildContext::register_action_global`](crate::BuildContext::register_action_global).
3835    pub(crate) fn push_global_action(&mut self, owner: WidgetId, action: crate::action::Action) {
3836        self.global_actions.push((owner, action));
3837    }
3838
3839    // --- Window-close request (drained by the app loop) ---
3840
3841    /// Drain the "close this window" flag set by
3842    /// [`EventContext::close_window`] during dispatch. A *guarded* close
3843    /// — the app routes it through the window's close guard.
3844    pub fn take_close_window_request(&mut self) -> bool {
3845        std::mem::replace(&mut self.close_window_requested, false)
3846    }
3847
3848    /// Drain the "close this window, no questions asked" flag set by
3849    /// [`EventContext::close_window_forced`] during dispatch. An
3850    /// *unconditional* close that bypasses the window's close guard.
3851    pub fn take_force_close_request(&mut self) -> bool {
3852        std::mem::replace(&mut self.force_close_requested, false)
3853    }
3854
3855    /// Drain the pending locale switch raised by
3856    /// [`EventContext::set_locale`] during dispatch. The app layer
3857    /// (`WindowManager::drain_pending_locale_requests`) parses the
3858    /// result and routes it through `WindowManager::set_locale` so the
3859    /// `I18nManager`'s active locale, version signal, and layout
3860    /// direction all stay in sync with the tree.
3861    pub fn take_pending_locale_request(&mut self) -> Option<String> {
3862        self.pending_locale_request.take()
3863    }
3864
3865    /// Drain the pending theme switch raised by
3866    /// [`EventContext::set_theme`] during dispatch. The app layer
3867    /// (`WindowManager::drain_pending_theme_requests`) routes it through
3868    /// `WindowManager::set_theme` so the new theme is applied to every
3869    /// window, not just the one whose handler requested it.
3870    pub fn take_pending_theme_request(&mut self) -> Option<crate::styles::Theme> {
3871        self.pending_theme_request.take()
3872    }
3873
3874    /// Drain the pending "follow OS theme" request raised by
3875    /// [`EventContext::follow_system_theme`] during dispatch. The app layer
3876    /// (`WindowManager::drain_pending_follow_system_requests`) switches to
3877    /// `ThemeMode::Native` and recomputes the theme from the OS for every
3878    /// window. Returns `true` if a request was pending.
3879    pub fn take_pending_follow_system_request(&mut self) -> bool {
3880        std::mem::take(&mut self.pending_follow_system_request)
3881    }
3882
3883    /// Drain the pending text-scale change raised by
3884    /// [`EventContext::set_text_scale`] during dispatch. The app layer
3885    /// (`WindowManager::drain_pending_text_scale_requests`) routes it through
3886    /// `WindowManager::set_text_scale` so the new factor is applied to every
3887    /// window, not just the one whose handler requested it.
3888    pub fn take_pending_text_scale_request(&mut self) -> Option<f32> {
3889        self.pending_text_scale_request.take()
3890    }
3891
3892    /// Drain all pending modal requests recorded during event handling.
3893    ///
3894    /// Each request includes the originating widget so higher layers can
3895    /// resolve routing and focus behavior relative to the source tree.
3896    pub fn drain_pending_modal_requests(&mut self) -> Vec<crate::modal::QueuedModalRequest> {
3897        std::mem::take(&mut self.pending_modal_requests)
3898    }
3899
3900    /// Drain whether the current native modal window should be dismissed.
3901    pub fn drain_pending_modal_dismissal(&mut self) -> bool {
3902        std::mem::replace(&mut self.pending_modal_dismissal, false)
3903    }
3904
3905    // --- Widget insertion ---
3906
3907    /// Walk `Widget::declare_shortcuts` for an already-inserted widget
3908    /// and register every returned shortcut with the registry, owned
3909    /// by `id`. Called at insertion AND at rebuild so the declared
3910    /// metadata survives across rebuilds (which `unregister_all_for_owner`
3911    /// would otherwise wipe). Build-time `ctx.register_shortcut` calls
3912    /// upsert handlers on top; the registry is idempotent on id.
3913    pub(crate) fn apply_declared_shortcuts(&mut self, id: WidgetId) {
3914        let declared = self
3915            .arena
3916            .get(id)
3917            .map(|n| n.widget.declare_shortcuts())
3918            .unwrap_or_default();
3919        for shortcut in declared {
3920            self.shortcut_registry.register_owned(shortcut, id);
3921        }
3922    }
3923
3924    /// Internal: insert a widget, call build(), wire children, register clips.
3925    fn insert_widget(&mut self, widget: Box<dyn Widget>) -> WidgetId {
3926        let id = self.arena.insert(widget);
3927
3928        {
3929            if let Some(mut widget_box) = self.arena.take_widget(id) {
3930                if let Some(handler_set) = widget_box.take_handler_set() {
3931                    self.arena.restore_widget(id, widget_box);
3932                    if let Some(node) = self.arena.get_mut(id) {
3933                        // Handlers attached at the widget's creation site
3934                        // are external from its own perspective — keep
3935                        // them out of the rebuild-cleared `handlers`
3936                        // slot so they survive data-driven rebuilds.
3937                        node.external_handlers = handler_set.handlers;
3938                        node.node_focusable = handler_set.focusable;
3939                        node.node_tab_index = handler_set.tab_index;
3940                        node.node_cursor = handler_set.cursor;
3941                        // `clips_children` and `event_pass_through` are
3942                        // node-level flags on `WidgetNode` — they must
3943                        // be mirrored here too. Without this an
3944                        // `Inner::new().event_pass_through(true)` chain
3945                        // silently no-ops (the flag stays at default
3946                        // `false`), and any widget wrapped with it
3947                        // catches every pointer event in its bounds.
3948                        if let Some(clips) = handler_set.clips_children {
3949                            node.clips_children = clips;
3950                        }
3951                        if let Some(pass_through) = handler_set.event_pass_through {
3952                            node.event_pass_through = pass_through;
3953                        }
3954                        if let Some(dead_zone) = handler_set.gesture_dead_zone {
3955                            node.gesture_dead_zone = dead_zone;
3956                        }
3957                        if let Some(role) = handler_set.long_press_role {
3958                            node.long_press_role = role;
3959                        }
3960                        if let Some(action) = handler_set.touch_action {
3961                            node.touch_action = action;
3962                        }
3963                        if let Some(claim) = handler_set.pan_claim {
3964                            node.pan_claim = Some(claim);
3965                        }
3966                        if let Some(behavior) = handler_set.overscroll_behavior {
3967                            node.overscroll_behavior = behavior;
3968                        }
3969                        if let Some(activation) = handler_set.drag_activation {
3970                            node.drag_activation = activation;
3971                        }
3972                        if let Some(policy) = handler_set.multi_contact {
3973                            node.multi_contact = policy;
3974                        }
3975                        if let Some(keyboard_capture) = handler_set.keyboard_capture {
3976                            node.keyboard_capture = keyboard_capture;
3977                        }
3978                        if let Some(hit_transparent) = handler_set.hit_transparent {
3979                            node.hit_transparent = hit_transparent;
3980                        }
3981                        if let Some(slop) = handler_set.hit_slop {
3982                            node.hit_slop = Some(slop);
3983                        }
3984                        if let Some(no_slop) = handler_set.no_hit_slop {
3985                            node.no_hit_slop = no_slop;
3986                        }
3987                        if handler_set.context_menu_factory.is_some() {
3988                            node.context_menu_factory = handler_set.context_menu_factory;
3989                        }
3990                        if let Some(sig) = handler_set.focus_within {
3991                            node.focus_within_signal = Some(sig);
3992                        }
3993                        if let Some(sig) = handler_set.hover_within {
3994                            node.hover_within_signal = Some(sig);
3995                        }
3996                        // Builder-chained `visible_when: prop`. Mirror of
3997                        // `WidgetTree::visible_when`: register a bound prop at
3998                        // Relayout, then store it on the node. (Disjoint field
3999                        // borrow, like `access_hidden` below.)
4000                        if let Some(prop) = handler_set.visible_when {
4001                            prop.register_if_bound(
4002                                id,
4003                                &self.binding_registry,
4004                                crate::binding::BindingLevel::Relayout,
4005                            );
4006                            node.visible_state = Some(prop);
4007                        }
4008                        // Builder-level accessibility overrides + subtree
4009                        // mode. Mirrored here because this insertion path
4010                        // bypasses `apply_handler_set`.
4011                        if handler_set.access.is_some() {
4012                            // Register any bound access_hidden/label/description/
4013                            // value Props at AccessibilityOnly so the AT tree
4014                            // re-walks when they flip. (Disjoint field borrow:
4015                            // `node` borrows `self.arena`, this reads
4016                            // `self.binding_registry`.)
4017                            if let Some(access) = handler_set.access.as_ref() {
4018                                Self::register_access_prop_bindings(
4019                                    access,
4020                                    id,
4021                                    &self.binding_registry,
4022                                );
4023                            }
4024                            // Merged, not assigned: a node can already carry a
4025                            // block from its builder chain, and replacing it
4026                            // drops everything in it (see
4027                            // `AccessibilityOverrides::merge_from`).
4028                            match (&mut node.access_overrides, handler_set.access) {
4029                                (Some(existing), Some(incoming)) => existing.merge_from(*incoming),
4030                                (slot, incoming) => *slot = incoming,
4031                            }
4032                        }
4033                        if let Some(mode) = handler_set.access_subtree {
4034                            node.access_subtree = mode;
4035                        }
4036                    }
4037                } else {
4038                    self.arena.restore_widget(id, widget_box);
4039                }
4040            }
4041        }
4042
4043        // Walk Widget::declare_shortcuts before build() so the
4044        // declared metadata lands in the registry first; if build()
4045        // also registers the same id with a real on_activate, the
4046        // registry upserts (preserving any user override).
4047        self.apply_declared_shortcuts(id);
4048
4049        {
4050            let mut widget_box = match self.arena.take_widget(id) {
4051                Some(widget) => widget,
4052                None => return id,
4053            };
4054            let mut build_ctx = crate::build_context::BuildContext {
4055                tree: self,
4056                composite_id: Some(id),
4057                effect_handles: Vec::new(),
4058                subscription_handles: Vec::new(),
4059                // A first mount has no previous build to inherit ids from.
4060                reusable_sub_ids: Vec::new(),
4061            };
4062            let built_children = widget_box.build(&mut build_ctx);
4063            let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
4064            let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);
4065
4066            self.arena.restore_widget(id, widget_box);
4067
4068            // Transfer per-widget handles to the node. Both lists are
4069            // stored unconditionally — a leaf widget that registers an
4070            // effect in its build() still needs its ObserverHandle to
4071            // persist (otherwise the effect unregisters the moment
4072            // BuildContext drops).
4073            if let Some(node) = self.arena.get_mut(id) {
4074                node.subscription_handles = subscription_handles;
4075                node.effect_handles = effect_handles;
4076            }
4077
4078            if !built_children.is_empty() {
4079                for &child_id in &built_children {
4080                    if let Some(child_node) = self.arena.get_mut(child_id) {
4081                        child_node.parent = Some(id);
4082                    }
4083                }
4084                if let Some(node) = self.arena.get_mut(id) {
4085                    node.children = built_children;
4086                }
4087            }
4088        }
4089
4090        let clips = self
4091            .arena
4092            .get(id)
4093            .is_some_and(|node| node.widget.clips_children());
4094        if clips {
4095            self.arena.set_clips_children(id, true);
4096        }
4097
4098        id
4099    }
4100
4101    /// Add a widget to the tree.
4102    pub fn add(&mut self, widget: impl Widget + 'static) -> WidgetId {
4103        self.insert_widget(Box::new(widget))
4104    }
4105
4106    /// Add a pre-boxed widget to the tree.
4107    pub fn add_boxed(&mut self, widget: Box<dyn Widget>) -> WidgetId {
4108        self.insert_widget(widget)
4109    }
4110
4111    /// The widget that paints `id`'s title, when it has one.
4112    ///
4113    /// See [`Widget::accessible_title_node`].
4114    pub fn widget_accessible_title_node(&self, id: WidgetId) -> Option<WidgetId> {
4115        self.arena.get(id)?.widget.accessible_title_node()
4116    }
4117
4118    /// Add a widget as a child of another widget.
4119    pub fn add_child(&mut self, parent: WidgetId, widget: impl Widget + 'static) -> WidgetId {
4120        let boxed: Box<dyn Widget> = Box::new(widget);
4121
4122        let id = self.arena.insert_child(parent, boxed);
4123
4124        {
4125            if let Some(mut widget_box) = self.arena.take_widget(id) {
4126                if let Some(handler_set) = widget_box.take_handler_set() {
4127                    self.arena.restore_widget(id, widget_box);
4128                    if let Some(node) = self.arena.get_mut(id) {
4129                        // Creation-site handlers are external (persist
4130                        // across the widget's own rebuilds) — see the
4131                        // matching block in `insert_widget`.
4132                        node.external_handlers = handler_set.handlers;
4133                        node.node_focusable = handler_set.focusable;
4134                        node.node_tab_index = handler_set.tab_index;
4135                        node.node_cursor = handler_set.cursor;
4136                        if let Some(clips) = handler_set.clips_children {
4137                            node.clips_children = clips;
4138                        }
4139                        if let Some(pass_through) = handler_set.event_pass_through {
4140                            node.event_pass_through = pass_through;
4141                        }
4142                        if let Some(dead_zone) = handler_set.gesture_dead_zone {
4143                            node.gesture_dead_zone = dead_zone;
4144                        }
4145                        if let Some(role) = handler_set.long_press_role {
4146                            node.long_press_role = role;
4147                        }
4148                        if let Some(action) = handler_set.touch_action {
4149                            node.touch_action = action;
4150                        }
4151                        if let Some(claim) = handler_set.pan_claim {
4152                            node.pan_claim = Some(claim);
4153                        }
4154                        if let Some(behavior) = handler_set.overscroll_behavior {
4155                            node.overscroll_behavior = behavior;
4156                        }
4157                        if let Some(activation) = handler_set.drag_activation {
4158                            node.drag_activation = activation;
4159                        }
4160                        if let Some(policy) = handler_set.multi_contact {
4161                            node.multi_contact = policy;
4162                        }
4163                        if let Some(keyboard_capture) = handler_set.keyboard_capture {
4164                            node.keyboard_capture = keyboard_capture;
4165                        }
4166                        if let Some(hit_transparent) = handler_set.hit_transparent {
4167                            node.hit_transparent = hit_transparent;
4168                        }
4169                        if let Some(slop) = handler_set.hit_slop {
4170                            node.hit_slop = Some(slop);
4171                        }
4172                        if let Some(no_slop) = handler_set.no_hit_slop {
4173                            node.no_hit_slop = no_slop;
4174                        }
4175                        if handler_set.context_menu_factory.is_some() {
4176                            node.context_menu_factory = handler_set.context_menu_factory;
4177                        }
4178                        if let Some(sig) = handler_set.focus_within {
4179                            node.focus_within_signal = Some(sig);
4180                        }
4181                        if let Some(sig) = handler_set.hover_within {
4182                            node.hover_within_signal = Some(sig);
4183                        }
4184                        // Builder-chained `visible_when: prop`. Same as in
4185                        // `insert_widget`.
4186                        if let Some(prop) = handler_set.visible_when {
4187                            prop.register_if_bound(
4188                                id,
4189                                &self.binding_registry,
4190                                crate::binding::BindingLevel::Relayout,
4191                            );
4192                            node.visible_state = Some(prop);
4193                        }
4194                        // Builder-level accessibility overrides + subtree
4195                        // mode. Same rationale as in `insert_widget`.
4196                        if handler_set.access.is_some() {
4197                            // Register any bound access_hidden/label/description/
4198                            // value Props at AccessibilityOnly so the AT tree
4199                            // re-walks when they flip. (Disjoint field borrow:
4200                            // `node` borrows `self.arena`, this reads
4201                            // `self.binding_registry`.)
4202                            if let Some(access) = handler_set.access.as_ref() {
4203                                Self::register_access_prop_bindings(
4204                                    access,
4205                                    id,
4206                                    &self.binding_registry,
4207                                );
4208                            }
4209                            // Merged, not assigned: a node can already carry a
4210                            // block from its builder chain, and replacing it
4211                            // drops everything in it (see
4212                            // `AccessibilityOverrides::merge_from`).
4213                            match (&mut node.access_overrides, handler_set.access) {
4214                                (Some(existing), Some(incoming)) => existing.merge_from(*incoming),
4215                                (slot, incoming) => *slot = incoming,
4216                            }
4217                        }
4218                        if let Some(mode) = handler_set.access_subtree {
4219                            node.access_subtree = mode;
4220                        }
4221                    }
4222                } else {
4223                    self.arena.restore_widget(id, widget_box);
4224                }
4225            }
4226        }
4227
4228        // Same shortcut-declaration walk as `insert_widget` — keeps
4229        // metadata visible from the moment the child mounts, before
4230        // build() runs.
4231        self.apply_declared_shortcuts(id);
4232
4233        {
4234            if let Some(mut widget_box) = self.arena.take_widget(id) {
4235                let mut build_ctx = crate::build_context::BuildContext {
4236                    tree: self,
4237                    composite_id: Some(id),
4238                    effect_handles: Vec::new(),
4239                    subscription_handles: Vec::new(),
4240                    // A first mount has no previous build to inherit ids from.
4241                    reusable_sub_ids: Vec::new(),
4242                };
4243                let built_children = widget_box.build(&mut build_ctx);
4244                let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
4245                let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);
4246
4247                self.arena.restore_widget(id, widget_box);
4248
4249                // Transfer per-widget handles to the node. See the
4250                // matching block in `insert_widget` — effect and
4251                // subscription handles must persist for leaf widgets
4252                // too, not only composite ones.
4253                if let Some(node) = self.arena.get_mut(id) {
4254                    node.subscription_handles = subscription_handles;
4255                    node.effect_handles = effect_handles;
4256                }
4257
4258                if !built_children.is_empty() {
4259                    for &child_id in &built_children {
4260                        if let Some(child_node) = self.arena.get_mut(child_id) {
4261                            child_node.parent = Some(id);
4262                        }
4263                    }
4264                    if let Some(node) = self.arena.get_mut(id) {
4265                        node.children = built_children;
4266                    }
4267                }
4268            }
4269        }
4270
4271        let clips = self
4272            .arena
4273            .get(id)
4274            .is_some_and(|node| node.widget.clips_children());
4275        if clips {
4276            self.arena.set_clips_children(id, true);
4277        }
4278
4279        id
4280    }
4281
4282    // --- Property bindings ---
4283
4284    /// Bind a widget's visibility to a boolean prop.
4285    /// When false, the widget is set dormant; when true, it is activated.
4286    /// Accepts `Signal<bool>`, `Prop<bool>`, or plain `bool`.
4287    pub fn visible_when(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
4288        let prop = state.into();
4289        prop.register_if_bound(
4290            id,
4291            &self.binding_registry,
4292            crate::binding::BindingLevel::Relayout,
4293        );
4294        if let Some(node) = self.arena.get_mut(id) {
4295            node.visible_state = Some(prop);
4296        }
4297    }
4298
4299    /// Set a widget subtree as dormant.
4300    ///
4301    /// Goes through the tree's cancel-aware parking door, so a pointer working
4302    /// inside the subtree is cancelled rather than stranded on a widget the
4303    /// dispatcher will no longer reach.
4304    pub fn set_dormant(&mut self, id: WidgetId) {
4305        self.park_subtree(id);
4306        self.arena.mark_ancestors_need_layout(id);
4307        self.cached_frame = None;
4308        self.a11y_dirty = true;
4309    }
4310
4311    /// Activate a dormant widget subtree.
4312    pub fn activate(&mut self, id: WidgetId) {
4313        self.arena.activate(id);
4314        self.arena.mark_ancestors_need_layout(id);
4315        self.cached_frame = None;
4316        self.a11y_dirty = true;
4317    }
4318
4319    /// Install (or reuse) the activation signal on a node and return a
4320    /// handle to it. The framework sets it to `false` when the node is
4321    /// parked dormant (`Switcher` / `visible_when`) and `true` when it is
4322    /// re-activated — see [`crate::arena::WidgetArena::set_dormant`] /
4323    /// [`activate`](crate::arena::WidgetArena::activate). The returned
4324    /// signal is initialised to the node's current active state. Used by
4325    /// widgets owning a resource outside the paint pass (a native subview)
4326    /// that must hide/show it in lockstep with framework activation.
4327    pub fn activation_signal(&mut self, id: WidgetId) -> crate::signal::Signal<bool> {
4328        if let Some(node) = self.arena.get_mut(id) {
4329            if let Some(existing) = node.activation_signal.clone() {
4330                return existing;
4331            }
4332            let active = node.activation == crate::arena::ActivationState::Active;
4333            let sig = crate::signal::Signal::new(active);
4334            node.activation_signal = Some(sig.clone());
4335            sig
4336        } else {
4337            // Node missing (shouldn't happen in build) — hand back a
4338            // detached signal so the caller still gets a valid handle.
4339            crate::signal::Signal::new(true)
4340        }
4341    }
4342
4343    // -----------------------------------------------------------------
4344    // In-node target geometry
4345    // -----------------------------------------------------------------
4346
4347    /// The interactive sub-regions the widget at `id` paints inside its own
4348    /// single node, in absolute arena coordinates.
4349    ///
4350    /// The read side of [`Widget::target_regions`]:
4351    /// a scroll bar's thumb, a slider's knob, a header cell's filter
4352    /// affordance. Empty for the overwhelming majority of widgets, whose node
4353    /// *is* their target and which therefore have nothing to add.
4354    ///
4355    /// Reporting only — reading this changes nothing. It exists so a
4356    /// conformance audit, and a test of one, can see geometry that no layout
4357    /// ever produced.
4358    pub fn widget_target_regions(&self, id: WidgetId) -> Vec<crate::partition::TargetRegion> {
4359        let Some(node) = self.arena.get(id) else {
4360            return Vec::new();
4361        };
4362        node.widget.target_regions(self.arena.bounds(id))
4363    }
4364
4365    /// The hit outset the **mounted** widget at `id` declares for `kind`,
4366    /// against the tree's live input tokens.
4367    ///
4368    /// The read side of [`Widget::hit_outset`], and the companion of
4369    /// [`widget_target_regions`](Self::widget_target_regions): both let a
4370    /// conformance audit — and a test of one — see target geometry that no
4371    /// layout ever produced.
4372    ///
4373    /// It has to read the mounted node rather than a freshly-built widget,
4374    /// because an outset is usually derived from what the widget *painted*,
4375    /// and an unmounted one has painted nothing. That is also what makes the
4376    /// **gates** assertable: a decorative avatar, an inert twist arrow, a
4377    /// disabled swatch and a breadcrumb's current crumb all take no press, so
4378    /// each must declare `EdgeInsets::ZERO` — a widened node that then refuses
4379    /// the press is a hole punched in whatever is behind it.
4380    ///
4381    /// The tokens are the **effective** theme's, not `theme`'s, because that
4382    /// is what the hit path itself reads:
4383    /// `hit_test_for_excluding` builds its `HitContext` from
4384    /// `effective_theme.input`, as do the pointer profile and the touch-enabled
4385    /// gate in `pointer_state.rs`. The two themes agree only for as long as
4386    /// nothing between them touches `input` — `recompute_effective_theme`
4387    /// currently projects typography alone — and an accessor that describes a
4388    /// path has to read that path's source rather than one that happens to
4389    /// match it.
4390    ///
4391    /// Reporting only — reading this changes nothing.
4392    pub fn widget_hit_outset(
4393        &self,
4394        id: WidgetId,
4395        kind: teksilo_tokens::PointerKind,
4396    ) -> teksilo_canvas::EdgeInsets {
4397        let Some(node) = self.arena.get(id) else {
4398            return teksilo_canvas::EdgeInsets::ZERO;
4399        };
4400        node.widget.hit_outset(kind, &self.effective_theme.input)
4401    }
4402
4403    // -----------------------------------------------------------------
4404    // Press state
4405    // -----------------------------------------------------------------
4406
4407    /// Install (or reuse) the framework press signal on a node and return a
4408    /// handle to it.
4409    ///
4410    /// `true` while the node holds a pointer press whose visual is showing:
4411    /// between press and release, `false` once the pointer leaves the press's
4412    /// tap boundary and `true` again on re-entry, cleared on a cancel or when
4413    /// a peer wins the arbitration. See `docs/touch-and-pen.md` §7.
4414    pub fn pressed_signal(&mut self, id: WidgetId) -> crate::signal::Signal<bool> {
4415        let showing = self.is_pressed(id);
4416        let Some(node) = self.arena.get_mut(id) else {
4417            // Node missing (should not happen during build) — hand back a
4418            // detached signal so the caller still gets a valid handle.
4419            return crate::signal::Signal::new(false);
4420        };
4421        if let Some(existing) = node.pressed_signal.clone() {
4422            return existing;
4423        }
4424        let sig = crate::signal::Signal::new(showing);
4425        node.pressed_signal = Some(sig.clone());
4426        sig
4427    }
4428
4429    /// The press held by the pointer being dispatched, as `(inside, pending)`,
4430    /// for [`EventContext`]'s per-dispatch
4431    /// snapshot. `None` when that pointer holds no press.
4432    pub(crate) fn current_press_snapshot(&self) -> Option<(bool, bool)> {
4433        self.presses
4434            .get(self.current_pointer_id())
4435            .map(|p| (p.inside, p.pending()))
4436    }
4437
4438    /// The contact holding `id`'s press, whether or not its visual is showing.
4439    ///
4440    /// `None` for a node nothing is pressing. A node held by a finger whose
4441    /// press-feedback delay has not elapsed still answers with that finger:
4442    /// the press is real, only its visual is waiting.
4443    pub fn pressed_by(&self, id: WidgetId) -> Option<crate::pointer::PointerId> {
4444        self.presses.owner_of(id)
4445    }
4446
4447    /// Whether `id`'s press visual is showing — held, inside its tap boundary,
4448    /// and past any press-feedback delay. What the node's
4449    /// [`pressed_signal`](Self::pressed_signal) mirrors.
4450    pub fn is_pressed(&self, id: WidgetId) -> bool {
4451        self.presses
4452            .for_node(id)
4453            .is_some_and(crate::press::Press::showing)
4454    }
4455
4456    /// Whether `id` is held and the pointer has not left the press's tap
4457    /// boundary. True during a press-feedback delay, unlike
4458    /// [`is_pressed`](Self::is_pressed).
4459    pub fn press_is_inside(&self, id: WidgetId) -> bool {
4460        self.presses.for_node(id).is_some_and(|p| p.inside)
4461    }
4462
4463    /// Whether `id` is held but its press-feedback delay has not elapsed, so
4464    /// the visual is deliberately withheld.
4465    pub fn press_pending(&self, id: WidgetId) -> bool {
4466        self.presses
4467            .for_node(id)
4468            .is_some_and(crate::press::Press::pending)
4469    }
4470
4471    /// Publish `id`'s press signal from the table. The one place a press
4472    /// signal is written, so "the table changed" and "the recipe was told"
4473    /// cannot drift apart.
4474    pub(crate) fn publish_pressed(&mut self, id: WidgetId) {
4475        let showing = self.is_pressed(id);
4476        if let Some(node) = self.arena.get(id)
4477            && let Some(sig) = node.pressed_signal.clone()
4478            && sig.get() != showing
4479        {
4480            sig.set(showing);
4481        }
4482    }
4483
4484    /// Fire the `activation_signal` of every node that transitioned
4485    /// Active↔Dormant since the last flush. Called at a well-defined tree-level
4486    /// point (the end of `process_state_changes`), never from inside the arena
4487    /// recursion — so an observer (e.g. a `WebView` calling the engine's
4488    /// `set_visible`) runs after the visibility pass has fully committed,
4489    /// matching the `focus_within` / `hover_within` update discipline.
4490    pub(crate) fn flush_activation_signals(&mut self) {
4491        let changes = self.arena.take_activation_changes();
4492        for (id, _recorded) in changes {
4493            // Re-read the node at flush time; it still exists (the transition
4494            // was recorded in the same synchronous operation).
4495            let Some(node) = self.arena.get(id) else {
4496                continue;
4497            };
4498            let Some(sig) = node.activation_signal.clone() else {
4499                continue;
4500            };
4501            // Fire the node's **current** state, not the value recorded at the
4502            // transition, and only when it differs from what observers last
4503            // saw. `pending_activation_changes` is an append-only queue: a
4504            // node parked and re-activated inside one batch records both
4505            // edges, and replaying them in order hands observers a `false`
4506            // that was never observable — the node is already Active by the
4507            // time anyone is told anything.
4508            //
4509            // The in-tree modal path does exactly that on every open: build
4510            // the content, `set_dormant` it, mount the scrim, `activate` it,
4511            // then move focus in. Both edges land in one batch and flush
4512            // *after* the focus dispatch, so the stale `false` arrives last
4513            // and observers act on a state that has already been superseded.
4514            // For a text editor that meant its dormancy handler wiped the
4515            // `has_focus` it had just been granted, and the dialog opened with
4516            // no caret; a `WebView` would have taken a real `set_visible(false)`
4517            // OS call for a subview that never left the screen.
4518            //
4519            // Collapsing to the final state also makes the flush idempotent
4520            // over duplicate ids: the first iteration syncs the signal, the
4521            // rest find it already equal and skip. `Signal::set` notifies
4522            // unconditionally, so the equality guard is what stops the
4523            // redundant fanout.
4524            let active = node.activation == crate::arena::ActivationState::Active;
4525            if sig.get() != active {
4526                sig.set(active);
4527            }
4528        }
4529    }
4530
4531    /// Bind an opacity multiplier (0..1) to a widget. The render walker
4532    /// emits `SetOpacity(value)` before painting the widget's subtree
4533    /// and `RestoreOpacity` afterwards, so the multiplier composes
4534    /// correctly with ancestor opacity scopes via the canvas's stacked
4535    /// opacity model. Bound at `Repaint` level: opacity changes never
4536    /// trigger relayout. Pass any `Prop<f32>` or `Signal<f32>` source
4537    /// (typically an animated signal driven by a `Fade` wrapper).
4538    pub fn set_opacity(&mut self, id: WidgetId, opacity: impl Into<crate::signal::Prop<f32>>) {
4539        let prop = opacity.into();
4540        prop.register_if_bound(
4541            id,
4542            &self.binding_registry,
4543            crate::binding::BindingLevel::RepaintOnly,
4544        );
4545        if let Some(node) = self.arena.get_mut(id) {
4546            node.opacity_prop = Some(prop);
4547        }
4548    }
4549
4550    /// Bind a 2D affine transform to a widget. The render walker emits
4551    /// `PushTransform(value)` before painting the widget's subtree and
4552    /// `PopTransform` afterwards; the renderer composes the transform
4553    /// onto its stack so nested wrappers and widget-internal canvas
4554    /// transforms compose correctly. Bound at `Repaint` level: visual-
4555    /// only transforms never trigger relayout. Wrappers that want the
4556    /// transform's *value change* to also drive layout (e.g.
4557    /// `Scale::reflow(true)`) must additionally bind the *driver*
4558    /// signal to themselves at `Relayout` level — the transform prop
4559    /// itself stays at Repaint. Pass a `Transform2D`, `Signal<Transform2D>`,
4560    /// or `Prop<Transform2D>`.
4561    pub fn set_transform(
4562        &mut self,
4563        id: WidgetId,
4564        transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
4565    ) {
4566        let prop = transform.into();
4567        prop.register_if_bound(
4568            id,
4569            &self.binding_registry,
4570            crate::binding::BindingLevel::RepaintOnly,
4571        );
4572        if let Some(node) = self.arena.get_mut(id) {
4573            node.transform_prop = Some(prop);
4574            // A plain transform is a *self* transform — clear any prior
4575            // content-transform marker so the flag can never go stale if a
4576            // node switches from `set_content_transform` to `set_transform`.
4577            node.content_transform = false;
4578        }
4579    }
4580
4581    /// Like [`set_transform`](Self::set_transform), but marks the transform as
4582    /// a **content** transform: it positions the node's content within a fixed
4583    /// parent-space viewport (the node's bounds) rather than transforming the
4584    /// node itself. Hit-testing then keeps the whole viewport interactive at
4585    /// any pan / zoom. Used by `SceneView`; see
4586    /// `WidgetNode::content_transform`.
4587    pub fn set_content_transform(
4588        &mut self,
4589        id: WidgetId,
4590        transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
4591    ) {
4592        let prop = transform.into();
4593        prop.register_if_bound(
4594            id,
4595            &self.binding_registry,
4596            crate::binding::BindingLevel::RepaintOnly,
4597        );
4598        if let Some(node) = self.arena.get_mut(id) {
4599            node.transform_prop = Some(prop);
4600            node.content_transform = true;
4601        }
4602    }
4603
4604    /// Bind a Gaussian-equivalent blur radius to a widget. The render
4605    /// walker emits `BeginBlurredSubtree { bounds, radius }` before
4606    /// painting the widget's subtree and `EndBlurredSubtree` afterwards;
4607    /// the renderer redirects drawing into an intermediate texture, runs
4608    /// a dual-Kawase blur chain at the requested radius, and composites
4609    /// the blurred result back into the parent pass. Bound at `Repaint`
4610    /// level: blur radius changes never trigger relayout. Sub-perceptual
4611    /// radii (< 0.5) skip the Begin/End pair entirely so animated
4612    /// enable/disable patterns have zero per-frame cost when fully off.
4613    /// Pass any `Prop<f32>` or `Signal<f32>` source.
4614    pub fn set_blur(&mut self, id: WidgetId, radius: impl Into<crate::signal::Prop<f32>>) {
4615        let prop = radius.into();
4616        prop.register_if_bound(
4617            id,
4618            &self.binding_registry,
4619            crate::binding::BindingLevel::RepaintOnly,
4620        );
4621        if let Some(node) = self.arena.get_mut(id) {
4622            node.blur_prop = Some(prop);
4623        }
4624    }
4625
4626    /// Bind a widget's enabled state to a boolean prop or compatibility state binding.
4627    /// When false, the widget and its entire subtree ignore all events but remain
4628    /// visible. Focus traversal skips disabled subtrees and AccessKit marks their
4629    /// nodes as disabled. Accepts `Signal<bool>`, `Prop<bool>`, compatibility state
4630    /// bindings, or plain `bool`.
4631    ///
4632    /// The bound signal registers at `BindingLevel::SubtreeRepaint`: when
4633    /// it flips, the entire subtree rooted at `id` is marked for repaint
4634    /// (not relayout — geometry doesn't change). Leaves like
4635    /// `IconWidget` then re-resolve their role color
4636    /// using the new `PaintContext::effective_enabled` value, so a
4637    /// disabled subtree's icons and text dim automatically.
4638    pub fn enabled_when(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
4639        let prop = state.into();
4640        // SubtreeRepaint propagates the visual dirty mark through the
4641        // disabled subtree so leaves re-resolve their role colors.
4642        prop.register_if_bound(
4643            id,
4644            &self.binding_registry,
4645            crate::binding::BindingLevel::SubtreeRepaint,
4646        );
4647        // AccessibilityOnly is orthogonal — it flips `a11y_dirty` so
4648        // AccessKit's `disabled` flag refreshes on the next a11y sync
4649        // (the accessibility walker reads `arena.is_enabled(id)`,
4650        // which is already correct via the prop, but the tree needs
4651        // to be told to rebuild).
4652        prop.register_if_bound(
4653            id,
4654            &self.binding_registry,
4655            crate::binding::BindingLevel::AccessibilityOnly,
4656        );
4657        if let Some(node) = self.arena.get_mut(id) {
4658            node.enabled_state = Some(prop);
4659        }
4660    }
4661
4662    /// Reactive view of "is this widget effectively enabled?" — the AND
4663    /// of the widget's own `enabled_state` and every ancestor's.
4664    /// [`Self::is_enabled`] is the non-reactive equivalent; this method
4665    /// gives composite widgets a `Signal<bool>` for derived state.
4666    ///
4667    /// Leaves (`IconWidget`, `TextWidget`, `RectWidget`) do NOT need this
4668    /// — they receive the resolved bool via
4669    /// [`crate::widget::PaintContext::effective_enabled`] at paint time.
4670    /// This method is for composites that want to derive cursor / custom
4671    /// paint roles / etc. reactively.
4672    ///
4673    /// Install-or-reuse, exactly like [`Self::activation_signal`]: the signal
4674    /// lives on the node and the framework refreshes it from the live arena
4675    /// once per state-change pass (`flush_effective_enabled_signals`).
4676    ///
4677    /// It is deliberately NOT a signal derived by walking the ancestor chain
4678    /// here. A widget's `parent` is still `None` while its own `build()` runs
4679    /// — `insert_widget` inserts the node parentless and wires the parent link
4680    /// only after `build()` returns — so an ancestor walk performed from
4681    /// inside `build()` (which is how every caller uses this) sees an empty
4682    /// chain and would capture the widget's OWN `enabled` prop as the whole
4683    /// answer, permanently. That was a real bug: a Button inside a disabled
4684    /// form stayed painted as if enabled.
4685    ///
4686    /// The value is seeded from the live arena and corrected on the next
4687    /// flush, so a first-`build()` caller (parent not yet wired) and a
4688    /// rebuild caller (parent wired) both converge before anything paints.
4689    pub fn effective_enabled_signal(&mut self, id: WidgetId) -> crate::signal::Signal<bool> {
4690        if let Some(existing) = self
4691            .arena
4692            .get(id)
4693            .and_then(|n| n.effective_enabled_signal.clone())
4694        {
4695            return existing;
4696        }
4697        // Seed from the live tree. Mid-`build()` the parent is not wired yet,
4698        // so this is the widget's own state only; `flush_effective_enabled_signals`
4699        // corrects it against the fully-wired tree before the first paint.
4700        let seed = self.arena.is_enabled(id);
4701        let sig = crate::signal::Signal::new(seed);
4702        let Some(node) = self.arena.get_mut(id) else {
4703            // Node missing (shouldn't happen in build) — hand back a detached
4704            // handle so the caller still gets a valid signal.
4705            return crate::signal::Signal::new(true);
4706        };
4707        node.effective_enabled_signal = Some(sig.clone());
4708        self.arena.watch_effective_enabled(id);
4709        sig
4710    }
4711
4712    /// Refresh every node-resident `effective_enabled_signal` against the live
4713    /// arena, firing observers only where the value actually changed.
4714    ///
4715    /// Unlike [`Self::flush_activation_signals`] this cannot be driven off a
4716    /// change queue: a node's `enabled_state` is a `Prop<bool>` that may be
4717    /// bound to an app `Signal` which flips without the arena being notified,
4718    /// so there is no mutation site at which to record a transition. Instead
4719    /// this recomputes the (cheap, `O(depth)`) ancestor AND for each opted-in
4720    /// node and diffs. Only nodes that called
4721    /// [`Self::effective_enabled_signal`] are visited, so a tree with no
4722    /// interactive widgets pays nothing.
4723    ///
4724    /// Values are collected first and set afterwards: a `Signal::set` observer
4725    /// may mutate the tree, and must not run while the arena is being walked —
4726    /// the same discipline as `flush_activation_signals` and the
4727    /// `focus_within` / `hover_within` updates.
4728    pub(crate) fn flush_effective_enabled_signals(&mut self) {
4729        self.arena.prune_effective_enabled_watchers();
4730        let mut updates: Vec<(crate::signal::Signal<bool>, bool)> = Vec::new();
4731        for id in self.arena.effective_enabled_watchers() {
4732            let Some(sig) = self
4733                .arena
4734                .get(id)
4735                .and_then(|n| n.effective_enabled_signal.clone())
4736            else {
4737                continue;
4738            };
4739            let now = self.arena.is_enabled(id);
4740            if sig.get() != now {
4741                updates.push((sig, now));
4742            }
4743        }
4744        for (sig, value) in updates {
4745            sig.set(value);
4746        }
4747    }
4748
4749    /// Whether a widget is effectively enabled. Returns `false` if the widget
4750    /// itself or any ancestor has `enabled_state` bound to `false`.
4751    pub fn is_enabled(&self, id: WidgetId) -> bool {
4752        self.arena.is_enabled(id)
4753    }
4754
4755    /// Bind a widget's Tab-key participation to a boolean prop or
4756    /// compatibility state binding. When false, the widget is removed
4757    /// from Tab / Shift+Tab traversal (`cycle_focus`) but remains
4758    /// reachable via `request_focus` and arrow-key navigation that
4759    /// calls `request_focus`. Implements the ARIA roving-tabindex
4760    /// pattern (HTML `tabindex="-1"` semantics). Accepts
4761    /// `Signal<bool>`, `Prop<bool>`, or plain `bool`.
4762    /// Publish what a data view's `Space` should do when the row containing
4763    /// `id` holds the keyboard cursor. See
4764    /// [`WidgetNode::keyboard_toggle`](crate::arena::WidgetNode).
4765    pub fn set_keyboard_toggle(
4766        &mut self,
4767        id: WidgetId,
4768        f: std::rc::Rc<dyn Fn(&mut crate::widget::EventContext)>,
4769    ) {
4770        if let Some(node) = self.arena.get_mut(id) {
4771            node.keyboard_toggle = Some(f);
4772        }
4773    }
4774
4775    /// The first keyboard-toggle action published in `root`'s subtree, in
4776    /// traversal order.
4777    ///
4778    /// Searched per keypress rather than cached: a data view rebuilds its rows
4779    /// as they realize, so an id recorded at build time would outlive the
4780    /// widget it named.
4781    pub fn keyboard_toggle_in(
4782        &self,
4783        root: WidgetId,
4784    ) -> Option<std::rc::Rc<dyn Fn(&mut crate::widget::EventContext)>> {
4785        if let Some(f) = self.arena.get(root).and_then(|n| n.keyboard_toggle.clone()) {
4786            return Some(f);
4787        }
4788        for &child in self.arena.children(root) {
4789            if let Some(found) = self.keyboard_toggle_in(child) {
4790                return Some(found);
4791            }
4792        }
4793        None
4794    }
4795
4796    pub fn set_tab_stop(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
4797        let prop = state.into();
4798        // Bind at the lightest level — tab-stop changes never affect
4799        // layout or paint; cycle_focus reads the current value on
4800        // each Tab keypress.
4801        prop.register_if_bound(
4802            id,
4803            &self.binding_registry,
4804            crate::binding::BindingLevel::RepaintOnly,
4805        );
4806        if let Some(node) = self.arena.get_mut(id) {
4807            node.tab_stop = Some(prop);
4808        }
4809    }
4810
4811    /// Current Tab-key participation for a widget. Returns the value
4812    /// of the `tab_stop` prop if bound, or `true` (the default) when
4813    /// no binding is present. Mirrors the filter used by
4814    /// `cycle_focus` — primarily for tests asserting the
4815    /// roving-tabindex contract.
4816    pub fn tab_stop(&self, id: WidgetId) -> bool {
4817        self.arena
4818            .get(id)
4819            .and_then(|node| node.tab_stop.as_ref())
4820            .map(|prop| prop.get())
4821            .unwrap_or(true)
4822    }
4823
4824    /// Declare `id` as a **traversal-scope boundary** with the given policy.
4825    /// `cycle_focus` then treats the node's subtree as an independent Tab
4826    /// group: `tab_index` values inside it are scoped (they never collide
4827    /// with sibling scopes) and `policy` governs Tab at the scope's ends.
4828    ///
4829    /// The scope node is forced non-focusable — it is a transparent boundary,
4830    /// never itself a Tab stop. Called from `BuildContext::set_traversal_scope`
4831    /// (which the `FocusScope` wrapper widget invokes during `build`), and
4832    /// directly usable from tests with no dependency on the widgets crate.
4833    pub fn set_traversal_scope(
4834        &mut self,
4835        id: WidgetId,
4836        policy: crate::focus::TraversalScopePolicy,
4837    ) {
4838        if let Some(node) = self.arena.get_mut(id) {
4839            node.node_traversal_scope = Some(policy);
4840            node.node_focusable = Some(false);
4841        }
4842    }
4843
4844    /// Remove a previously set traversal-scope marker from `id` (rebuild
4845    /// paths where a `FocusScope` is replaced by a non-scope widget). Leaves
4846    /// `node_focusable` untouched — a later handler-set application resets it.
4847    pub fn clear_traversal_scope(&mut self, id: WidgetId) {
4848        if let Some(node) = self.arena.get_mut(id) {
4849            node.node_traversal_scope = None;
4850        }
4851    }
4852
4853    /// Current traversal-scope policy on `id`, if any. For tests asserting
4854    /// the scope marker contract.
4855    pub fn traversal_scope(&self, id: WidgetId) -> Option<crate::focus::TraversalScopePolicy> {
4856        self.arena
4857            .get(id)
4858            .and_then(|node| node.node_traversal_scope)
4859    }
4860
4861    // --- Theme override ---
4862
4863    /// Set a theme override on a widget. All descendants of this widget
4864    /// will see the modified theme during layout and paint.
4865    /// The override function receives a mutable `Theme` to modify.
4866    ///
4867    /// ```
4868    /// # use teksilo_core::{Widget, LayoutResponse, LayoutContext, widget_tree::WidgetTree};
4869    /// # use teksilo_canvas::{Size, SizeProposal};
4870    /// # use teksilo_tokens::ColorTokens;
4871    /// # #[derive(Debug)] struct MinWidget;
4872    /// # impl Widget for MinWidget {
4873    /// #     fn layout_response(&self, _: SizeProposal, _: &LayoutContext) -> LayoutResponse {
4874    /// #         Size::new(0.0, 0.0).into()
4875    /// #     }
4876    /// # }
4877    /// # let mut tree = WidgetTree::new();
4878    /// # let panel_id = tree.add(MinWidget);
4879    /// tree.set_theme_override(panel_id, |theme| {
4880    ///     theme.colors = ColorTokens::dark_default();
4881    /// });
4882    /// ```
4883    pub fn set_theme_override(
4884        &mut self,
4885        id: WidgetId,
4886        f: impl Fn(&mut crate::styles::Theme) + 'static,
4887    ) {
4888        let had_override = self
4889            .arena
4890            .get(id)
4891            .is_some_and(|n| n.theme_override.is_some());
4892        if let Some(node) = self.arena.get_mut(id) {
4893            node.theme_override = Some(crate::environment::ThemeOverride { func: Box::new(f) });
4894            node.dirty.needs_layout = true;
4895            node.dirty.needs_paint = true;
4896        }
4897        if !had_override {
4898            self.arena.theme_override_count += 1;
4899        }
4900    }
4901
4902    /// Get the resolved theme for a specific widget (applying ancestor overrides).
4903    pub fn resolved_theme(&self, id: WidgetId) -> crate::styles::Theme {
4904        self.arena.resolve_theme(id, &self.theme).into_owned()
4905    }
4906}
4907
4908impl Default for WidgetTree {
4909    fn default() -> Self {
4910        Self::new()
4911    }
4912}
4913
4914#[cfg(test)]
4915mod activation_signal_tests {
4916    use super::*;
4917    use crate::build_context::BuildContext;
4918    use crate::signal::Signal;
4919    use crate::widget::{LayoutContext, LayoutResponse, Widget};
4920    use teksilo_canvas::SizeProposal;
4921
4922    /// A leaf that, on build, opts into its activation signal and mirrors it
4923    /// into an out-of-band signal the test can read.
4924    #[derive(Debug)]
4925    struct ActivationProbe {
4926        log: Signal<Vec<bool>>,
4927    }
4928
4929    impl Widget for ActivationProbe {
4930        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
4931            let id = ctx.self_id();
4932            let vis = ctx.activation_signal(id);
4933            let log = self.log.clone();
4934            ctx.effect(&vis, move |active| {
4935                let mut v = log.get();
4936                v.push(*active);
4937                log.set(v);
4938            });
4939            Vec::new()
4940        }
4941
4942        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
4943            proposal.resolve(10.0, 10.0).into()
4944        }
4945    }
4946
4947    /// A widget that enqueues a post-mount action via `run_after_mount` has it
4948    /// run exactly once, with a real `EventContext`, when the tree drains
4949    /// (`run_mount_actions`) — and `has_pending_mount_actions` reflects the
4950    /// queue state.
4951    #[derive(Debug)]
4952    struct MountActionProbe {
4953        ran: Signal<u32>,
4954    }
4955
4956    impl Widget for MountActionProbe {
4957        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
4958            let ran = self.ran.clone();
4959            ctx.run_after_mount(move |_ectx| ran.set(ran.get() + 1));
4960            Vec::new()
4961        }
4962
4963        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
4964            proposal.resolve(10.0, 10.0).into()
4965        }
4966    }
4967
4968    #[test]
4969    fn run_after_mount_runs_once_on_drain() {
4970        let mut tree = WidgetTree::new();
4971        let ran = Signal::new(0_u32);
4972        tree.add(MountActionProbe { ran: ran.clone() });
4973        tree.layout(SizeProposal::exact(100.0, 100.0));
4974
4975        // Queued during build, not yet run.
4976        assert!(tree.has_pending_mount_actions());
4977        assert_eq!(ran.get(), 0);
4978
4979        // Drain with a Noop sink (as headless callers do).
4980        tree.run_mount_actions(&mut crate::window::NoopWindowOps);
4981        assert_eq!(ran.get(), 1);
4982        assert!(!tree.has_pending_mount_actions());
4983
4984        // Draining again is a no-op (the queue is empty).
4985        tree.run_mount_actions(&mut crate::window::NoopWindowOps);
4986        assert_eq!(ran.get(), 1);
4987    }
4988
4989    #[test]
4990    fn activation_signal_fires_on_dormant_and_reactivate() {
4991        let mut tree = WidgetTree::new();
4992        let log = Signal::new(Vec::<bool>::new());
4993        let probe = tree.add(ActivationProbe { log: log.clone() });
4994
4995        // Gate the probe's visibility on a signal.
4996        let visible = Signal::new(true);
4997        tree.visible_when(probe, visible.clone());
4998        tree.layout(SizeProposal::exact(100.0, 100.0));
4999
5000        // Initially active: effect registration alone fires nothing.
5001        assert_eq!(log.get(), Vec::<bool>::new());
5002
5003        // Hide → dormant → activation signal false.
5004        visible.set(false);
5005        tree.layout(SizeProposal::exact(100.0, 100.0));
5006        assert_eq!(log.get(), vec![false]);
5007
5008        // Show → active → activation signal true.
5009        visible.set(true);
5010        tree.layout(SizeProposal::exact(100.0, 100.0));
5011        assert_eq!(log.get(), vec![false, true]);
5012
5013        // Redundant relayout while active fires nothing new.
5014        tree.layout(SizeProposal::exact(100.0, 100.0));
5015        assert_eq!(log.get(), vec![false, true]);
5016    }
5017
5018    /// **A node parked and re-woken before the flush never looks dormant.**
5019    ///
5020    /// `pending_activation_changes` is an append-only queue, so this records
5021    /// two edges; replaying them in order would hand observers a `false` that
5022    /// was already superseded — for a state signal that is a lie, not a
5023    /// history. `present_in_tree_modal_request` takes exactly this route on
5024    /// every dialog (build the content, park it, mount the scrim, wake it,
5025    /// *then* move focus in), and the stale `false` landed after the focus
5026    /// dispatch: a text editor's dormancy handler wiped the focus it had just
5027    /// been granted and the dialog opened with no caret.
5028    #[test]
5029    fn a_park_and_wake_inside_one_batch_fires_nothing() {
5030        let mut tree = WidgetTree::new();
5031        let log = Signal::new(Vec::<bool>::new());
5032        let probe = tree.add(ActivationProbe { log: log.clone() });
5033        tree.layout(SizeProposal::exact(100.0, 100.0));
5034        assert_eq!(log.get(), Vec::<bool>::new());
5035
5036        tree.set_dormant(probe);
5037        tree.activate(probe);
5038        tree.layout(SizeProposal::exact(100.0, 100.0));
5039        assert_eq!(
5040            log.get(),
5041            Vec::<bool>::new(),
5042            "a node that ends the batch where it started never observably \
5043             changed — firing the intermediate `false` makes observers act on \
5044             a state that was never visible",
5045        );
5046
5047        // The converse still reports: a batch with a *net* transition fires
5048        // once, with the state the node actually ended in.
5049        tree.activate(probe);
5050        tree.set_dormant(probe);
5051        tree.layout(SizeProposal::exact(100.0, 100.0));
5052        assert_eq!(
5053            log.get(),
5054            vec![false],
5055            "a net Active→Dormant batch must still report, exactly once",
5056        );
5057
5058        tree.activate(probe);
5059        tree.layout(SizeProposal::exact(100.0, 100.0));
5060        assert_eq!(log.get(), vec![false, true]);
5061    }
5062}
5063
5064#[cfg(test)]
5065mod visible_when_builder_tests {
5066    use super::*;
5067    use crate::signal::{Prop, Signal};
5068    use crate::widget_builder::WidgetBuilder;
5069
5070    /// `.visible_when(signal)` on any widget builder threads a *bound*
5071    /// visibility prop onto the inserted node — so `teksu!`'s property form
5072    /// (`Widget { visible_when: sig }`) reaches the same `node.visible_state`
5073    /// slot as the imperative `ctx.visible_when(id, sig)`.
5074    #[test]
5075    fn visible_when_builder_binds_node_visibility() {
5076        let mut tree = WidgetTree::new();
5077        let shown = Signal::new(false);
5078        let id = tree.add(crate::test_widgets::FillWidget::new().visible_when(shown.clone()));
5079
5080        let node = tree.arena.get(id).expect("node exists");
5081        assert!(
5082            matches!(node.visible_state, Some(Prop::Bound(_))),
5083            "`.visible_when(Signal)` must store a bound visibility prop"
5084        );
5085    }
5086
5087    /// A static `bool` is accepted too (`Prop::Static`), matching
5088    /// `ctx.visible_when` / `access_hidden` semantics.
5089    #[test]
5090    fn visible_when_builder_accepts_static_bool() {
5091        let mut tree = WidgetTree::new();
5092        let id = tree.add(crate::test_widgets::FillWidget::new().visible_when(false));
5093
5094        let node = tree.arena.get(id).expect("node exists");
5095        assert!(matches!(node.visible_state, Some(Prop::Static(false))));
5096    }
5097}
5098
5099#[cfg(test)]
5100mod text_scale_tests {
5101    use super::*;
5102
5103    #[test]
5104    fn effective_theme_starts_equal_to_theme() {
5105        let tree = WidgetTree::new();
5106        assert_eq!(
5107            tree.effective_theme.typography.body.size,
5108            tree.theme.typography.body.size
5109        );
5110        assert_eq!(tree.user_text_scale(), 1.0);
5111    }
5112
5113    #[test]
5114    fn effective_text_scale_and_signal_track_the_combined_factor() {
5115        let mut tree = WidgetTree::new();
5116        assert_eq!(tree.effective_text_scale(), 1.0);
5117        assert_eq!(tree.text_scale_signal().get(), 1.0);
5118
5119        tree.set_user_text_scale(1.5);
5120        assert!((tree.effective_text_scale() - 1.5).abs() < 0.001);
5121        assert!((tree.text_scale_signal().get() - 1.5).abs() < 0.001);
5122
5123        // OS preference multiplies in.
5124        tree.set_accessibility_preferences(false, false, 2.0);
5125        assert!((tree.effective_text_scale() - 3.0).abs() < 0.01);
5126        assert!((tree.text_scale_signal().get() - 3.0).abs() < 0.01);
5127    }
5128
5129    #[test]
5130    fn set_user_text_scale_scales_effective_typography() {
5131        let mut tree = WidgetTree::new();
5132        let base = tree.theme.typography.body.size;
5133        tree.set_user_text_scale(1.5);
5134        assert!((tree.effective_theme.typography.body.size - base * 1.5).abs() < 0.001);
5135        // The unscaled base theme is untouched.
5136        assert_eq!(tree.theme.typography.body.size, base);
5137    }
5138
5139    #[test]
5140    fn set_theme_preserves_existing_user_scale() {
5141        let mut tree = WidgetTree::new();
5142        tree.set_user_text_scale(2.0);
5143        let dark = crate::presets::intui::dark();
5144        let dark_base = dark.typography.body.size;
5145        tree.set_theme(dark);
5146        assert!((tree.effective_theme.typography.body.size - dark_base * 2.0).abs() < 0.001);
5147    }
5148
5149    #[test]
5150    fn os_text_scale_multiplies_with_user_scale() {
5151        let mut tree = WidgetTree::new();
5152        let base = tree.theme.typography.body.size;
5153        tree.set_user_text_scale(1.5);
5154        // OS preference reports 1.2 → combined 1.8.
5155        tree.set_accessibility_preferences(false, false, 1.2);
5156        assert!((tree.effective_theme.typography.body.size - base * 1.8).abs() < 0.01);
5157    }
5158
5159    #[test]
5160    fn same_scale_is_a_noop_and_factor_is_clamped() {
5161        let mut tree = WidgetTree::new();
5162        tree.set_user_text_scale(1.5);
5163        // Re-setting the same value should not panic / change anything.
5164        tree.set_user_text_scale(1.5);
5165        assert_eq!(tree.user_text_scale(), 1.5);
5166        // Out-of-range clamps into [0.25, 8.0].
5167        tree.set_user_text_scale(100.0);
5168        assert_eq!(tree.user_text_scale(), 8.0);
5169    }
5170}
5171
5172/// Covers the `WidgetTree`-level facts
5173/// `teksilo_app::WindowManager::request_redraw_needing_render` (the
5174/// targeted cross-window redraw added for shared-`Signal` dispatch
5175/// fan-out) relies on. Neither test touches windows at all — they exist
5176/// to pin down `needs_render()`'s contract in isolation, since
5177/// teksilo-app cannot stand up a real `PlatformWindow` in a unit test.
5178#[cfg(test)]
5179mod cross_window_redraw_signal_tests {
5180    use super::*;
5181    use crate::signal::Signal;
5182    use crate::test_widgets::{FillWidget, StackWidget};
5183    use teksilo_canvas::SizeProposal;
5184
5185    /// The premise the fix acts on, AND the trap a naive fix would fall
5186    /// into. A `Signal` shared by two independent trees (standing in for
5187    /// two windows) is supposed to dirty both when mutated, even though
5188    /// only one of them is the tree whose dispatch made the mutation —
5189    /// but `Signal::set` only flips a dirty flag on the signal itself and
5190    /// in the `BindingRegistry`; nothing walks that into a tree's
5191    /// `needs_layout` / `needs_paint` bits (what `needs_render()` reads)
5192    /// except that tree's OWN `process_state_changes`, run at the top of
5193    /// its OWN `layout()`. So immediately after the mutation, with
5194    /// neither tree having re-run `layout()`, BOTH read clean — a naive
5195    /// "just check `needs_render()`" cross-window redraw would see
5196    /// nothing to do and stay a permanent no-op. Once tree B's `layout()`
5197    /// runs (what `request_redraw_needing_render` does for every window
5198    /// before checking it), the same mutation is finally visible there.
5199    ///
5200    /// Each tree wraps its gated leaf in a `StackWidget` parent (rather
5201    /// than gating a bare root leaf) so the fact under test — an ACTIVE
5202    /// widget ending up dirty — is unambiguous: `any_needs_layout()` /
5203    /// `any_needs_paint()` only ever look at `Active` nodes, and a leaf
5204    /// that itself goes dormant is deliberately excluded from both (a
5205    /// hidden widget has nothing to paint). What must go dirty here is
5206    /// the STILL-ACTIVE stack, via `mark_ancestors_need_layout` — the
5207    /// same mechanism that makes a real window's content re-flow around
5208    /// a child that just appeared or disappeared.
5209    #[test]
5210    fn a_shared_signal_mutation_only_shows_up_after_that_trees_own_layout_reconciles_it() {
5211        let shared = Signal::new(true);
5212
5213        let mut tree_a = WidgetTree::new();
5214        let stack_a = tree_a.add(StackWidget::new());
5215        let id_a = tree_a.add_child(stack_a, FillWidget::new());
5216        tree_a.visible_when(id_a, shared.clone());
5217
5218        let mut tree_b = WidgetTree::new();
5219        let stack_b = tree_b.add(StackWidget::new());
5220        let id_b = tree_b.add_child(stack_b, FillWidget::new());
5221        tree_b.visible_when(id_b, shared.clone());
5222
5223        let proposal = SizeProposal::exact(100.0, 100.0);
5224
5225        // Bring both to the same clean baseline a real event loop reaches
5226        // after its initial layout + paint.
5227        tree_a.layout(proposal);
5228        tree_a.render();
5229        tree_b.layout(proposal);
5230        tree_b.render();
5231        assert!(!tree_a.needs_render(), "precondition: tree A starts clean");
5232        assert!(!tree_b.needs_render(), "precondition: tree B starts clean");
5233
5234        // Simulate a handler mutating the shared Signal during tree A's
5235        // dispatch. Neither tree re-runs layout() here yet.
5236        shared.set(false);
5237
5238        assert!(
5239            !tree_a.needs_render(),
5240            "the mutation alone does not retroactively dirty tree A either — \
5241             a Signal write cannot poke an arena directly, only the next \
5242             process_state_changes (inside layout()) can"
5243        );
5244        assert!(
5245            !tree_b.needs_render(),
5246            "and tree B reads exactly as clean as tree A does at this point — \
5247             checking needs_render() without reconciling first cannot tell them apart"
5248        );
5249
5250        // ...but they are NOT indistinguishable to `needs_reconcile()`,
5251        // which is what `request_redraw_needing_render` actually gates
5252        // its reconcile on. Both trees observe the shared Signal, so
5253        // both report pending reactive work here — and asking is
5254        // read-only, so asking tree A first does not answer for tree B.
5255        assert!(
5256            tree_a.needs_reconcile() && tree_b.needs_reconcile(),
5257            "both trees must report pending reactive work from the shared write"
5258        );
5259
5260        // This is what `request_redraw_needing_render` does for a window
5261        // whose gate is open — reconcile at the window's OWN current
5262        // size, which is what walks a pending Signal-driven change into
5263        // the arena.
5264        tree_b.layout(proposal);
5265
5266        assert!(
5267            tree_b.needs_render(),
5268            "tree B, which merely OBSERVES the shared Signal, is now dirty — \
5269             this is the cross-tree fan-out request_redraw_needing_render \
5270             exists to notice (via its own reconcile-then-check) and repaint"
5271        );
5272        assert!(
5273            !tree_b.needs_reconcile(),
5274            "and having reconciled, tree B's gate closes again"
5275        );
5276        assert!(
5277            tree_a.needs_reconcile(),
5278            "while tree A — which has NOT reconciled — is still waiting; one \
5279             window's reconcile must never close another's gate"
5280        );
5281    }
5282
5283    /// The gate `request_redraw_needing_render` gained must stay SHUT for
5284    /// a window with nothing reactive pending, or it saves nothing: that
5285    /// method runs after every dispatched event, and an open gate costs
5286    /// a full `layout_with_ops` — a dozen per-frame passes (pending
5287    /// animations, frame tick, scheduler tick, drag tick,
5288    /// `process_state_changes`, tooltips, four overlay passes) before it
5289    /// reaches the geometry short-circuit, for every open window, on
5290    /// every event of a fast mouse-move stream.
5291    #[test]
5292    fn needs_reconcile_is_false_for_an_idle_tree() {
5293        let shared = Signal::new(true);
5294        let mut idle = WidgetTree::new();
5295        let stack = idle.add(StackWidget::new());
5296        let leaf = idle.add_child(stack, FillWidget::new());
5297        idle.visible_when(leaf, shared.clone());
5298        idle.layout(SizeProposal::exact(100.0, 100.0));
5299        idle.render();
5300
5301        assert!(!idle.needs_reconcile(), "nothing written — gate shut");
5302        assert!(!idle.needs_reconcile(), "and asking does not open it");
5303
5304        shared.set(false);
5305        assert!(idle.needs_reconcile(), "a write opens it");
5306        idle.layout(SizeProposal::exact(100.0, 100.0));
5307        assert!(!idle.needs_reconcile(), "reconciling closes it again");
5308    }
5309
5310    /// A *pending* `animate_to` contributes no scheduler deadline until
5311    /// `process_pending_animations` promotes it, so `next_timer_deadline`
5312    /// (and therefore `request_redraw_due`) cannot see it. The gate must,
5313    /// or arming an animation from another window's handler would leave
5314    /// it parked until something unrelated woke the window.
5315    #[test]
5316    fn needs_reconcile_sees_an_animation_armed_but_not_yet_started() {
5317        let mut tree = WidgetTree::new();
5318        let id = tree.add(FillWidget::new());
5319        tree.layout(SizeProposal::exact(50.0, 50.0));
5320        tree.render();
5321
5322        // Registered but deliberately NOT bound to any widget, so the
5323        // binding-registry term cannot be what notices it.
5324        let anim = Signal::new_animated(0.0_f32);
5325        tree.register_animated_signal(&anim, id);
5326        assert!(!tree.needs_reconcile(), "precondition: nothing armed yet");
5327
5328        anim.animate_to(
5329            1.0,
5330            std::time::Duration::from_millis(200),
5331            teksilo_tokens::Easing::Linear,
5332        );
5333        assert!(
5334            tree.needs_reconcile(),
5335            "an armed-but-unstarted animation is reactive work only layout() can pick up"
5336        );
5337        assert!(
5338            anim.has_pending_animation(),
5339            "and asking must not have consumed the request"
5340        );
5341
5342        tree.layout(SizeProposal::exact(50.0, 50.0));
5343        assert!(
5344            !anim.has_pending_animation(),
5345            "the reconcile promoted it into the scheduler"
5346        );
5347    }
5348
5349    /// An animation armed *after* the wall clock has overtaken the simulated one
5350    /// must still run.
5351    ///
5352    /// `layout` promotes a pending `animate_to` into the scheduler; once
5353    /// `tick_animations` is driving the tree, the scheduler is only ever ticked at
5354    /// `sim_clock`. Stamping the promotion with `Instant::now()` therefore put the
5355    /// start in the scheduler's *future* the moment a test's real time outran the
5356    /// simulated time it had asked for — and a start in the future does not run
5357    /// slow, it does not run at all. That made animated headless tests a function
5358    /// of machine load: green run alone, frozen once a full suite filled the cores
5359    /// and stretched each test's wall-clock time past its simulated budget.
5360    ///
5361    /// The two clocks below are the shape of that: 90 ms simulated against at
5362    /// least 100 ms real, so simulated time never catches up. Under the old
5363    /// behaviour the animation stays pinned at its start value forever.
5364    #[test]
5365    fn an_animation_armed_after_real_time_outran_the_sim_clock_still_runs() {
5366        let mut tree = WidgetTree::new();
5367        let id = tree.add(FillWidget::new());
5368        tree.layout(SizeProposal::exact(50.0, 50.0));
5369
5370        // Put the tree in simulated time, then let the wall clock get ahead of it.
5371        tree.tick_animations(std::time::Duration::from_millis(10));
5372        std::thread::sleep(std::time::Duration::from_millis(100));
5373
5374        let anim = Signal::new_animated(0.0_f32);
5375        tree.register_animated_signal(&anim, id);
5376        anim.animate_to(
5377            1.0,
5378            std::time::Duration::from_millis(50),
5379            teksilo_tokens::Easing::Linear,
5380        );
5381        tree.layout(SizeProposal::exact(50.0, 50.0));
5382
5383        // 80 ms of simulated time against a 50 ms animation: comfortably finished
5384        // on the only clock the scheduler is ever ticked with, and still short of
5385        // the ~100 ms of real time that has passed.
5386        tree.tick_animations(std::time::Duration::from_millis(80));
5387
5388        assert_eq!(
5389            anim.get(),
5390            1.0,
5391            "the animation must be measured against the clock it is ticked with, \
5392             not the wall clock that has already run past it"
5393        );
5394        assert!(
5395            !tree.has_active_animations(),
5396            "and having reached its target it must be off the scheduler"
5397        );
5398    }
5399
5400    /// A layout pass on a simulated tree advances no animation.
5401    ///
5402    /// The mirror image of the test above, and the other half of the same
5403    /// contract: `layout` both **promotes** a pending `animate_to` and
5404    /// **ticks** the scheduler, and it has to do both against the same clock.
5405    /// Promoting at `sim_clock` while ticking at `Instant::now()` hands the
5406    /// freshly promoted animation an elapsed time equal to the tree's entire
5407    /// wall-clock age — so it finishes inside the very layout pass that started
5408    /// it, and how much of it a test ever observes depends on how long that
5409    /// test took to get there.
5410    ///
5411    /// The 120 ms slept below is what the wall clock would contribute; the
5412    /// tween is 100 ms, so under the old behaviour it is already over before
5413    /// the caller advances anything.
5414    #[test]
5415    fn a_layout_pass_on_a_simulated_tree_advances_no_animation() {
5416        let mut tree = WidgetTree::new();
5417        let id = tree.add(FillWidget::new());
5418        tree.layout(SizeProposal::exact(50.0, 50.0));
5419
5420        // Put the tree on the simulated clock, then let real time run past the
5421        // whole duration of the animation that is about to be armed.
5422        tree.advance_time(std::time::Duration::from_millis(1));
5423        std::thread::sleep(std::time::Duration::from_millis(120));
5424
5425        let anim = Signal::new_animated(0.0_f32);
5426        tree.register_animated_signal(&anim, id);
5427        anim.animate_to(
5428            100.0,
5429            std::time::Duration::from_millis(100),
5430            teksilo_tokens::Easing::Linear,
5431        );
5432        tree.layout(SizeProposal::exact(50.0, 50.0));
5433
5434        assert_eq!(
5435            anim.get(),
5436            0.0,
5437            "a layout pass promotes the animation; it does not also age it by \
5438             however long the tree has been alive"
5439        );
5440
5441        // It moves when, and only when, the clock is advanced.
5442        tree.advance_time(std::time::Duration::from_millis(50));
5443        assert!(
5444            (anim.get() - 50.0).abs() < 2.0,
5445            "half of a 100 ms linear tween: {}",
5446            anim.get()
5447        );
5448    }
5449
5450    /// `needs_render()` (paint/layout dirt only) must stay `false` while a
5451    /// per-frame `Signal<f32>` animation is merely *running*, with nothing
5452    /// new to paint. `request_redraw_needing_render` filters on
5453    /// `needs_render()`, not the broader `needs_redraw()`, precisely so a
5454    /// window with a live animation isn't forced into an extra immediate
5455    /// redraw on every sibling-window event — that would defeat the 60 Hz
5456    /// `WaitUntil` pacing those animations already get elsewhere and
5457    /// reintroduce the uncapped free-running redraw bug that pacing was
5458    /// written to remove.
5459    #[test]
5460    fn needs_render_excludes_a_running_animation_with_no_dirty_paint() {
5461        let mut tree = WidgetTree::new();
5462        let id = tree.add(FillWidget::new());
5463        tree.layout(SizeProposal::exact(50.0, 50.0));
5464        tree.render();
5465        assert!(!tree.needs_render(), "precondition: tree starts clean");
5466        assert!(!tree.needs_redraw(), "precondition: nothing running yet");
5467
5468        let anim = Signal::new_animated(0.0_f32);
5469        tree.register_animated_signal(&anim, id);
5470        anim.animate_to(
5471            1.0,
5472            std::time::Duration::from_millis(200),
5473            teksilo_tokens::Easing::Linear,
5474        );
5475        // `process_pending_animations` (which picks up the pending
5476        // `animate_to` and starts it on the scheduler) runs inside `layout`.
5477        tree.layout(SizeProposal::exact(50.0, 50.0));
5478
5479        assert!(
5480            tree.needs_redraw(),
5481            "an animation just started, so needs_redraw() (has_running()) must be true"
5482        );
5483        assert!(
5484            !tree.needs_render(),
5485            "but nothing is actually dirty for paint/layout — needs_render() must stay false, \
5486             which is the whole point of using it (not needs_redraw()) as the cross-window filter"
5487        );
5488    }
5489}
5490
5491#[cfg(test)]
5492mod effective_enabled_signal_tests {
5493    use super::*;
5494    use crate::build_context::BuildContext;
5495    use crate::signal::Signal;
5496    use crate::widget::{LayoutContext, LayoutResponse, Widget};
5497    use std::cell::RefCell;
5498    use std::rc::Rc;
5499    use teksilo_canvas::SizeProposal;
5500
5501    type SignalSlot = Rc<RefCell<Option<Signal<bool>>>>;
5502
5503    /// A leaf that opts into `effective_enabled_signal` from inside its own
5504    /// `build()` — the only way real widgets use it, and the case that was
5505    /// broken. It publishes the handle so the test can read the live value.
5506    #[derive(Debug)]
5507    struct EnabledProbe {
5508        out: SignalSlot,
5509    }
5510
5511    impl Widget for EnabledProbe {
5512        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
5513            let id = ctx.self_id();
5514            let sig = ctx.effective_enabled_signal(id);
5515            // Also pins that the signal is *mutable*: the previous derived
5516            // implementation panicked here with "observe() is only supported
5517            // on mutable signals", which is why widgets could not use
5518            // `ctx.effect` to react to being disabled.
5519            ctx.effect(&sig, |_| {});
5520            *self.out.borrow_mut() = Some(sig);
5521            Vec::new()
5522        }
5523
5524        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
5525            proposal.resolve(10.0, 10.0).into()
5526        }
5527    }
5528
5529    /// A composite that adds the probe through the ordinary `ctx.add` idiom, so
5530    /// the child is inserted PARENTLESS and builds before its parent link is
5531    /// wired — the exact situation that defeated the old
5532    /// walk-the-ancestors-at-call-time implementation.
5533    #[derive(Debug)]
5534    struct Form {
5535        enabled: Signal<bool>,
5536        out: SignalSlot,
5537    }
5538
5539    impl Widget for Form {
5540        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
5541            let id = ctx.self_id();
5542            ctx.enabled_when(id, self.enabled.clone());
5543            vec![ctx.add(EnabledProbe {
5544                out: self.out.clone(),
5545            })]
5546        }
5547
5548        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
5549            proposal.resolve(10.0, 10.0).into()
5550        }
5551    }
5552
5553    fn mount_form(enabled: Signal<bool>) -> (WidgetTree, WidgetId, Signal<bool>) {
5554        let out: SignalSlot = Rc::new(RefCell::new(None));
5555        let mut tree = WidgetTree::new();
5556        let form = tree.add(Form {
5557            enabled,
5558            out: out.clone(),
5559        });
5560        tree.layout(SizeProposal::exact(100.0, 100.0));
5561        let sig = out.borrow().clone().expect("probe published its signal");
5562        (tree, form, sig)
5563    }
5564
5565    /// THE REGRESSION. A widget whose own `enabled` is untouched must still
5566    /// report disabled when an ANCESTOR is disabled. This failed before the
5567    /// signal became node-resident: `insert_widget` inserts the node with
5568    /// `parent: None` and wires the parent only after `build()` returns, so an
5569    /// ancestor walk done during `build()` saw an empty chain and captured
5570    /// "enabled" for the widget's whole life.
5571    #[test]
5572    fn tracks_an_ancestor_disabled_before_mount() {
5573        let (_tree, _form, sig) = mount_form(Signal::new(false));
5574        assert!(
5575            !sig.get(),
5576            "a child of a disabled ancestor must report effectively-disabled"
5577        );
5578    }
5579
5580    /// The live case: the ancestor's bound signal flips after mount. The child
5581    /// must follow, in both directions, with no rebuild.
5582    #[test]
5583    fn follows_an_ancestor_flipping_after_mount() {
5584        let enabled = Signal::new(true);
5585        let (mut tree, _form, sig) = mount_form(enabled.clone());
5586        assert!(sig.get(), "starts enabled");
5587
5588        enabled.set(false);
5589        tree.layout(SizeProposal::exact(100.0, 100.0));
5590        assert!(!sig.get(), "child follows the ancestor going disabled");
5591
5592        enabled.set(true);
5593        tree.layout(SizeProposal::exact(100.0, 100.0));
5594        assert!(sig.get(), "and follows it coming back");
5595    }
5596
5597    /// A widget's own `enabled_state` still works on its own.
5598    #[test]
5599    fn honours_the_widgets_own_state() {
5600        let out: SignalSlot = Rc::new(RefCell::new(None));
5601        let mut tree = WidgetTree::new();
5602        let probe = tree.add(EnabledProbe { out: out.clone() });
5603        tree.enabled_when(probe, false);
5604        tree.layout(SizeProposal::exact(100.0, 100.0));
5605        let sig = out.borrow().clone().unwrap();
5606        assert!(!sig.get(), "own enabled_state alone disables");
5607    }
5608
5609    /// The signal must agree with the paint-time bool the render walker
5610    /// computes. If they disagreed, role-driven chrome (which dims from
5611    /// `PaintContext::effective_enabled`) and signal-driven chrome (which dims
5612    /// from this signal) would grey out at different moments.
5613    #[test]
5614    fn agrees_with_the_paint_time_effective_enabled() {
5615        let enabled = Signal::new(true);
5616        let (mut tree, form, sig) = mount_form(enabled.clone());
5617        let probe = tree.children(form)[0];
5618
5619        for value in [false, true, false] {
5620            enabled.set(value);
5621            tree.layout(SizeProposal::exact(100.0, 100.0));
5622            assert_eq!(
5623                sig.get(),
5624                tree.is_enabled(probe),
5625                "signal and the arena's live is_enabled must agree (enabled={value})"
5626            );
5627        }
5628    }
5629
5630    /// Install-or-reuse: asking twice hands back the same signal.
5631    #[test]
5632    fn is_install_or_reuse() {
5633        let out: SignalSlot = Rc::new(RefCell::new(None));
5634        let mut tree = WidgetTree::new();
5635        let id = tree.add(EnabledProbe { out });
5636        let a = tree.effective_enabled_signal(id);
5637        let b = tree.effective_enabled_signal(id);
5638        assert!(
5639            Signal::same(&a, &b),
5640            "must hand back the same signal handle"
5641        );
5642    }
5643}
5644
5645#[cfg(test)]
5646mod density_tests {
5647    use super::*;
5648    use teksilo_tokens::{DensityPolicy, TargetDensity};
5649
5650    /// A fresh tree is Compact — today's behaviour — and reports it.
5651    #[test]
5652    fn a_fresh_tree_is_compact() {
5653        let tree = WidgetTree::new();
5654        assert_eq!(tree.input_density(), TargetDensity::Compact);
5655        assert_eq!(tree.theme().input, teksilo_tokens::InputTokens::default());
5656        assert!(tree.touch_enabled());
5657        assert_eq!(
5658            tree.density_policy(),
5659            DensityPolicy::Fixed(TargetDensity::Compact)
5660        );
5661    }
5662
5663    /// A density switch changes what the tree reports **and** dirties it at the
5664    /// rebuild level — a target size is baked in `build()`, so layout+paint
5665    /// alone cannot re-bake it.
5666    #[test]
5667    fn switching_density_reports_and_dirties_at_rebuild_level() {
5668        let mut tree = WidgetTree::new();
5669        let root = tree.add(crate::test_widgets::FillWidget::new());
5670        tree.layout(SizeProposal::exact(400.0, 300.0));
5671        assert!(
5672            tree.arena.collect_needs_rebuild().is_empty(),
5673            "a laid-out tree starts clean"
5674        );
5675
5676        tree.set_input_density(TargetDensity::Touch);
5677
5678        assert_eq!(tree.input_density(), TargetDensity::Touch);
5679        assert_eq!(tree.theme().input.target_size, 44.0);
5680        assert_eq!(tree.theme().input.grab_size, 16.0);
5681        assert!(
5682            tree.arena.collect_needs_rebuild().contains(&root),
5683            "the root must be marked for rebuild, not merely relayout"
5684        );
5685    }
5686
5687    /// Re-setting the same density must not throw away every widget id in the
5688    /// tree for nothing.
5689    #[test]
5690    fn re_setting_the_same_density_is_a_no_op() {
5691        let mut tree = WidgetTree::new();
5692        let _root = tree.add(crate::test_widgets::FillWidget::new());
5693        tree.layout(SizeProposal::exact(400.0, 300.0));
5694
5695        tree.set_input_density(TargetDensity::Compact);
5696
5697        assert!(tree.arena.collect_needs_rebuild().is_empty());
5698    }
5699
5700    /// The theme signal follows a density switch, so a `theme_signal`-bound
5701    /// widget re-resolves without its own wiring.
5702    #[test]
5703    fn the_theme_signal_follows_a_density_switch() {
5704        let mut tree = WidgetTree::new();
5705        tree.set_input_density(TargetDensity::Comfortable);
5706        assert_eq!(
5707            tree.theme_signal().get().input.density,
5708            TargetDensity::Comfortable
5709        );
5710        assert_eq!(tree.theme_signal().get().input.target_size, 32.0);
5711    }
5712
5713    /// The kill switch is state on the theme, reachable both ways, and does
5714    /// not rebuild — it changes which events are accepted, not a dimension.
5715    #[test]
5716    fn the_touch_kill_switch_round_trips_without_a_rebuild() {
5717        let mut tree = WidgetTree::new();
5718        let _root = tree.add(crate::test_widgets::FillWidget::new());
5719        tree.layout(SizeProposal::exact(400.0, 300.0));
5720
5721        tree.set_touch_enabled(false);
5722
5723        assert!(!tree.touch_enabled());
5724        assert!(!tree.theme().input.touch_enabled);
5725        assert!(!tree.theme_signal().get().input.touch_enabled);
5726        assert!(tree.arena.collect_needs_rebuild().is_empty());
5727
5728        tree.set_touch_enabled(true);
5729        assert!(tree.touch_enabled());
5730    }
5731
5732    /// The policy is stored verbatim and does not itself move the density.
5733    #[test]
5734    fn setting_a_policy_does_not_switch_the_density() {
5735        let mut tree = WidgetTree::new();
5736        let policy = DensityPolicy::FollowLastPointer {
5737            coarse: TargetDensity::Touch,
5738            fine: TargetDensity::Compact,
5739            hysteresis: std::time::Duration::from_secs(1),
5740        };
5741        tree.set_density_policy(policy);
5742
5743        assert_eq!(tree.density_policy(), policy);
5744        assert_eq!(tree.input_density(), TargetDensity::Compact);
5745    }
5746}
5747
5748#[cfg(test)]
5749mod app_callback_intent_tests {
5750    use super::*;
5751    use crate::{Action, BuildContext, Intent, LayoutContext, LayoutResponse, Widget};
5752    use std::cell::{Cell, RefCell};
5753    use std::rc::Rc;
5754    use teksilo_canvas::{Size, SizeProposal};
5755
5756    #[derive(Debug)]
5757    struct QuitActions {
5758        callback_returned: Rc<Cell<bool>>,
5759        calls: Rc<RefCell<Vec<&'static str>>>,
5760    }
5761
5762    impl Widget for QuitActions {
5763        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
5764            let returned = self.callback_returned.clone();
5765            let calls = self.calls.clone();
5766            ctx.register_action_global(Action::new("test.quit").on_invoke(move |_, ctx| {
5767                assert!(
5768                    returned.get(),
5769                    "dispatch must wait until the callback returns"
5770                );
5771                calls.borrow_mut().push("quit");
5772                ctx.send_intent(Intent::new("test.confirm"));
5773            }));
5774            let calls = self.calls.clone();
5775            ctx.register_action_global(Action::new("test.confirm").on_invoke(move |_, _| {
5776                calls.borrow_mut().push("confirm");
5777            }));
5778            vec![]
5779        }
5780
5781        fn layout_response(&self, _: SizeProposal, _: &LayoutContext) -> LayoutResponse {
5782            Size::new(0.0, 0.0).into()
5783        }
5784    }
5785
5786    #[test]
5787    fn app_callback_dispatches_quit_and_followup_without_an_input_event() {
5788        let mut tree = WidgetTree::new();
5789        let returned = Rc::new(Cell::new(false));
5790        let calls = Rc::new(RefCell::new(Vec::new()));
5791        tree.add(QuitActions {
5792            callback_returned: returned.clone(),
5793            calls: calls.clone(),
5794        });
5795        let mut ops = crate::window::NoopWindowOps;
5796        // This is the entry point WindowManager uses to evaluate a close guard.
5797        tree.run_with_event_context(&mut ops, |ctx| {
5798            ctx.send_intent(Intent::new("test.quit"));
5799            assert!(calls.borrow().is_empty());
5800            returned.set(true);
5801        });
5802        assert_eq!(&*calls.borrow(), &["quit", "confirm"]);
5803        assert!(tree.pending_intents.is_empty());
5804        tree.run_with_event_context(&mut ops, |_| {});
5805        assert_eq!(
5806            &*calls.borrow(),
5807            &["quit", "confirm"],
5808            "dispatch exactly once"
5809        );
5810    }
5811
5812    #[test]
5813    fn callback_on_empty_tree_does_not_leave_an_unanchored_intent() {
5814        let mut tree = WidgetTree::new();
5815        tree.run_with_event_context(&mut crate::window::NoopWindowOps, |ctx| {
5816            ctx.send_intent(Intent::new("test.quit"));
5817        });
5818        assert!(tree.pending_intents.is_empty());
5819    }
5820}