Skip to main content

teksilo_core/
widget_tree.rs

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