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