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