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_impl;
16mod drag_drop_impl;
17mod event_dispatch_impl;
18mod focus_impl;
19mod gesture_dispatch_impl;
20mod layout_impl;
21mod overlay_impl;
22mod query_impl;
23mod rendering_impl;
24mod test_api;
25
26/// The main widget tree orchestrating arena, layout, events, accessibility, and paint.
27/// Provides both the runtime API and the headless test API.
28struct AnimatedRegistration {
29    weak: crate::signal::WeakAnimatedSignal,
30    owner: WidgetId,
31}
32
33impl AnimatedRegistration {
34    fn same_signal(&self, signal: &crate::signal::Signal<f32>) -> bool {
35        self.weak.same_signal(signal)
36    }
37
38    fn is_alive(&self) -> bool {
39        self.weak.upgrade().is_some()
40    }
41
42    fn take_pending_animation(
43        &self,
44    ) -> Option<(
45        crate::signal::Signal<f32>,
46        crate::animation::AnimationRequest,
47        WidgetId,
48    )> {
49        let signal = self.weak.upgrade()?;
50        let request = signal.take_pending_animation()?;
51        Some((signal, request, self.owner))
52    }
53
54    /// Non-consuming counterpart to [`take_pending_animation`](Self::take_pending_animation),
55    /// for [`WidgetTree::needs_reconcile`] — which must be able to ask
56    /// "is there work here?" without doing any.
57    fn has_pending_animation(&self) -> bool {
58        self.weak
59            .upgrade()
60            .is_some_and(|signal| signal.has_pending_animation())
61    }
62}
63
64#[allow(clippy::type_complexity)]
65pub struct WidgetTree {
66    arena: WidgetArena,
67    /// Current theme value cached for `&Theme` accessors used by layout/paint
68    /// contexts and by widgets that need an immediate read. The reactive source
69    /// of truth is `theme_signal`; both are updated in lockstep by `set_theme`.
70    theme: Theme,
71    /// Reactive theme signal. Widgets that want their visual or derived state
72    /// to track theme changes bind to this signal or build derived signals via
73    /// `zip`/`map`. `set_theme` updates the signal (firing observers) without
74    /// rebuilding the widget tree, so interaction state (focus, scroll, expanded
75    /// panels, …) survives theme switches.
76    theme_signal: crate::signal::Signal<Theme>,
77    /// User-controlled global text-scale factor (`1.0` = 100 %). Layered on top
78    /// of the OS `text_scale_factor`: the two multiply. Set via
79    /// `set_user_text_scale`; persisted by the application through
80    /// `teksilo_settings::TEXT_SCALE_KEY`.
81    user_text_scale: f32,
82    /// Cached projection of `theme` whose `typography` is scaled by
83    /// `user_text_scale * text_scale_factor`. Recomputed by
84    /// `recompute_effective_theme` whenever the theme or either scale factor
85    /// changes; the layout and paint walkers read this instead of `theme` so
86    /// all text grows uniformly. Equal to `theme` when the combined factor is 1.
87    effective_theme: Theme,
88    /// The combined `user_text_scale * text_scale_factor`, cached so the
89    /// layout/paint context construction sites don't recompute it. The single
90    /// scalar published to widgets that size from a source *other* than
91    /// `Theme.typography` (icons, the rich-text engine, calendar constants,
92    /// scene text). Written alongside `effective_theme` in
93    /// `recompute_effective_theme`.
94    effective_text_scale: f32,
95    /// Reactive mirror of `effective_text_scale`, for build-time binders that
96    /// must react to a scale change without a rebuild path of their own (e.g.
97    /// `Calendar` binds this at `Rebuild` level). Fired by
98    /// `recompute_effective_theme`.
99    text_scale_signal: crate::signal::Signal<f32>,
100    /// Reactive window-active state (`focused AND not occluded`), the
101    /// occlusion-aware companion to `WindowState::focused` (which is raw OS
102    /// focus only). The single source of truth for "is this window active",
103    /// read by `is_window_active()` and published to widgets via
104    /// `window_active_signal()` / `BuildContext::window_active*` /
105    /// `PaintContext::window_active`. Drives caret hiding, selection
106    /// desaturation and `DimWhenInactive`. Starts `true` — winit may not send
107    /// `Focused(true)` for the first window, so a window must not be born
108    /// inactive. Mutated only by `set_window_active`.
109    window_active_signal: crate::signal::Signal<bool>,
110    text_backend: Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
111    focused: Option<WidgetId>,
112    /// Reactive mirror of `focused`. Same pattern as `hovered_signal`
113    /// — kept in sync via `set_focused`. Drives the inspector's Focus
114    /// tab without polling.
115    focused_signal: crate::signal::Signal<Option<WidgetId>>,
116    hovered: Option<WidgetId>,
117    /// Reactive mirror of `hovered`. Set whenever `hovered` changes
118    /// during dispatch / hit-test recovery so external observers
119    /// (notably the debug inspector's hover tooltip) can react without
120    /// polling. Held by handle so the field is a cheap clone.
121    hovered_signal: crate::signal::Signal<Option<WidgetId>>,
122    /// Last known pointer position from `PointerMove`. Used by
123    /// `revalidate_interaction_state` to re-hit-test the hover after
124    /// a rebuild shifts content under a stationary cursor — without
125    /// this, the next `Scroll` event routes to `focused` (or falls
126    /// through to an ancestor scrollable) instead of the item the
127    /// user is actually pointing at.
128    last_pointer_position: Option<teksilo_canvas::Point>,
129    /// The pointer position *before* the move currently being
130    /// dispatched — i.e. the last sample that was still over the
131    /// previously-hovered widget. Read when arming an overlay's safe
132    /// triangle: the apex belongs at the point the pointer left the
133    /// anchor, not at the first sample past it (they coincide for a
134    /// real mouse, but the distinction is what keeps the cone from
135    /// degenerating to "the apex is wherever I am, so I am always
136    /// inside it").
137    previous_pointer_position: Option<teksilo_canvas::Point>,
138    /// A rebuild destroyed the focused widget: the subtree that owned focus,
139    /// remembered so the end of the layout pass can land focus back inside it.
140    ///
141    /// A rebuild allocates fresh `WidgetId`s for its children, so the focused
142    /// node dies and `revalidate_interaction_state` drops focus to `None`.
143    /// Leaving it there kicks the user out of the widget they were in — most
144    /// visibly, a popover that refreshes its content when it opens would throw
145    /// away the very row the popover had just focused, so the menu comes up with
146    /// no keyboard focus at all. Focus is re-entered *after* the layout walk
147    /// (see the tail of `layout_with_ops`), once the fresh children have real
148    /// bounds for the focus-driven scroll-into-view — the same shape as the
149    /// post-layout hover refresh next to it.
150    pending_focus_restore: Option<WidgetId>,
151    last_proposal: SizeProposal,
152    pending_modal_requests: Vec<crate::modal::QueuedModalRequest>,
153    pending_modal_dismissal: bool,
154    shortcut_registry: crate::shortcut::ShortcutRegistry,
155    /// Queue of intents awaiting dispatch. Populated either by the
156    /// keystroke interception path (`dispatch_event` for KeyDown) or
157    /// by handlers calling `ctx.send_intent(...)`. Drained between
158    /// event-handler calls by [`WidgetTree::drain_pending_intents`].
159    /// The tuple carries the source widget (dispatch anchor), the
160    /// intent itself, and the firing shortcut's
161    /// `propagate_when_disabled` policy.
162    pending_intents: Vec<(WidgetId, crate::intent::Intent, bool)>,
163    /// Window-global actions registered via
164    /// [`BuildContext::register_action_global`](crate::BuildContext::register_action_global).
165    /// Consulted as a fallback at the end of every intent dispatch — *after* the
166    /// source→root walk finds no consuming node action — so an app-global command
167    /// is reachable no matter where the intent originated (a menu-bar dropdown
168    /// overlay, deep content, a global shortcut anchored at the root). Each entry
169    /// is owned by the registering widget and torn down on its rebuild/destroy,
170    /// mirroring `register_shortcut_global`.
171    global_actions: Vec<(WidgetId, crate::action::Action)>,
172    /// The widgets that edit text, registered via
173    /// [`BuildContext::register_text_surface`](crate::BuildContext::register_text_surface).
174    ///
175    /// Owned by the registering widget and torn down on its rebuild/destroy,
176    /// exactly like `global_actions` above. Read through
177    /// [`WidgetTree::focused_text_surface`] by a host that has taken a text
178    /// chord — `Ctrl+Z`, `Ctrl+C` — for itself and owes every text widget in the
179    /// tree an answer about what happens to it. See
180    /// [`crate::text_surface`] for why the framework is the only place that
181    /// question can be answered completely.
182    text_surfaces: crate::text_surface::TextSurfaces,
183    /// Currently-armed key-capture slot. `Some` when
184    /// [`WidgetTree::begin_key_capture`] has been called and the
185    /// returned [`CaptureHandle`](crate::shortcut::CaptureHandle)
186    /// is still alive. The slot is shared (via `Rc`) with the handle
187    /// so dropping the handle cancels the capture, and calling
188    /// `begin_key_capture` again creates a fresh slot without
189    /// touching the previous one (whose handle, if dropped later,
190    /// only clears its own orphaned slot).
191    key_capture: Option<crate::shortcut::KeyCaptureSlot>,
192    binding_registry: crate::binding::BindingRegistry,
193    idle_queue: crate::idle::IdleQueue,
194    /// Simulated clock for deterministic time-dependent testing.
195    sim_clock: std::time::Instant,
196    /// Whether [`tick_animations`](Self::tick_animations) has ever driven this
197    /// tree — i.e. whether [`Self::sim_clock`], rather than the wall clock, is
198    /// the one animations are measured against. See [`Self::animation_clock`].
199    sim_driven: bool,
200    /// Overlay manager for tooltips, menus, popovers.
201    pub(crate) overlay_manager: crate::overlay::OverlayManager,
202    /// Tooltip attachments: (anchor_id, content_id, text, delay, hover_start, overlay_id).
203    tooltips: Vec<TooltipEntry>,
204    /// Simulated-clock end of the tooltip "reshow session". While any tip is
205    /// visible, or until this instant after the last tip dismissed, subsequent
206    /// anchors use `MotionTokens::tooltip_reshow_delay` instead of the full
207    /// initial delay (Windows `TTDT_RESHOW` behaviour).
208    tooltip_session_until_sim: Option<std::time::Instant>,
209    /// Real-clock counterpart of [`Self::tooltip_session_until_sim`].
210    tooltip_session_until_real: Option<std::time::Instant>,
211    /// The tooltip currently surfaced by keyboard menu navigation
212    /// (`show_highlight_tooltip`): `(overlay_id, content_id)`. At most one
213    /// is shown at a time; moving the highlight or closing the menu clears
214    /// it. Distinct from the hover/focus tooltip paths — this one is a
215    /// `Manual`-dismiss child of the menu overlay so a single Escape closes
216    /// the menu (and cascades the tooltip) rather than only the tooltip.
217    highlight_tooltip: Option<(crate::overlay::OverlayId, WidgetId)>,
218    /// How the currently focused widget gained focus.
219    focus_origin: Option<crate::focus::FocusOrigin>,
220    /// Input-modality "focus-visible" state: `true` after keyboard input,
221    /// `false` after pointer input. Focus rings (e.g. `StandardItem`'s current
222    /// row) show only while this is `true`, the standard `:focus-visible`
223    /// behaviour — so a mouse click selects without a ring, and keyboard
224    /// navigation reveals it.
225    focus_visible: crate::signal::Signal<bool>,
226    /// Active focus-scope stack during build. A data view pushes its scope
227    /// (`begin_view_focus`) around its row loop so each row reads *its view's*
228    /// focus deterministically — independent of arena parenting, which may not
229    /// be wired yet while rows build (docked / virtualized content). Drives
230    /// focus-aware selection + focus rings in `StandardItem`.
231    view_focus_stack: Vec<crate::signal::Signal<bool>>,
232    /// Layout direction for RTL/LTR support.
233    layout_direction: crate::environment::LayoutDirection,
234    /// Animation scheduler for smooth animated state and signal transitions.
235    animation_scheduler: crate::animation::AnimationScheduler,
236    /// Weakly tracked animated values from both state and signal APIs.
237    animated_values: Vec<AnimatedRegistration>,
238    /// Registry of shader-driven animated quads (opt-in alternative to
239    /// `Signal<f32>::animate_looping` for decorative motion — progress
240    /// sweeps, sprite-atlas frame cycling, future pulse/shimmer). The
241    /// scheduler-style signal path stays for everything else. Per-slot
242    /// `AnimParams` are ticked and attached to every `RenderFrame`
243    /// produced by `render()` — the renderer reads them from there.
244    animated_quads: crate::animated_quad::AnimatedQuadRegistry,
245    /// Per-frame-effect scheduler. Owns the registry of widgets that
246    /// asked for a frame-tick subscription (Pulse, Cycle, …). Sits
247    /// alongside `animation_scheduler` and `animated_quads` as the
248    /// third visibility-aware motion source — they all consult the
249    /// same [`motion_visibility`](crate::motion_visibility) helpers.
250    /// After every `render()` the tree calls
251    /// `FrameTickScheduler::should_arm_frame_tick` and re-arms
252    /// `frame_tick_requested` if any subscriber's owner was painted
253    /// this frame.
254    pub(crate) frame_tick_scheduler: crate::frame_tick_scheduler::FrameTickScheduler,
255    /// Monotonic counter bumped at the start of each `render()` call.
256    /// Each widget's `last_painted_epoch` is set to this value whenever
257    /// the paint pass (or the cache-hit early-out) confirms the widget
258    /// intersects the window viewport. The animation scheduler uses it
259    /// to detect and pause animations for widgets that have scrolled
260    /// off-screen. Starts at `0`, which serves as the "never painted"
261    /// sentinel; tests that only call `layout()` see the gate bypass.
262    paint_epoch: u64,
263    /// Cached accessibility tree update, rebuilt only when something that
264    /// changes the AT tree has happened, not on every layout.
265    cached_a11y: Option<accesskit::TreeUpdate>,
266    /// Whether the accessibility tree needs rebuilding. Set by focus moves,
267    /// overlay changes, widget rebuilds, active↔dormant transitions,
268    /// `AccessibilityOnly` binding flips and `request_accessibility_update()`;
269    /// a plain relayout does not set it.
270    a11y_dirty: bool,
271    /// Snapshot of `shortcut_registry.version()` at the last
272    /// `sync_accessibility` call. When the live version differs the
273    /// AT cache is dirtied, so widgets that bound their announced
274    /// shortcut via `access_shortcut_id(id)` track user rebinds
275    /// without any explicit signaling from the settings UI.
276    last_synced_shortcut_version: u64,
277    /// Snapshot of `locale_signal` at the last `sync_accessibility`
278    /// call. When the locale differs the AT cache is dirtied, so
279    /// `access_label(tr!(...))` (stored as a locale-bound
280    /// `Prop<String>`) re-resolves into the announced node — even on a
281    /// same-direction switch that doesn't rebuild the composite.
282    last_synced_locale: Option<String>,
283    /// Reverse map from synthetic (widget-emitted) AccessKit NodeIds
284    /// to the WidgetId that owns them. Rebuilt on every full
285    /// accessibility walk. `handle_accessibility_actions` uses this
286    /// to route an `ActionRequest` targeting a TextRun child back
287    /// to the owning rich-text editor, since synthetic NodeIds
288    /// can't be decoded back to a WidgetId by value alone.
289    pub(crate) synthetic_parent_map: std::collections::HashMap<accesskit::NodeId, WidgetId>,
290    /// Where each synthetic child sits inside its owner, for the ones that
291    /// declared their bounds in the owner's own space (a label's line
292    /// boxes, an editor's rows). A pure translation re-places them from
293    /// here; a synthetic node absent from this map — a scene item under a
294    /// view transform, which reports absolute rects — is moved by the
295    /// owner's delta instead.
296    pub(crate) synthetic_local_bounds:
297        std::collections::HashMap<accesskit::NodeId, teksilo_canvas::Rect>,
298    /// Monotonic count of accessibility walks. A consumer that has not
299    /// seen the latest one is holding a tree whose *shape* may be stale,
300    /// not merely its geometry.
301    pub(crate) a11y_walk_generation: u64,
302    /// Cached full render frame — reused when no widget needs painting.
303    /// `Rc<RenderFrame>` rather than `RenderFrame` so cache-hit frames
304    /// cost an atomic refcount bump instead of a deep clone of every
305    /// draw-command Vec. `render()` uses `Rc::make_mut` to update
306    /// `anim_params` in place when the tree is the sole owner (the
307    /// common case — the caller usually drops the previous frame
308    /// before calling render() again).
309    cached_frame: Option<std::rc::Rc<RenderFrame>>,
310    /// Widget that has captured the pointer (receives all PointerMove/PointerUp
311    /// regardless of hit-test). Set via `EventContext::capture_pointer()`.
312    pointer_captured_by: Option<WidgetId>,
313    /// Strict ancestors of the captured widget that carry a drag/swipe
314    /// recognizer, armed on `PointerDown` so an ancestor drag can still start
315    /// while a descendant tap holds the capture (tap-vs-drag disambiguation
316    /// across the hit-path). Innermost-first. Drained when a drag latches or
317    /// the pointer sequence ends. See `arm_drag_observers`.
318    drag_observers: Vec<WidgetId>,
319    /// Current cursor selected by hover/interaction routing.
320    current_cursor: crate::widget::CursorIcon,
321    /// Delayed overlay requests (e.g., submenu hover-open delay).
322    pending_delayed_overlays: Vec<PendingDelayedOverlay>,
323    /// Reusable scratch buffer for active-id snapshots taken on hot
324    /// paths that mutate per-widget state inside the loop
325    /// (`tick_gestures_with_ops`, post-render dirty-bit clear,
326    /// post-layout `needs_layout` clear). Cleared and refilled on
327    /// every use via `WidgetArena::fill_active_ids`. Previously these
328    /// sites called the allocating `arena.active_ids()` per frame,
329    /// which `perf record` ranked at ~13 % of CPU on the
330    /// `widget_catalog --tab animations` scene.
331    active_ids_scratch: Vec<WidgetId>,
332    /// Widgets currently carrying a non-`None` `EventHandlers::gesture_arena`.
333    /// Updated on attach (`ensure_gesture_arena` install) and on
334    /// teardown (rebuild / destroy / handler-clear). Every per-frame
335    /// gesture pass (`tick_gestures_with_ops`, `next_gesture_deadline`)
336    /// iterates this set instead of every active widget — most active
337    /// widgets have `gesture_arena = None`, so the savings come from
338    /// not even visiting them. Filtered by `arena.is_active(id)` at
339    /// iteration time so dormant entries don't fire (a widget can be
340    /// dormant while still holding its handlers).
341    gesture_owners: std::collections::HashSet<WidgetId>,
342    /// OS-level accessibility preferences (high contrast, reduced motion, text scale).
343    prefers_high_contrast: bool,
344    prefers_reduced_motion: bool,
345    text_scale_factor: f64,
346    /// Host window HiDPI device scale (physical px per logical px), fed by
347    /// `teksilo-app` before each layout. Surfaced to widgets via
348    /// `LayoutContext::scale_factor`. The widget tree is otherwise fully
349    /// logical (the renderer applies this scale at the vertex stage); this is
350    /// the escape hatch for widgets that must size a device-pixel OS resource
351    /// (e.g. a `WebView`'s native subview). 1.0 in headless / test contexts.
352    device_scale_factor: f32,
353    /// Active drag-and-drop session, if any.
354    pub(crate) active_drag: Option<crate::drag_state::DragSession>,
355    /// Source widget of an in-flight OS (outbound) drag that escalated past
356    /// the window boundary. Set only on the window that *started* the drag.
357    /// The in-app `active_drag` session is torn down at escalation (the OS owns
358    /// the pointer); this remembers who started it so the eventual `DragEnded`
359    /// can fire the source's `on_drag_ended`.
360    pub(crate) outbound_drag_source: Option<WidgetId>,
361    /// True while *this* window currently holds the re-entered internal session
362    /// for an in-flight app-originated OS drag (the OS drag wandered back over
363    /// this window — possibly a different window than the source — and we
364    /// restored the original typed payload). Distinguishes that session from a
365    /// plain internal drag so leaving again re-stashes instead of starting a
366    /// second OS drag, and dropping doesn't double-fire `on_drag_ended`.
367    pub(crate) os_drag_reentered: bool,
368    /// Optional platform host for custom window chrome (set when the
369    /// application opts in via `WindowConfig::custom_chrome(true)`). Stored
370    /// here so that the root-builder closure has access during widget
371    /// construction; the same `Rc` is also held by `WindowManager` so it
372    /// outlives the widget tree if needed.
373    title_bar_host: Option<Rc<dyn crate::PlatformTitleBarHost>>,
374    /// App-level subscription state: registered event source adapter,
375    /// proxy poster, UI-side subscription callbacks. Default is empty;
376    /// teksilo-app installs a populated context when an event source is
377    /// registered on the builder.
378    pub(crate) app_context: Rc<crate::event_source::TreeAppContext>,
379    /// Active locale identifier. Cached for `Option<&str>` accessors; the
380    /// reactive source of truth is `locale_signal`. Both are updated in
381    /// lockstep by `set_locale`.
382    pub(crate) locale: Option<String>,
383    /// Reactive locale signal. Widgets and `LocalizedString` adapters bind to
384    /// this signal to react to locale changes; `set_locale` updates the signal
385    /// without rebuilding the widget tree.
386    pub(crate) locale_signal: crate::signal::Signal<Option<String>>,
387    /// Per-frame delta-seconds signal, advanced by `layout()` **only when
388    /// a widget has explicitly requested a frame** via `request_frame()`.
389    /// This preserves Teksilo's draw-when-needed model: idle trees stay
390    /// idle even if widgets have registered observers on this signal.
391    pub(crate) frame_tick: crate::signal::Signal<f32>,
392    /// Set by `request_frame()`; consumed by `advance_frame_tick()` on
393    /// the next `layout()`. Observers that need another tick after the
394    /// current one must re-request. Stored as `Rc<Cell>` so observers
395    /// fired from inside the layout pass (`ctx.effect` closures on
396    /// `frame_tick`) can chain-request without needing &mut access
397    /// to the tree — see `FrameRequestHandle`.
398    pub(crate) frame_tick_requested: std::rc::Rc<std::cell::Cell<bool>>,
399    /// Debug-only re-entrancy flag: `true` while a focus-change dispatch
400    /// (`FocusGained` / `FocusLost` handlers) is running. Threaded into each
401    /// `EventContext` so `open_window` / `focus_window` can warn if a handler
402    /// changes context merely because a control gained focus (WCAG 3.2.1). A
403    /// shared `Rc<Cell<bool>>` (like `frame_tick_requested`) so the flag is
404    /// readable from an `EventContext` that holds no `&mut` to the tree.
405    pub(crate) in_focus_dispatch: std::rc::Rc<std::cell::Cell<bool>>,
406    /// Shared "accessibility re-walk requested" flag. Set via
407    /// [`request_accessibility_update`](Self::request_accessibility_update)
408    /// (or its `BuildContext` / `EventContext` wrappers) and drained at the
409    /// top of [`sync_accessibility`](Self::sync_accessibility) into
410    /// `a11y_dirty`. A relayout no longer re-walks the AT tree on its own, so
411    /// widgets that restructure their subtree in an AT-affecting way (e.g.
412    /// `SceneView` materialising / destroying scene widgets) need this lever.
413    /// `Rc<Cell>` so the shared `&self` paths can toggle it like
414    /// `frame_tick_requested`.
415    pub(crate) a11y_update_requested: std::rc::Rc<std::cell::Cell<bool>>,
416    /// Delayed frame wake-up deadline. Widgets that need to schedule
417    /// a future frame without pumping at full framerate (caret blink,
418    /// etc.) store the target instant here via
419    /// [`wake_at_handle`](Self::wake_at_handle). `next_timer_deadline`
420    /// rolls it into the event loop's WaitUntil; when reached, the
421    /// next `layout()` automatically re-arms `frame_tick_requested`
422    /// so the frame-tick effects run on the wake-up pass.
423    pub(crate) pending_wake_at: std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>,
424    /// One-shot post-mount actions enqueued during `build()` via
425    /// [`BuildContext::run_after_mount`](crate::BuildContext::run_after_mount), drained by the app loop (and by
426    /// tests) through [`WidgetTree::run_mount_actions`] with a real
427    /// [`EventContext`] — the only place a widget
428    /// can read the OS parent handle / app-state / poster together after it is
429    /// mounted. Used by widgets that own a native resource needing a window
430    /// handle to initialise (a `WebView`'s engine subview).
431    pub(crate) pending_mount_actions: Vec<Box<dyn FnOnce(&mut crate::widget::EventContext)>>,
432    /// Wall-clock time of the previous `layout()` call (for delta computation).
433    pub(crate) last_frame_time: Option<std::time::Instant>,
434    /// Set by [`EventContext::close_window`] during dispatch; drained
435    /// by the application event loop after each event via
436    /// [`WidgetTree::take_close_window_request`]. A *guarded* close
437    /// request — the app routes it through the window's close guard.
438    pub(crate) close_window_requested: bool,
439    /// Set by [`EventContext::close_window_forced`] during dispatch;
440    /// drained via [`WidgetTree::take_force_close_request`]. An
441    /// *unconditional* close request that bypasses the window's close
442    /// guard.
443    pub(crate) force_close_requested: bool,
444    /// Raised by [`EventContext::set_locale`] during dispatch; drained by
445    /// the application event loop (see
446    /// `WindowManager::drain_pending_locale_requests`) so the switch can be
447    /// routed through the `I18nManager` (active locale + version signal +
448    /// RTL direction). `WidgetTree::set_locale` alone would only update the
449    /// tree's local locale signal — the i18n thread-local would stay put
450    /// and `tr!` lookups would not re-resolve.
451    pub(crate) pending_locale_request: Option<String>,
452    /// Raised by [`EventContext::set_theme`] during dispatch; drained by
453    /// the application event loop (see
454    /// `WindowManager::drain_pending_theme_requests`) so the switch is
455    /// routed through `WindowManager::set_theme`, which fans the new theme
456    /// out to *every* window. Applying via `WidgetTree::set_theme` inline
457    /// would only re-theme the originating window — the rest of the app
458    /// would stay on the old theme. Mirrors `pending_locale_request`.
459    pub(crate) pending_theme_request: Option<crate::styles::Theme>,
460    /// Raised by [`EventContext::follow_system_theme`] during dispatch;
461    /// drained by the application event loop (see
462    /// `WindowManager::drain_pending_follow_system_requests`), which switches
463    /// the app to `ThemeMode::Native` and recomputes the theme from current
464    /// OS colours. Mirrors `pending_theme_request`.
465    pub(crate) pending_follow_system_request: bool,
466    /// Raised by [`EventContext::set_text_scale`] during dispatch; drained by
467    /// the application event loop (see
468    /// `WindowManager::drain_pending_text_scale_requests`) so the change is
469    /// routed through `WindowManager::set_text_scale`, fanning the new factor
470    /// out to *every* window. Mirrors `pending_theme_request`.
471    pub(crate) pending_text_scale_request: Option<f32>,
472    /// Monotonic version counter bumped after every *real* accessibility
473    /// rebuild in [`Self::sync_accessibility`] (cache hits don't bump).
474    /// Mirror of [`crate::shortcut::ShortcutRegistry::version`]: an
475    /// automation / test harness can poll it to know whether the AT tree
476    /// changed without diffing the whole `TreeUpdate`.
477    at_version: crate::signal::Signal<u64>,
478    /// The framework's own live regions, one per politeness level. See
479    /// [`crate::announcer`]: each owns a reserved AccessKit node and cycles it
480    /// in and out of the filtered tree, which is the only mechanism all three
481    /// platform adapters agree announces.
482    announcer_polite: crate::announcer::Announcer,
483    announcer_assertive: crate::announcer::Announcer,
484    /// Ring buffer of captured live-region announcements (see
485    /// [`crate::accessibility::Announcement`]). Filled by a `&mut self`
486    /// post-pass in `sync_accessibility` that diffs `Live::{Polite,
487    /// Assertive}` nodes against `automation_last_text`. Capped at
488    /// [`AUTOMATION_ANNOUNCE_CAP`]; drained by
489    /// [`Self::announcements_since`].
490    automation_announcements: std::collections::VecDeque<crate::accessibility::Announcement>,
491    /// Monotonic sequence number for the next announcement (starts at 0;
492    /// the first announcement is assigned `1`).
493    automation_announce_seq: u64,
494    /// Last announced text per live-region node, so a re-sync only emits a
495    /// new announcement when the text actually changes. Pruned each pass
496    /// to the set of currently-present live nodes, so a node that
497    /// disappears and reappears with the same text re-announces.
498    automation_last_text: std::collections::HashMap<accesskit::NodeId, String>,
499    /// Whether the `WidgetEvent::AccessAction` currently being dispatched was
500    /// consumed by a handler. Written by the dispatcher's `AccessAction` arm,
501    /// read (and reset) by [`Self::dispatch_access_action`], which is the only
502    /// caller that can answer "did anything happen?" to its own caller.
503    ///
504    /// A side channel because `dispatch_event_with_ops` returns `()` for every
505    /// event kind, and routing AT actions through the *same* path as everything
506    /// else is load-bearing (it is what lets an action open a window). The
507    /// alternative — reporting success whenever a live widget merely *existed*
508    /// at the target — is how an unhandled action came to look like a
509    /// successful one to every automation client.
510    access_action_handled: bool,
511    /// The `WindowState` for this tree's hosting window. Populated
512    /// by the app-level window manager when the tree is registered;
513    /// `None` for standalone trees. Cloned into every `EventContext`
514    /// and `BuildContext` so widgets can bind to the current window's
515    /// signals via `ctx.window()`.
516    pub(crate) window_state: Option<crate::window::WindowState>,
517}
518
519/// Maximum number of live-region [`crate::accessibility::Announcement`]s
520/// the [`WidgetTree`] retains. The oldest is evicted when the buffer is
521/// full; `announcements_since` only ever returns the retained tail.
522const AUTOMATION_ANNOUNCE_CAP: usize = 256;
523
524/// How long the shortened reshow delay stays active after the last tooltip
525/// dismisses. Long enough to cover moving between adjacent toolbar icons;
526/// short enough that a later, deliberate hover still pays the full initial
527/// delay. Not a theme token — it is session bookkeeping, not a visual feel.
528const TOOLTIP_SESSION_GRACE: std::time::Duration = std::time::Duration::from_millis(1000);
529
530/// Number of visible steps a sticky-on-dwell tooltip's promotion window is
531/// divided into.
532///
533/// The tree uses this only to decide *how often to wake* while a dwell is
534/// running — one redraw per step boundary rather than a free-run — but it must
535/// match the step count the content widget actually renders, or the indicator
536/// would advance on a different beat from the wake-ups driving it.
537/// `teksilo-widgets`' `DWELL_STEPS` is pinned to this value by a compile-time
538/// assertion; the per-step *duration* is derived from each entry's own
539/// `sticky_after`, so a caller that picks a non-default promotion window still
540/// gets correctly-spaced wake-ups.
541pub const TOOLTIP_DWELL_STEPS: u32 = 4;
542
543/// Max pointer travel (logical px) from the hover-origin before a pending
544/// tooltip timer restarts. Mirrors Windows hover-tracking slop
545/// (`SPI_GETMOUSEHOVERWIDTH` / height, typically ~4 px): the tip waits for a
546/// *paused* pointer, not merely "entered the bounds."
547const TOOLTIP_STATIONARY_SLOP: f32 = 4.0;
548
549/// A tooltip attachment managed by the WidgetTree.
550struct TooltipEntry {
551    anchor_id: WidgetId,
552    content_id: WidgetId,
553    /// The widget whose accessibility node should carry this tooltip's
554    /// description, which is not always the node the overlay hangs off.
555    ///
556    /// A composing control anchors the *overlay* on an inner chrome node it
557    /// built -- the thing with the right bounds to open a tooltip against --
558    /// while its role, its name and its focusability live on its own outer
559    /// node. A description on the inner one is a description an assistive
560    /// technology never reads, because it never lands there.
561    ///
562    /// Recorded by `BuildContext`'s `attach_tooltip*` wrappers as the widget
563    /// that was building at the time. Defaults to `anchor_id`, which is both
564    /// the historic behaviour and the right answer for a widget that anchors
565    /// its tooltip on itself.
566    ///
567    /// Naming an owner is a *claim*, not a guarantee: the accessibility walk
568    /// honours it only where exactly one tooltip claims that node. See
569    /// `WidgetTree::build_accessibility_recursive`.
570    description_owner_id: WidgetId,
571    delay: std::time::Duration,
572    /// Simulated hover start (for deterministic tests via advance_time).
573    hover_start: Option<std::time::Instant>,
574    /// Real hover start (for windowed apps via layout).
575    real_hover_start: Option<std::time::Instant>,
576    /// Pointer position when the current pending hover started. Used to
577    /// restart the delay if the pointer keeps moving inside the anchor
578    /// (stationary-pointer intent filter).
579    hover_origin: Option<teksilo_canvas::Point>,
580    overlay_id: Option<crate::overlay::OverlayId>,
581    /// When set, the tooltip auto-promotes to "sticky" after this
582    /// much elapsed time since it was shown. The entry stays in the
583    /// table and is just flagged sticky — the difference is that
584    /// pointer-leave no longer dismisses it and the overlay's
585    /// dismiss behavior is swapped to `EscapeOrClickOutside`.
586    sticky_after: Option<std::time::Duration>,
587    /// True when the dwell timer reached `sticky_after`. Causes
588    /// `tooltip_pointer_leave` to skip the dismissal and lets the
589    /// overlay survive pointer-leave until the user explicitly
590    /// dismisses it via Escape or a click outside.
591    is_sticky: bool,
592    /// When the overlay was shown (simulated). Together with
593    /// `sticky_after` drives auto-promotion.
594    shown_at_sim: Option<std::time::Instant>,
595    /// When the overlay was shown (real).
596    shown_at_real: Option<std::time::Instant>,
597    /// Optional shared sink the tooltip widget can read from to
598    /// compute its own dwell progress. Mirrors `shown_at_real`:
599    /// set on show, cleared on dismissal. Used by `RichTooltipWidget`
600    /// to drive the dwell indicator without relying on a fragile
601    /// paint-gap heuristic.
602    shown_at_sink: Option<std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>>,
603    /// True when the tooltip was shown by the keyboard-focus path
604    /// rather than the pointer-hover path. Focus-promoted tooltips
605    /// dismiss when focus moves outside both the anchor and the
606    /// tooltip content subtree (preventing accumulation as the user
607    /// Tabs through a form); pointer-dwelled stickies survive
608    /// focus changes and only dismiss via Escape or click-outside.
609    promoted_by_focus: bool,
610    /// Set while this entry's pending delay was started by keyboard focus
611    /// rather than by the pointer. Decides, at show time, that the surface
612    /// dismisses on `Escape`/click-outside rather than on pointer-leave (there
613    /// is no pointer in the story), and that it counts as focus-shown.
614    armed_by_focus: bool,
615    /// Set when the tip was dismissed while the focus that summoned it is
616    /// still inside its anchor — i.e. Escape on a focus-promoted tooltip.
617    ///
618    /// Escape restores focus to the anchor, and that restore runs the ordinary
619    /// focus path, which ends in `tooltip_focus_enter`. Without this flag the
620    /// tip the user just dismissed re-opens on the same keystroke, because
621    /// `dormant_dismissed_content` has already cleared `overlay_id` by then and
622    /// the entry looks eligible again. Cleared when focus genuinely leaves the
623    /// anchor (`tooltip_focus_leave_outside`), so Tabbing away and back
624    /// re-summons it normally. The hover path needs no equivalent: a stationary
625    /// pointer never re-fires `tooltip_pointer_enter`.
626    suppressed_until_focus_leaves: bool,
627    /// Where the tooltip opens relative to its anchor. `Below` (default)
628    /// for the common case; `Side` for anchors stacked vertically (menu
629    /// items, a vertical tab strip, list/tree rows) so the tooltip does
630    /// not cover the next sibling. Consulted at show time in both the
631    /// hover (`process_tooltips_impl`) and focus (`tooltip_focus_enter`)
632    /// paths.
633    placement: crate::overlay::TooltipPlacement,
634}
635
636/// A delayed overlay request (e.g., submenu hover-open delay).
637struct PendingDelayedOverlay {
638    request: crate::overlay::OverlayRequest,
639    delay: std::time::Duration,
640    focus_target: Option<WidgetId>,
641    /// Dismiss the anchor's sibling overlays when this one finally
642    /// shows, instead of when it was requested. See
643    /// [`EventContext::show_overlay_after_replacing_siblings`](crate::widget::EventContext::show_overlay_after_replacing_siblings).
644    replace_siblings: bool,
645    /// When the request was made (real time, for windowed apps).
646    real_requested_at: std::time::Instant,
647    /// When the request was made (simulated time, for tests).
648    sim_requested_at: std::time::Instant,
649}
650
651impl WidgetTree {
652    pub fn new() -> Self {
653        let initial_theme = crate::presets::intui::light();
654        // One signal, shared: the field the tree writes and the registry the
655        // application reads must be the same one, or a host mirroring "is the
656        // caret in a text widget" would never see focus move.
657        let focused_signal = crate::signal::Signal::new(None);
658        Self {
659            arena: WidgetArena::new(),
660            theme: initial_theme.clone(),
661            theme_signal: crate::signal::Signal::new(initial_theme.clone()),
662            user_text_scale: 1.0,
663            effective_theme: initial_theme,
664            effective_text_scale: 1.0,
665            text_scale_signal: crate::signal::Signal::new(1.0),
666            // Starts active: winit may not send `Focused(true)` for the first
667            // window, so a window must not be born inactive (caret hidden,
668            // selection muted) before the first focus event arrives.
669            window_active_signal: crate::signal::Signal::new(true),
670            text_backend: None,
671            focused: None,
672            focused_signal: focused_signal.clone(),
673            hovered: None,
674            hovered_signal: crate::signal::Signal::new(None),
675            focus_visible: crate::signal::Signal::new(false),
676            view_focus_stack: Vec::new(),
677            last_pointer_position: None,
678            previous_pointer_position: None,
679            pending_focus_restore: None,
680            last_proposal: SizeProposal::exact(800.0, 600.0),
681            pending_modal_requests: Vec::new(),
682            pending_modal_dismissal: false,
683            shortcut_registry: crate::shortcut::ShortcutRegistry::new(),
684            pending_intents: Vec::new(),
685            global_actions: Vec::new(),
686            text_surfaces: crate::text_surface::TextSurfaces::new(focused_signal.clone()),
687            key_capture: None,
688            binding_registry: crate::binding::BindingRegistry::new(),
689            idle_queue: crate::idle::IdleQueue::new(),
690            sim_clock: std::time::Instant::now(),
691            sim_driven: false,
692            focus_origin: None,
693            overlay_manager: crate::overlay::OverlayManager::new(),
694            tooltips: Vec::new(),
695            tooltip_session_until_sim: None,
696            tooltip_session_until_real: None,
697            highlight_tooltip: None,
698            layout_direction: crate::environment::LayoutDirection::default(),
699            animation_scheduler: crate::animation::AnimationScheduler::new(),
700            animated_values: Vec::new(),
701            animated_quads: crate::animated_quad::AnimatedQuadRegistry::new(),
702            frame_tick_scheduler: crate::frame_tick_scheduler::FrameTickScheduler::new(),
703            paint_epoch: 0,
704            cached_a11y: None,
705            a11y_dirty: true,
706            last_synced_shortcut_version: 0,
707            last_synced_locale: None,
708            synthetic_parent_map: std::collections::HashMap::new(),
709            synthetic_local_bounds: std::collections::HashMap::new(),
710            a11y_walk_generation: 0,
711            cached_frame: None,
712            pointer_captured_by: None,
713            drag_observers: Vec::new(),
714            current_cursor: crate::widget::CursorIcon::Default,
715            pending_delayed_overlays: Vec::new(),
716            active_ids_scratch: Vec::new(),
717            gesture_owners: std::collections::HashSet::new(),
718            prefers_high_contrast: false,
719            prefers_reduced_motion: false,
720            text_scale_factor: 1.0,
721            device_scale_factor: 1.0,
722            active_drag: None,
723            outbound_drag_source: None,
724            os_drag_reentered: false,
725            title_bar_host: None,
726            app_context: Rc::new(crate::event_source::TreeAppContext::empty()),
727            locale: None,
728            locale_signal: crate::signal::Signal::new(None),
729            frame_tick: crate::signal::Signal::new(0.0_f32),
730            frame_tick_requested: std::rc::Rc::new(std::cell::Cell::new(false)),
731            in_focus_dispatch: std::rc::Rc::new(std::cell::Cell::new(false)),
732            a11y_update_requested: std::rc::Rc::new(std::cell::Cell::new(false)),
733            pending_wake_at: std::rc::Rc::new(std::cell::Cell::new(None)),
734            pending_mount_actions: Vec::new(),
735            last_frame_time: None,
736            close_window_requested: false,
737            force_close_requested: false,
738            pending_locale_request: None,
739            pending_theme_request: None,
740            pending_follow_system_request: false,
741            pending_text_scale_request: None,
742            at_version: crate::signal::Signal::new(0),
743            announcer_polite: crate::announcer::Announcer::new(
744                crate::announcer::Politeness::Polite,
745            ),
746            announcer_assertive: crate::announcer::Announcer::new(
747                crate::announcer::Politeness::Assertive,
748            ),
749            automation_announcements: std::collections::VecDeque::new(),
750            automation_announce_seq: 0,
751            automation_last_text: std::collections::HashMap::new(),
752            access_action_handled: false,
753            window_state: None,
754        }
755    }
756
757    /// Construct an [`EventContext`]
758    /// pre-populated with the tree's app-state registry, hosting
759    /// `WindowState`, and a `&mut dyn WindowOps` handle so handlers
760    /// can synchronously reach the multi-window API. Used by every
761    /// dispatch site.
762    pub(crate) fn make_event_context<'ops>(
763        &self,
764        ops: &'ops mut dyn crate::window::WindowOps,
765    ) -> crate::widget::EventContext<'ops> {
766        let drag_is_external = self.active_drag.as_ref().is_some_and(|d| d.is_external);
767        // Read-only snapshot of tree query state that handlers may
768        // need synchronously: the last pointer position and a
769        // (content_id, bounds) slice of open overlays, both read by the
770        // safe-triangle submenu hover gate, and the focused widget, read
771        // by a container deciding whether a shortcut of its own should
772        // yield to the widget the user is standing on.
773        let overlay_snapshot: Vec<(
774            crate::widget_id::WidgetId,
775            teksilo_canvas::Rect,
776            Option<teksilo_canvas::Point>,
777        )> = self
778            .overlay_manager
779            .active_content_ids()
780            .into_iter()
781            .filter_map(|cid| {
782                self.overlay_manager
783                    .bounds_for_content(cid)
784                    .map(|r| (cid, r, self.unexpired_safe_apex_for_content(cid)))
785            })
786            .collect();
787        crate::widget::EventContext::new()
788            .with_app_context(self.app_context.clone())
789            .with_window_context(ops, self.window_state.clone())
790            .with_drag_external(drag_is_external)
791            .with_query_snapshot(self.last_pointer_position, overlay_snapshot, self.focused())
792            .with_layout_direction(self.layout_direction)
793            .with_window_active(self.is_window_active())
794            .with_focus_dispatch_flag(self.in_focus_dispatch.clone())
795    }
796
797    /// Run a closure with a fresh [`EventContext`] anchored at this
798    /// tree, then collect any pending operations queued through the
799    /// context (intents, modal requests, frame requests, idle
800    /// callbacks…) so they take effect on the next event-loop tick.
801    ///
802    /// Used by the `teksilo-app` event-loop dispatcher to deliver
803    /// async-result callbacks (file dialogs, future background
804    /// tasks) on the main thread with full handler-equivalent
805    /// semantics. There is no source widget for app-level events,
806    /// so intents are anchored at the tree's first root id (or
807    /// silently dropped when the tree is empty).
808    pub fn run_with_event_context<F>(&mut self, ops: &mut dyn crate::window::WindowOps, f: F)
809    where
810        F: FnOnce(&mut crate::widget::EventContext),
811    {
812        let mut ctx = self.make_event_context(ops);
813        f(&mut ctx);
814        let anchor = self.arena.roots().first().copied();
815        if let Some(anchor_id) = anchor {
816            self.collect_from_ctx(ctx, anchor_id);
817        } else {
818            // Empty tree — nothing to anchor intents on. Drop ctx;
819            // its only side effects (frame requests, cursor) are
820            // not meaningful for an empty tree.
821            drop(ctx);
822        }
823    }
824
825    /// Enqueue a one-shot action to run with a real
826    /// [`EventContext`] after the current build,
827    /// once the tree is mounted under its window. Used via
828    /// [`BuildContext::run_after_mount`](crate::BuildContext::run_after_mount). Drained by
829    /// [`Self::run_mount_actions`].
830    pub(crate) fn queue_mount_action(
831        &mut self,
832        action: Box<dyn FnOnce(&mut crate::widget::EventContext)>,
833    ) {
834        self.pending_mount_actions.push(action);
835    }
836
837    /// Whether any post-mount actions are waiting to run.
838    pub fn has_pending_mount_actions(&self) -> bool {
839        !self.pending_mount_actions.is_empty()
840    }
841
842    /// Drain and run every queued post-mount action with a fresh
843    /// [`EventContext`] built over `ops`. The app
844    /// loop calls this each iteration with a real `WindowOps` sink (so
845    /// `ctx.parent_window_handle()` resolves); headless tests call it with a
846    /// `NoopWindowOps`. Actions enqueued *by* an action (rare) are left for the
847    /// next drain rather than run re-entrantly.
848    pub fn run_mount_actions(&mut self, ops: &mut dyn crate::window::WindowOps) {
849        if self.pending_mount_actions.is_empty() {
850            return;
851        }
852        let actions = std::mem::take(&mut self.pending_mount_actions);
853        self.run_with_event_context(ops, move |ctx| {
854            for action in actions {
855                action(ctx);
856            }
857        });
858    }
859
860    /// Attach the [`WindowState`](crate::window::WindowState) for this
861    /// tree's hosting window. Called by `WindowManager::create_window`.
862    pub fn set_window_state(&mut self, state: crate::window::WindowState) {
863        self.window_state = Some(state);
864    }
865
866    pub fn window_state(&self) -> Option<&crate::window::WindowState> {
867        self.window_state.as_ref()
868    }
869
870    /// Clone the shared "frame requested" flag. Widgets stash this
871    /// in their state and call `.set(true)` from inside frame-tick
872    /// closures to chain-request another frame without needing
873    /// mutable access to the tree. See `RichTextEditor` for the
874    /// canonical use (caret blink, drag-select auto-scroll).
875    pub fn frame_request_handle(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
876        self.frame_tick_requested.clone()
877    }
878
879    /// Clone the shared wake-at deadline cell. Widgets stash this in
880    /// their state and call `request_wake_at` from frame-tick effects
881    /// to schedule a one-shot deadline without keeping the event loop
882    /// in `Poll` mode. On the next `layout()` at or past the deadline,
883    /// the tree auto-arms `frame_tick_requested` so the effect runs on
884    /// the wake-up pass. Canonical use: the rich text editor's caret
885    /// blink schedules a 500 ms wake instead of pumping every frame.
886    pub fn wake_at_handle(&self) -> std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>> {
887        self.pending_wake_at.clone()
888    }
889
890    /// Schedule a one-shot frame wake at `at`. Merges with any existing
891    /// deadline — keeps the earlier instant so the most urgent wake
892    /// wins.
893    pub fn request_wake_at(&self, at: std::time::Instant) {
894        let current = self.pending_wake_at.get();
895        let merged = match current {
896            Some(existing) if existing <= at => existing,
897            _ => at,
898        };
899        self.pending_wake_at.set(Some(merged));
900    }
901
902    /// The per-frame delta-seconds signal. Observers fire **only on frames
903    /// the tree was asked to pump** via [`request_frame`](Self::request_frame);
904    /// merely observing the signal does not keep the event loop awake.
905    /// See `BuildContext::frame_tick` for widget-side access and
906    /// `BuildContext::request_frame` for the opt-in request side.
907    pub fn frame_tick(&self) -> crate::signal::Signal<f32> {
908        self.frame_tick.clone()
909    }
910
911    /// Ask the tree to pump exactly one more frame. `needs_redraw()`
912    /// returns true until the request is consumed by the next
913    /// `layout()` call, which fires the per-frame tick signal and
914    /// clears the flag. Observers that still need more frames (drag
915    /// auto-scroll, caret blink, pending document events) must call
916    /// `request_frame()` again from inside their tick closure.
917    ///
918    /// Takes `&self` on purpose: widget handlers and per-frame effects
919    /// receive a shared reference to the tree via `EventContext` /
920    /// `BuildContext`, and the request flag is a `Cell` specifically so
921    /// those shared paths can toggle it without ceremony.
922    pub fn request_frame(&self) {
923        self.frame_tick_requested.set(true);
924    }
925
926    /// Request that the AccessKit tree be re-walked on the next
927    /// [`sync_accessibility`](Self::sync_accessibility). Takes `&self` (the
928    /// flag is a `Cell`) so handlers and `build()` closures reaching the tree
929    /// through a shared reference can request a re-walk without `&mut` access.
930    /// The drain at the top of `sync_accessibility` flips `a11y_dirty`.
931    pub fn request_accessibility_update(&self) {
932        self.a11y_update_requested.set(true);
933    }
934
935    /// Speak `message` to the screen reader, politely.
936    ///
937    /// For anything the user needs told that is not the name of a widget: a
938    /// completed action, a changed count, the result of an undo. The message is
939    /// delivered on the next two accessibility syncs, which this schedules.
940    ///
941    /// Prefer `EventContext::announce` inside a handler and
942    /// `BuildContext::announce` inside a build; this is the tree-level entry
943    /// point both of those reach.
944    ///
945    /// Takes `impl Into<String>`, so `tr!(…)` works directly. See
946    /// [`crate::announcer`] for why it is a `String` and not a
947    /// `LocalizedString`, and for why an announcement beside a `Toast` says
948    /// everything twice.
949    pub fn announce(&mut self, message: impl Into<String>) {
950        self.announce_with(message, crate::announcer::Politeness::Polite);
951    }
952
953    /// Speak `message` to the screen reader at the given urgency.
954    ///
955    /// [`Politeness::Assertive`](crate::announcer::Politeness::Assertive)
956    /// interrupts whatever is being spoken; reserve it for something the user
957    /// must not miss and cannot recover by re-reading the screen.
958    pub fn announce_with(
959        &mut self,
960        message: impl Into<String>,
961        politeness: crate::announcer::Politeness,
962    ) {
963        match politeness {
964            crate::announcer::Politeness::Polite => self.announcer_polite.push(message.into()),
965            crate::announcer::Politeness::Assertive => {
966                self.announcer_assertive.push(message.into())
967            }
968        }
969        // Two syncs are needed per message (expose, then retract), and a sync
970        // only happens on a frame. Without both of these a message queued from
971        // a handler that changed nothing visible would sit unspoken until
972        // something else happened to redraw.
973        self.request_accessibility_update();
974        self.request_frame();
975    }
976
977    /// Clone the shared "accessibility re-walk requested" flag, for the same
978    /// stash-and-toggle pattern as [`frame_request_handle`](Self::frame_request_handle).
979    pub fn a11y_request_handle(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
980        self.a11y_update_requested.clone()
981    }
982
983    /// Whether a frame was explicitly requested. Exposed for tests and
984    /// for the event-loop driver that decides when to schedule the next
985    /// wake-up.
986    pub fn frame_requested(&self) -> bool {
987        self.frame_tick_requested.get()
988    }
989
990    /// The next wake-up deadline for the per-frame-effect path, or
991    /// `None` when no per-frame effect is armed.
992    ///
993    /// This is the **60 Hz cap** for continuous per-frame animations
994    /// (`Pulse`, caret blink, drag auto-scroll, `--cycle` drivers). The
995    /// per-frame-effect path used to force `ControlFlow::Poll`, which
996    /// free-runs at the display's refresh rate — so on a 300 Hz panel a
997    /// single `Pulse`/`Cycle` rendered at 300 fps (measured ~45 % CPU) for
998    /// motion that looks identical at 60 fps. Routing it through a fixed
999    /// 16.667 ms deadline (folded into
1000    /// [`next_timer_deadline`](Self::next_timer_deadline)) makes it pace at
1001    /// 60 Hz regardless of refresh rate, matching the signal-tween
1002    /// [`AnimationScheduler`](crate::animation::AnimationScheduler) and
1003    /// shader-quad [`AnimatedQuadRegistry`](crate::animated_quad::AnimatedQuadRegistry),
1004    /// which already share the same interval.
1005    ///
1006    /// A **throttled** subscriber (registered via
1007    /// [`FrameTickScheduler::subscribe_throttled`](crate::frame_tick_scheduler::FrameTickScheduler::subscribe_throttled)
1008    /// — e.g. `Cycle`, whose visible child only changes once per period)
1009    /// stretches the deadline to its own interval: the loop then sleeps to
1010    /// the period instead of rendering identical 60 fps frames in between.
1011    /// The interval used is the **minimum across all currently-visible
1012    /// subscribers**, so a `Cycle` next to a `Pulse` still ticks at 60 Hz
1013    /// while a lone `Cycle` sleeps to its period. Raw `request_frame`
1014    /// consumers with no subscription fall back to 60 Hz.
1015    ///
1016    /// Paces from `last_frame_time` so the cadence is drift-free; before
1017    /// the first render it fires on the next loop turn.
1018    pub fn frame_tick_deadline(&self) -> Option<std::time::Instant> {
1019        // 60 Hz fallback for raw `request_frame` consumers (no subscriber).
1020        const DEFAULT_INTERVAL: std::time::Duration = std::time::Duration::from_micros(16_667);
1021        if !self.frame_tick_requested.get() {
1022            return None;
1023        }
1024        let interval = self
1025            .frame_tick_scheduler
1026            .min_visible_interval(&self.arena, self.paint_epoch)
1027            .unwrap_or(DEFAULT_INTERVAL);
1028        Some(match self.last_frame_time {
1029            Some(prev) => prev + interval,
1030            None => std::time::Instant::now(),
1031        })
1032    }
1033
1034    /// Subscribe `owner` to the per-frame-effect scheduler. The
1035    /// returned [`FrameTickSubscription`](crate::frame_tick_scheduler::FrameTickSubscription)
1036    /// is an RAII guard — drop it (typically by replacing the field on
1037    /// the owning widget on rebuild, or letting the widget's `Drop`
1038    /// run) to remove the subscription. While the guard is alive, the
1039    /// tree will keep arming `frame_tick_requested` after every render
1040    /// in which `owner` was painted, and stop on frames where it
1041    /// wasn't — so a subscribed widget hidden inside a non-selected
1042    /// `Switcher` branch contributes zero idle frames.
1043    ///
1044    /// Apps should not call this directly — use
1045    /// [`BuildContext::subscribe_frame_tick`](crate::build_context::BuildContext::subscribe_frame_tick)
1046    /// from inside `Widget::build`.
1047    pub fn subscribe_frame_tick(
1048        &self,
1049        owner: WidgetId,
1050    ) -> crate::frame_tick_scheduler::FrameTickSubscription {
1051        self.frame_tick_scheduler.subscribe(owner)
1052    }
1053
1054    /// Like [`subscribe_frame_tick`](Self::subscribe_frame_tick), but the
1055    /// owner only needs to wake **at most once per `interval`** while
1056    /// visible. Same visibility gate; between wakes the event loop sleeps
1057    /// to the interval deadline instead of rendering identical 60 fps
1058    /// frames. Use for effects whose visible output changes far less often
1059    /// than 60 Hz — e.g. `Cycle`'s once-per-period index advance.
1060    ///
1061    /// Apps should not call this directly — use
1062    /// [`BuildContext::subscribe_frame_tick_throttled`](crate::build_context::BuildContext::subscribe_frame_tick_throttled)
1063    /// from inside `Widget::build`.
1064    pub fn subscribe_frame_tick_throttled(
1065        &self,
1066        owner: WidgetId,
1067        interval: std::time::Duration,
1068    ) -> crate::frame_tick_scheduler::FrameTickSubscription {
1069        self.frame_tick_scheduler
1070            .subscribe_throttled(owner, interval)
1071    }
1072
1073    /// Advance the frame tick signal when (and only when) a frame was
1074    /// requested. Called by `layout()` before the scheduler tick so the
1075    /// per-frame observers fire on the same frame they asked for.
1076    pub(crate) fn advance_frame_tick(&mut self, now: std::time::Instant) {
1077        if !self.frame_tick_requested.get() {
1078            self.last_frame_time = Some(now);
1079            return;
1080        }
1081        self.frame_tick_requested.set(false);
1082        let delta = match self.last_frame_time {
1083            Some(prev) => {
1084                let d = now.saturating_duration_since(prev).as_secs_f32();
1085                // Clamp absurd deltas (pause/breakpoint) so observers never see a spike.
1086                d.clamp(0.0, 0.1)
1087            }
1088            None => 0.0,
1089        };
1090        self.last_frame_time = Some(now);
1091        self.frame_tick.set(delta);
1092    }
1093
1094    /// Replace the per-tree app context. Called by `teksilo-app` when
1095    /// constructing a window so the widget tree can reach the registered
1096    /// event source adapter and post subscription events through the
1097    /// event-loop proxy.
1098    pub fn set_app_context(&mut self, app_context: Rc<crate::event_source::TreeAppContext>) {
1099        self.app_context = app_context;
1100    }
1101
1102    /// Get the per-tree app context. Used by `BuildContext::subscribe_event`
1103    /// and by the event-loop handler when dispatching incoming
1104    /// `AppEvent::SubscriptionEvent`.
1105    pub fn app_context(&self) -> &Rc<crate::event_source::TreeAppContext> {
1106        &self.app_context
1107    }
1108
1109    /// Switch the tree-level locale at runtime.
1110    ///
1111    /// Updates `locale_signal` (a reactive `Signal<Option<String>>`) and marks
1112    /// all widgets dirty for relayout and repaint. Widgets are **not** rebuilt:
1113    /// per-string reactivity flows through `LocalizedString::to_signal()` which
1114    /// observes the teksilo-i18n manager, and anything else that depends on the
1115    /// tree-level locale can bind to `locale_signal()`.
1116    pub fn set_locale(&mut self, locale: String) {
1117        if self.locale.as_deref() == Some(locale.as_str()) {
1118            return;
1119        }
1120        let new = Some(locale);
1121        self.locale = new.clone();
1122        self.locale_signal.set(new);
1123        self.arena.mark_all_dirty();
1124    }
1125
1126    /// Currently active locale identifier, if any.
1127    pub fn locale(&self) -> Option<&str> {
1128        self.locale.as_deref()
1129    }
1130
1131    /// Reactive handle on the current locale. Mirrors `locale()` but updates
1132    /// observers when `set_locale` is called.
1133    pub fn locale_signal(&self) -> &crate::signal::Signal<Option<String>> {
1134        &self.locale_signal
1135    }
1136
1137    fn pointer_inside_overlay_region(
1138        &self,
1139        overlay_id: crate::overlay::OverlayId,
1140        position: Point,
1141    ) -> bool {
1142        let Some(overlay) = self
1143            .overlay_manager
1144            .stack
1145            .iter()
1146            .find(|overlay| overlay.id == overlay_id)
1147        else {
1148            return false;
1149        };
1150
1151        if self.arena.is_active(overlay.anchor)
1152            && self.arena.bounds(overlay.anchor).contains(position)
1153        {
1154            return true;
1155        }
1156
1157        self.overlay_manager.stack.iter().any(|candidate| {
1158            (candidate.id == overlay_id
1159                || self
1160                    .overlay_manager
1161                    .is_descendant_of(candidate.id, overlay_id))
1162                && candidate.bounds.contains(position)
1163        })
1164    }
1165
1166    /// Whether the overlay's safe region is armed and still within
1167    /// [`SAFE_REGION_BUDGET`](crate::overlay::SAFE_REGION_BUDGET).
1168    ///
1169    /// Both clocks are consulted and either expiring is enough: the real
1170    /// one drives a running app, the simulated one drives headless tests
1171    /// (`advance_time`), and an overlay armed in one and aged in the
1172    /// other must still expire.
1173    fn safe_region_unexpired(
1174        &self,
1175        overlay_id: crate::overlay::OverlayId,
1176        real_now: std::time::Instant,
1177        sim_now: std::time::Instant,
1178    ) -> bool {
1179        let Some(overlay) = self
1180            .overlay_manager
1181            .stack
1182            .iter()
1183            .find(|overlay| overlay.id == overlay_id)
1184        else {
1185            return false;
1186        };
1187        if overlay.safe_apex.is_none() {
1188            return false;
1189        }
1190        let budget = crate::overlay::SAFE_REGION_BUDGET;
1191        let real_ok = overlay
1192            .safe_apex_started_real
1193            .is_none_or(|started| real_now.saturating_duration_since(started) < budget);
1194        let sim_ok = overlay
1195            .safe_apex_started_sim
1196            .is_none_or(|started| sim_now.saturating_duration_since(started) < budget);
1197        real_ok && sim_ok
1198    }
1199
1200    /// The armed safe-triangle apex of the overlay rooted at
1201    /// `content_id`, or `None` when no region is armed or its budget is
1202    /// spent.
1203    ///
1204    /// This is the form the per-dispatch `EventContext` snapshot carries,
1205    /// because a widget asking "is a traversal toward that submenu still
1206    /// live?" must get the same answer the dismissal path acts on — an
1207    /// unfiltered apex outlives the region by up to a frame and makes a
1208    /// sibling row stand aside for a submenu the framework has already
1209    /// stopped protecting.
1210    fn unexpired_safe_apex_for_content(
1211        &self,
1212        content_id: crate::widget_id::WidgetId,
1213    ) -> Option<teksilo_canvas::Point> {
1214        // Cheap short-circuit first: nothing is armed in the common case,
1215        // and this runs once per node per pointer dispatch.
1216        let apex = self.overlay_manager.safe_apex_for_content(content_id)?;
1217        let id = self.overlay_manager.find_by_content(content_id)?;
1218        self.safe_region_unexpired(id, std::time::Instant::now(), self.sim_clock)
1219            .then_some(apex)
1220    }
1221
1222    fn update_pointer_leave_overlays(
1223        &mut self,
1224        position: Point,
1225        ops: &mut dyn crate::window::WindowOps,
1226    ) {
1227        let overlay_ids: Vec<crate::overlay::OverlayId> = self
1228            .overlay_manager
1229            .stack
1230            .iter()
1231            .filter(|overlay| {
1232                matches!(
1233                    overlay.dismiss,
1234                    crate::overlay::DismissBehavior::PointerLeave { .. }
1235                )
1236            })
1237            .map(|overlay| overlay.id)
1238            .collect();
1239
1240        let real_now = std::time::Instant::now();
1241        let sim_now = self.sim_clock;
1242
1243        for overlay_id in overlay_ids {
1244            let inside = self.pointer_inside_overlay_region(overlay_id, position);
1245            // A pointer travelling the safe triangle toward this overlay
1246            // is still "inside" as far as dismissal is concerned — that
1247            // is the whole point of the triangle. Without this the
1248            // grace period runs for the entire diagonal and closes the
1249            // submenu mid-flight, no matter what the hover handlers do.
1250            // See `overlay::safe_triangle`.
1251            let armed = self.safe_region_unexpired(overlay_id, real_now, sim_now);
1252            let travelling = !inside
1253                && armed
1254                && self
1255                    .overlay_manager
1256                    .point_in_safe_region(overlay_id, position);
1257            // Arrived, or returned to the trigger row: the traversal is
1258            // over and the ordinary rules apply again.
1259            //
1260            // Straying out of the cone deliberately does NOT disarm. The
1261            // cone is a needle at its apex — the first sample after the
1262            // pointer leaves the trigger row is a pixel or two away, so
1263            // whether it lands inside is one quantized step's direction
1264            // rather than the user's intent. Disarming there made that
1265            // sample final, because a region is only ever armed as the
1266            // pointer leaves the row and the pointer has already left:
1267            // nothing could re-arm it. Keeping it armed makes the cone a
1268            // per-sample hold that the grace below re-evaluates instead,
1269            // so a wobble mid-diagonal costs nothing while a real change
1270            // of mind still closes the submenu one close-delay later. The
1271            // budget is what retires a region for good; the frame pass
1272            // spends it even for a pointer that stopped moving entirely.
1273            if armed && inside {
1274                self.overlay_manager.clear_safe_region(overlay_id);
1275            }
1276            if let Some(overlay) = self
1277                .overlay_manager
1278                .stack
1279                .iter_mut()
1280                .find(|overlay| overlay.id == overlay_id)
1281            {
1282                if inside || travelling {
1283                    overlay.pointer_leave_started_real = None;
1284                    overlay.pointer_leave_started_sim = None;
1285                } else if overlay.pointer_leave_started_real.is_none() {
1286                    overlay.pointer_leave_started_real = Some(real_now);
1287                    overlay.pointer_leave_started_sim = Some(sim_now);
1288                    self.arena.mark_needs_paint(overlay.anchor);
1289                }
1290            }
1291        }
1292
1293        self.process_pointer_leave_overlays_real(&mut *ops);
1294    }
1295
1296    fn process_pointer_leave_overlays(&mut self) {
1297        let sim_now = self.sim_clock;
1298        let mut noop = crate::window::NoopWindowOps;
1299        self.process_pointer_leave_overlays_impl(
1300            |overlay| {
1301                overlay
1302                    .pointer_leave_started_sim
1303                    .map(|started| sim_now.saturating_duration_since(started))
1304            },
1305            &mut noop,
1306        );
1307    }
1308
1309    fn process_pointer_leave_overlays_real(&mut self, ops: &mut dyn crate::window::WindowOps) {
1310        let real_now = std::time::Instant::now();
1311        self.process_pointer_leave_overlays_impl(
1312            |overlay| {
1313                overlay
1314                    .pointer_leave_started_real
1315                    .map(|started| real_now.saturating_duration_since(started))
1316            },
1317            &mut *ops,
1318        );
1319    }
1320
1321    fn process_auto_dismiss_overlays(&mut self) {
1322        let sim_now = self.sim_clock;
1323        let mut noop = crate::window::NoopWindowOps;
1324        self.process_auto_dismiss_overlays_impl(
1325            |overlay| {
1326                overlay
1327                    .auto_dismiss_after
1328                    .map(|_| sim_now.saturating_duration_since(overlay.shown_at_sim))
1329            },
1330            &mut noop,
1331        );
1332    }
1333
1334    fn process_auto_dismiss_overlays_real(&mut self, ops: &mut dyn crate::window::WindowOps) {
1335        let real_now = std::time::Instant::now();
1336        self.process_auto_dismiss_overlays_impl(
1337            |overlay| {
1338                overlay
1339                    .auto_dismiss_after
1340                    .map(|_| real_now.saturating_duration_since(overlay.shown_at_real))
1341            },
1342            &mut *ops,
1343        );
1344    }
1345
1346    /// Drain overlays whose fade-out tween has completed (set up by
1347    /// `OverlayRequest::with_fade`). Same dormant-and-restore-focus
1348    /// flow as the normal dismiss path; called once per layout pass
1349    /// after `process_auto_dismiss_overlays_real` so an overlay that
1350    /// hits its auto-dismiss deadline kicks off its fade-out tween
1351    /// in the same pass.
1352    pub(crate) fn process_overlay_fade_dismissals_real(
1353        &mut self,
1354        ops: &mut dyn crate::window::WindowOps,
1355    ) {
1356        let now = std::time::Instant::now();
1357        let pending = self.overlay_manager.process_pending_fade_dismissals(now);
1358        for (_id, dismissed, focus_restore) in pending {
1359            self.dormant_dismissed_content(&dismissed, &mut *ops);
1360            if let Some(restore_id) = focus_restore
1361                && self.arena.is_active(restore_id)
1362            {
1363                self.focus_ops(restore_id, &mut *ops);
1364            }
1365        }
1366    }
1367
1368    /// Sim-clock variant for headless tests. Same shape as
1369    /// [`process_overlay_fade_dismissals_real`](Self::process_overlay_fade_dismissals_real)
1370    /// but reads `dismissing_started_sim`.
1371    pub(crate) fn process_overlay_fade_dismissals_sim(&mut self) {
1372        let mut noop = crate::window::NoopWindowOps;
1373        let pending = self
1374            .overlay_manager
1375            .process_pending_fade_dismissals_sim(self.sim_clock);
1376        for (_id, dismissed, focus_restore) in pending {
1377            self.dormant_dismissed_content(&dismissed, &mut noop);
1378            if let Some(restore_id) = focus_restore
1379                && self.arena.is_active(restore_id)
1380            {
1381                self.focus_ops(restore_id, &mut noop);
1382            }
1383        }
1384    }
1385
1386    fn process_auto_dismiss_overlays_impl(
1387        &mut self,
1388        elapsed_fn: impl Fn(&crate::overlay::ActiveOverlay) -> Option<std::time::Duration>,
1389        ops: &mut dyn crate::window::WindowOps,
1390    ) {
1391        let mut to_dismiss = Vec::new();
1392
1393        for overlay in self.overlay_manager.stack.iter().rev() {
1394            let Some(delay) = overlay.auto_dismiss_after else {
1395                continue;
1396            };
1397
1398            if to_dismiss
1399                .iter()
1400                .any(|ancestor| self.overlay_manager.is_descendant_of(overlay.id, *ancestor))
1401            {
1402                continue;
1403            }
1404
1405            if let Some(elapsed) = elapsed_fn(overlay)
1406                && elapsed >= delay
1407            {
1408                to_dismiss.push(overlay.id);
1409            }
1410        }
1411
1412        for overlay_id in to_dismiss {
1413            let (dismissed, focus_restore) =
1414                self.overlay_manager.dismiss_with_focus_restore(overlay_id);
1415            self.dormant_dismissed_content(&dismissed, &mut *ops);
1416            if let Some(restore_id) = focus_restore
1417                && self.arena.is_active(restore_id)
1418            {
1419                self.focus_ops(restore_id, &mut *ops);
1420            }
1421        }
1422    }
1423
1424    fn process_pointer_leave_overlays_impl(
1425        &mut self,
1426        elapsed_fn: impl Fn(&crate::overlay::ActiveOverlay) -> Option<std::time::Duration>,
1427        ops: &mut dyn crate::window::WindowOps,
1428    ) {
1429        // Retire safe regions whose budget is spent. `update_pointer_leave_overlays`
1430        // does this too, but only when the pointer moves — and a pointer parked
1431        // inside the cone generates no moves at all, so without this pass the
1432        // submenu would stay open indefinitely. Expiring here also starts the
1433        // ordinary grace, so the overlay closes `delay` later exactly as if the
1434        // triangle had never been armed.
1435        let real_now = std::time::Instant::now();
1436        let sim_now = self.sim_clock;
1437        let expired: Vec<crate::overlay::OverlayId> = self
1438            .overlay_manager
1439            .stack
1440            .iter()
1441            .filter(|overlay| overlay.safe_apex.is_some())
1442            .map(|overlay| overlay.id)
1443            .filter(|id| !self.safe_region_unexpired(*id, real_now, sim_now))
1444            .collect();
1445        let budget = crate::overlay::SAFE_REGION_BUDGET;
1446        for id in expired {
1447            if let Some(overlay) = self
1448                .overlay_manager
1449                .stack
1450                .iter_mut()
1451                .find(|overlay| overlay.id == id)
1452                && overlay.pointer_leave_started_real.is_none()
1453            {
1454                // Backdate to the instant the budget ran out: the
1455                // pointer stopped counting as "inside" then, not when a
1456                // frame got around to noticing. Otherwise the grace
1457                // silently restarts from zero and the total wait
1458                // depends on the frame rate.
1459                let backdate = |started: Option<std::time::Instant>, now: std::time::Instant| {
1460                    started
1461                        .and_then(|s| s.checked_add(budget))
1462                        .map(|deadline| deadline.min(now))
1463                        .unwrap_or(now)
1464                };
1465                overlay.pointer_leave_started_real =
1466                    Some(backdate(overlay.safe_apex_started_real, real_now));
1467                overlay.pointer_leave_started_sim =
1468                    Some(backdate(overlay.safe_apex_started_sim, sim_now));
1469            }
1470            self.overlay_manager.clear_safe_region(id);
1471        }
1472
1473        let mut to_dismiss = Vec::new();
1474
1475        for overlay in self.overlay_manager.stack.iter().rev() {
1476            let crate::overlay::DismissBehavior::PointerLeave { delay } = overlay.dismiss else {
1477                continue;
1478            };
1479
1480            if to_dismiss
1481                .iter()
1482                .any(|ancestor| self.overlay_manager.is_descendant_of(overlay.id, *ancestor))
1483            {
1484                continue;
1485            }
1486
1487            if let Some(elapsed) = elapsed_fn(overlay)
1488                && elapsed >= delay
1489            {
1490                to_dismiss.push(overlay.id);
1491            }
1492        }
1493
1494        for overlay_id in to_dismiss {
1495            let (dismissed, focus_restore) =
1496                self.overlay_manager.dismiss_with_focus_restore(overlay_id);
1497            self.dormant_dismissed_content(&dismissed, &mut *ops);
1498            if let Some(restore_id) = focus_restore
1499                && self.arena.is_active(restore_id)
1500            {
1501                self.focus_ops(restore_id, &mut *ops);
1502            }
1503        }
1504    }
1505
1506    pub fn with_theme(mut self, theme: Theme) -> Self {
1507        // Update both the cached `Theme` AND the reactive
1508        // `theme_signal` — widgets that observe the signal (e.g.
1509        // `TextInputField` resetting the rich-text engine's default
1510        // text colour) would otherwise see the constructor's
1511        // `light_default()` initial value forever, even when
1512        // `TeksiloAppBuilder.theme(crate::presets::intui::dark())` was used.
1513        // `set_theme` already does this; `with_theme` was the
1514        // builder-time analogue that forgot to keep them aligned.
1515        self.theme = theme.clone();
1516        self.theme_signal.set(theme);
1517        self.recompute_effective_theme();
1518        self
1519    }
1520
1521    pub fn with_text_backend(
1522        mut self,
1523        backend: Rc<RefCell<dyn teksilo_canvas::TextBackend>>,
1524    ) -> Self {
1525        self.text_backend = Some(backend);
1526        self
1527    }
1528
1529    /// Attach a platform host for custom window chrome. Set by the
1530    /// `WindowManager` when the application opts in via
1531    /// `WindowConfig::custom_chrome(true)`. Widgets like `TitleBar` retrieve
1532    /// it from inside the root-builder closure via [`Self::title_bar_host`].
1533    pub fn with_title_bar_host(mut self, host: Rc<dyn crate::PlatformTitleBarHost>) -> Self {
1534        self.title_bar_host = Some(host);
1535        self
1536    }
1537
1538    pub fn set_title_bar_host(&mut self, host: Rc<dyn crate::PlatformTitleBarHost>) {
1539        self.title_bar_host = Some(host);
1540    }
1541
1542    /// Get the platform title bar host, if one was attached. Returns `None`
1543    /// when the application did not opt into custom chrome, or when the
1544    /// platform does not support it (X11 without an EWMH-capable window
1545    /// manager, or a headless build).
1546    pub fn title_bar_host(&self) -> Option<Rc<dyn crate::PlatformTitleBarHost>> {
1547        self.title_bar_host.clone()
1548    }
1549
1550    pub fn theme(&self) -> &Theme {
1551        &self.theme
1552    }
1553
1554    /// Reactive handle on the current theme. Updates fire when `set_theme`
1555    /// is called; widgets that want theme-derived values to stay live should
1556    /// build derived signals via `theme_signal.map(...)` or combine with
1557    /// other inputs using `.zip(...)`.
1558    pub fn theme_signal(&self) -> &crate::signal::Signal<Theme> {
1559        &self.theme_signal
1560    }
1561
1562    /// Whether any widget needs layout or paint (i.e., a redraw would be useful).
1563    ///
1564    /// Uses `has_running` rather than `has_active` so that animations
1565    /// parked by the window-inactive gate stop forcing the event loop
1566    /// into `ControlFlow::WaitUntil`. Without this, an unfocused window
1567    /// would still wake at the animation frame interval and the
1568    /// pause would save nothing. Both the signal scheduler AND the
1569    /// shader-driven animated-quad registry are consulted — a
1570    /// ProgressBar::indeterminate whose widget has no pending paint
1571    /// dirt still needs the loop to keep waking at the animation
1572    /// frame interval so its phase advances.
1573    pub fn needs_redraw(&self) -> bool {
1574        self.arena.any_needs_layout()
1575            || self.arena.any_needs_paint()
1576            || self.animation_scheduler.has_running()
1577            || self.animated_quads.has_running()
1578            || self.frame_tick_requested.get()
1579    }
1580
1581    /// Whether a render pass is needed (any widget needs layout or paint).
1582    pub fn needs_render(&self) -> bool {
1583        self.arena.any_needs_layout() || self.arena.any_needs_paint()
1584    }
1585
1586    /// Whether this tree has reactive work that only a `layout()` pass can
1587    /// turn into arena dirt — i.e. whether reconciling it right now could
1588    /// change what [`needs_render`](Self::needs_render) reports.
1589    ///
1590    /// Read-only and cheap: `O(unique bound sources)` `u64` comparisons
1591    /// plus one peek per registered animated signal. No arena walk, no
1592    /// rebuilds, no geometry. Asking does not consume the answer, so it
1593    /// can be asked every dispatch.
1594    ///
1595    /// # Why this is exactly the right question, and no broader
1596    ///
1597    /// `teksilo_app::WindowManager::request_redraw_needing_render` exists
1598    /// for ONE case: a handler in window A wrote a `Signal` that window
1599    /// B's widgets also bind, and B — which never saw the event — must be
1600    /// reconciled before anyone can tell it needs repainting. Every OTHER
1601    /// thing `layout_with_ops` drives already has its own scheduling
1602    /// path and does not need this sweep:
1603    ///
1604    /// - tooltip dwell + sticky steps, delayed overlays, auto-dismiss,
1605    ///   overlay fades, the animation scheduler, animated quads,
1606    ///   gestures, `wake_at` and the 60 Hz frame tick are all timing
1607    ///   driven, and every one of them contributes to
1608    ///   [`next_timer_deadline`](Self::next_timer_deadline) — which
1609    ///   `request_redraw_due` polls to wake precisely the due windows;
1610    /// - drag ticks follow that window's own pointer stream;
1611    /// - a handler that called `request_rebuild` marked the arena
1612    ///   directly, so `needs_render()` is already true without any
1613    ///   reconcile.
1614    ///
1615    /// So the two terms below are what the sweep uniquely covers:
1616    /// binding-registry staleness (the whole point), and a *pending*
1617    /// `animate_to` — which the scheduler has not started yet, so it
1618    /// contributes no deadline, and which only `process_pending_animations`
1619    /// (inside `layout`) can promote into one. The second term is
1620    /// belt-and-braces: arming an animation also advances the signal's
1621    /// generation, so a bound animated signal is already covered by the
1622    /// first — but an animated signal registered without being bound
1623    /// would not be, and this makes that impossible to get wrong.
1624    pub fn needs_reconcile(&self) -> bool {
1625        self.binding_registry.any_dirty()
1626            || self
1627                .animated_values
1628                .iter()
1629                .any(AnimatedRegistration::has_pending_animation)
1630    }
1631
1632    /// Register a `Signal<f32>` for animation support. The framework
1633    /// checks registered signals each frame for pending `animate_to`
1634    /// requests. Called automatically by `BuildContext::animated_signal()`
1635    /// — `owner` is `ctx.self_id()` of the widget whose `build()` created
1636    /// the signal. Used by the scheduler to pause/cancel animations when
1637    /// the owning widget is offscreen, dormant, or destroyed.
1638    pub fn register_animated_signal(
1639        &mut self,
1640        signal: &crate::signal::Signal<f32>,
1641        owner: WidgetId,
1642    ) {
1643        self.animated_values
1644            .retain(|registration| registration.is_alive());
1645        if let Some(existing) = self
1646            .animated_values
1647            .iter_mut()
1648            .find(|registration| registration.same_signal(signal))
1649        {
1650            // Signal may have been registered earlier with a placeholder
1651            // owner (e.g. a widget field constructed pre-build and
1652            // re-registered during build()) — prefer the latest owner.
1653            existing.owner = owner;
1654            return;
1655        }
1656        if let Some(weak_signal) = signal.weak_handle() {
1657            self.animated_values.push(AnimatedRegistration {
1658                weak: weak_signal,
1659                owner,
1660            });
1661        }
1662    }
1663
1664    /// Whether any animation is currently running.
1665    pub fn has_active_animations(&self) -> bool {
1666        self.animation_scheduler.has_active()
1667    }
1668
1669    /// The clock a newly promoted animation must be stamped with: the same one
1670    /// the scheduler will later be ticked against.
1671    ///
1672    /// Normally the wall clock. But once [`tick_animations`](Self::tick_animations)
1673    /// has driven this tree, the scheduler is *only* ever ticked at
1674    /// [`Self::sim_clock`] — so an animation stamped `Instant::now()` is measured
1675    /// against a clock that may never reach its start. A headless test
1676    /// interleaving `layout()` (which promotes) with `tick_animations()` (which
1677    /// ticks) advances the two clocks independently: simulated time by whatever
1678    /// the test asks for, real time by however long the test actually takes. The
1679    /// moment real time overtakes simulated time, every animation armed from then
1680    /// on has a start in the scheduler's future and its progress **freezes** —
1681    /// not slowly, completely, and no number of further ticks recovers it.
1682    ///
1683    /// That made animated layout tests fail as a function of machine load rather
1684    /// than of behaviour: green run alone or on a couple of threads, red once the
1685    /// runner filled the cores and each test's wall-clock time stretched past the
1686    /// simulated time it was asking for. The overlay manager already keeps its
1687    /// real and simulated timestamps apart for this reason; animations now agree
1688    /// on one clock the same way.
1689    fn animation_clock(&self) -> std::time::Instant {
1690        if self.sim_driven {
1691            self.sim_clock
1692        } else {
1693            std::time::Instant::now()
1694        }
1695    }
1696
1697    /// Pick up pending `animate_to` requests from registered signals
1698    /// and start them on the animation scheduler.
1699    fn process_pending_animations(&mut self) {
1700        let now = self.animation_clock();
1701        self.process_pending_animations_at(now);
1702    }
1703
1704    /// Pick up pending animations using the given time (for sim clock).
1705    fn process_pending_animations_at(&mut self, now: std::time::Instant) {
1706        let mut pending = Vec::new();
1707        self.animated_values.retain(|registration| {
1708            if let Some(animation) = registration.take_pending_animation() {
1709                pending.push(animation);
1710                true
1711            } else {
1712                registration.is_alive()
1713            }
1714        });
1715
1716        for (signal, req, owner) in pending {
1717            if req.looping {
1718                let start = signal.get();
1719                self.animation_scheduler.animate_looping(
1720                    &signal,
1721                    owner,
1722                    start,
1723                    req.target,
1724                    req.duration,
1725                    req.easing,
1726                    req.frame_interval,
1727                    req.epsilon,
1728                    req.max_duration,
1729                    now,
1730                );
1731            } else {
1732                self.animation_scheduler.animate_with_options(
1733                    &signal,
1734                    owner,
1735                    req.target,
1736                    req.duration,
1737                    req.easing,
1738                    req.frame_interval,
1739                    req.epsilon,
1740                    req.max_duration,
1741                    now,
1742                );
1743            }
1744        }
1745    }
1746
1747    /// Mark the owning window as active (focused AND not occluded) or
1748    /// inactive. Propagates to the animation scheduler AND the
1749    /// animated-quad registry so both pause-resume in lockstep — no
1750    /// ticks, no frame wakes, no GPU submits.
1751    ///
1752    /// On an actual state change it also fires `window_active_signal`
1753    /// (so build-time binders and `DimWhenInactive` react) and issues a
1754    /// global paint-only dirty mark, so every widget that reads
1755    /// `PaintContext::window_active` (caret gates, selection bands) repaints
1756    /// once. This is a repaint, not a relayout — geometry never changes when
1757    /// the window's active state flips (the caret keeps its space). Window
1758    /// focus changes are rare and user-driven, so the O(n) mark is cheap and
1759    /// Mark every node paint-dirty (no relayout, no rebuild) so the next
1760    /// render re-runs their `paint()`. This is the paint-cache invalidation an
1761    /// off-thread source needs after posting a [`RepaintWindowRequest`](crate::RepaintWindowRequest):
1762    /// a bare redraw request re-presents the cached frame, so a widget whose
1763    /// content changed off the UI thread (a terminal's PTY output) must be
1764    /// marked dirty for its `paint()` to run again.
1765    pub fn mark_all_needs_paint_only(&mut self) {
1766        self.arena.mark_all_needs_paint_only();
1767    }
1768
1769    /// strictly lighter than `set_theme`'s `mark_all_dirty` (layout + paint).
1770    pub fn set_window_active(&mut self, active: bool) {
1771        let now = std::time::Instant::now();
1772        self.animation_scheduler.set_window_active(active, now);
1773        self.animated_quads.set_window_active(active, now);
1774        if self.window_active_signal.get() != active {
1775            self.window_active_signal.set(active);
1776            self.arena.mark_all_needs_paint_only();
1777            if !active {
1778                // The pointer has left for another window; the OS sends no
1779                // leave event we can rely on, so a tooltip shown at the moment
1780                // of the switch would float over the newly-focused window's
1781                // chrome with nothing left to dismiss it. Retire tips and
1782                // cancel pending dwells — but leave *sticky* ones, which the
1783                // user pinned deliberately and expects to find on return.
1784                self.tooltip_window_deactivated();
1785                // Same reasoning for a held pointer: a widget that captured
1786                // the pointer for a drag (a column-resize grip, a splitter
1787                // divider, a scrollbar thumb, a slider) will never see the
1788                // matching PointerUp — the user releases the button over the
1789                // window that took focus, and this window is told nothing.
1790                // Capture is otherwise cleared only by that Up or by the
1791                // widget going inactive, so leaving it set strands the whole
1792                // window: every subsequent PointerMove is redelivered to the
1793                // abandoned widget instead of hit-testing (killing hover,
1794                // cursor shapes and tooltips everywhere else), and the next
1795                // click's Up is swallowed by it, so the first press on any
1796                // release-activated control silently does nothing.
1797                self.pointer_captured_by = None;
1798            }
1799        }
1800    }
1801
1802    /// Whether the owning window is currently active (`focused AND not
1803    /// occluded`). The reactive companion is [`Self::window_active_signal`].
1804    pub fn is_window_active(&self) -> bool {
1805        self.window_active_signal.get()
1806    }
1807
1808    /// Reactive handle on window-active state. Fires when the window gains or
1809    /// loses active status. Bind at [`BindingLevel::RepaintOnly`] — an
1810    /// active-state flip never affects geometry. Starts `true`.
1811    ///
1812    /// [`BindingLevel::RepaintOnly`]: crate::binding::BindingLevel::RepaintOnly
1813    pub fn window_active_signal(&self) -> crate::signal::Signal<bool> {
1814        self.window_active_signal.clone()
1815    }
1816
1817    /// Register a new animated quad for the currently-building widget.
1818    /// Called by [`crate::build_context::BuildContext::animated_quad`];
1819    /// returns an opaque handle the widget stashes for its `paint()`
1820    /// call.
1821    pub fn register_animated_quad(
1822        &mut self,
1823        owner: WidgetId,
1824        kind: crate::animated_quad::AnimatedQuadKind,
1825    ) -> crate::animated_quad::AnimatedQuadHandle {
1826        self.animated_quads
1827            .register(owner, kind, std::time::Instant::now())
1828    }
1829
1830    /// Active animated-quad slot count. Test / debug helper.
1831    pub fn animated_quad_count(&self) -> usize {
1832        self.animated_quads.active_count()
1833    }
1834
1835    /// Advance time-driven gesture recognizers (currently only
1836    /// [`crate::gesture::LongPressRecognizer`]) across every widget that
1837    /// has a gesture arena. Must be called by the event loop on each
1838    /// wake-up; otherwise long-press will never fire during an idle hold.
1839    ///
1840    /// When a recognizer transitions to `Recognized`, the corresponding
1841    /// handler on the owning widget is invoked with a fresh
1842    /// [`EventContext`], and any commands / overlay requests it emits are
1843    /// collected through the normal post-event path.
1844    pub fn tick_gestures(&mut self, now: std::time::Instant) {
1845        let mut noop = crate::window::NoopWindowOps;
1846        self.tick_gestures_with_ops(now, &mut noop);
1847    }
1848
1849    /// App-facing variant of [`tick_gestures`](Self::tick_gestures)
1850    /// that accepts a real [`WindowOps`](crate::window::WindowOps)
1851    /// sink so gesture-recognized handlers can call the multi-window
1852    /// API synchronously.
1853    pub fn tick_gestures_with_ops(
1854        &mut self,
1855        now: std::time::Instant,
1856        ops: &mut dyn crate::window::WindowOps,
1857    ) {
1858        // Snapshot the gesture-owners set into the reusable scratch.
1859        // Previously this iterated every active widget; in practice
1860        // only a tiny fraction carry a gesture arena, so visiting the
1861        // rest was pure overhead.
1862        // `mem::take` lets the loop borrow `&mut self` for
1863        // `make_event_context` etc. without conflicting with the
1864        // scratch buffer; we put the storage back at the end.
1865        let mut ids = std::mem::take(&mut self.active_ids_scratch);
1866        ids.clear();
1867        ids.extend(
1868            self.gesture_owners
1869                .iter()
1870                .copied()
1871                .filter(|id| self.arena.is_active(*id)),
1872        );
1873        for &id in &ids {
1874            let gesture = match self.arena.get_mut(id) {
1875                Some(node) => node
1876                    .handlers
1877                    .gesture_arena
1878                    .as_mut()
1879                    .and_then(|arena| arena.tick(now)),
1880                None => None,
1881            };
1882            let Some(gesture) = gesture else { continue };
1883
1884            let mut ctx = self.make_event_context(&mut *ops);
1885            if let Some(node) = self.arena.get_mut(id) {
1886                Self::dispatch_recognized_gesture(node, gesture, &mut ctx);
1887            }
1888            self.collect_from_ctx(ctx, id);
1889            self.arena.mark_needs_paint(id);
1890        }
1891        self.active_ids_scratch = ids;
1892    }
1893
1894    /// Earliest wall-clock deadline at which any active gesture arena
1895    /// needs [`WidgetTree::tick_gestures`] called — typically a pending
1896    /// long-press timeout. Returns `None` when no recognizer is waiting.
1897    pub fn next_gesture_deadline(&self) -> Option<std::time::Instant> {
1898        // Iterate just the widgets that actually carry a gesture arena.
1899        // `filter` for `is_active` skips dormant entries that may still
1900        // be in the set after a hide-without-detach.
1901        self.gesture_owners
1902            .iter()
1903            .copied()
1904            .filter(|id| self.arena.is_active(*id))
1905            .filter_map(|id| self.arena.get(id))
1906            .filter_map(|node| node.handlers.gesture_arena.as_ref())
1907            .filter_map(|arena| arena.next_deadline())
1908            .min()
1909    }
1910
1911    /// Advance animations by simulated time (for deterministic testing).
1912    /// Pending `animate_to` requests are started at the current sim_clock,
1913    /// then time advances by `duration`, and the scheduler ticks at the new time.
1914    pub fn tick_animations(&mut self, duration: std::time::Duration) {
1915        // From here on this tree is simulation-driven: `layout` must stamp the
1916        // animations it promotes with `sim_clock` too, or they are measured
1917        // against a clock that never reaches them. See `animation_clock`.
1918        self.sim_driven = true;
1919        self.process_pending_animations_at(self.sim_clock);
1920
1921        self.sim_clock += duration;
1922        // Mirror onto the overlay manager so any fade-out tween
1923        // started during this tick stamps its sim-time start in
1924        // lockstep with real time.
1925        self.overlay_manager.set_sim_clock(self.sim_clock);
1926
1927        if self.frame_tick_requested.get() {
1928            self.frame_tick_requested.set(false);
1929            let delta = duration.as_secs_f32().clamp(0.0, 0.1);
1930            self.frame_tick.set(delta);
1931        }
1932
1933        self.animation_scheduler
1934            .tick(self.sim_clock, &self.arena, self.paint_epoch);
1935
1936        // Simulated-time test helper — use NoopWindowOps; tests that
1937        // need a real sink call layout_with_ops / dispatch_event_with_ops
1938        // themselves.
1939        let mut noop = crate::window::NoopWindowOps;
1940        self.process_state_changes(&mut noop);
1941    }
1942
1943    /// Switch the tree-level theme at runtime.
1944    ///
1945    /// Updates `theme_signal` (a reactive `Signal<Theme>`) and marks all widgets
1946    /// dirty for relayout and repaint. Widgets are **not** rebuilt: the
1947    /// `LayoutContext` and `PaintContext` already resolve the current theme on
1948    /// every pass, and any widget that derives state from theme tokens should
1949    /// do so through a `theme_signal()` subscription rather than a build-time
1950    /// capture. Preserves focus, scroll offsets, and other interaction state.
1951    pub fn set_theme(&mut self, theme: Theme) {
1952        self.theme = theme.clone();
1953        self.theme_signal.set(theme);
1954        self.recompute_effective_theme();
1955        self.arena.mark_all_dirty();
1956        // A theme change re-resolves typography, so every label re-shapes —
1957        // inside bounds the layout pass may leave untouched, which means no
1958        // resize is recorded and nothing else would dirty the tree. The
1959        // label's lines, and therefore its text runs, can be a different
1960        // set at the same size.
1961        self.a11y_dirty = true;
1962    }
1963
1964    /// Recompute [`Self::effective_theme`] from the current `theme` and the
1965    /// combined text scale (`user_text_scale * text_scale_factor`). Callers
1966    /// that change either input are responsible for `mark_all_dirty()`.
1967    fn recompute_effective_theme(&mut self) {
1968        let combined = (self.user_text_scale as f64 * self.text_scale_factor) as f32;
1969        self.effective_theme = if (combined - 1.0).abs() < f32::EPSILON {
1970            self.theme.clone()
1971        } else {
1972            let mut t = self.theme.clone();
1973            t.typography = t.typography.scaled(combined);
1974            t
1975        };
1976        // Single source: every downstream consumer (the layout/paint context
1977        // `text_scale` field, the reactive `text_scale_signal`) reads from here.
1978        self.effective_text_scale = combined;
1979        self.text_scale_signal.set(combined);
1980    }
1981
1982    /// The combined effective text scale (`user_text_scale * OS text_scale_factor`).
1983    /// Read by the layout/paint walkers to populate `ctx.text_scale` for widgets
1984    /// that size from a source other than `Theme.typography`.
1985    pub fn effective_text_scale(&self) -> f32 {
1986        self.effective_text_scale
1987    }
1988
1989    /// Reactive handle on [`Self::effective_text_scale`]. Build-time binders that
1990    /// must react to a scale change without their own rebuild path bind this
1991    /// (e.g. `Calendar` binds it at `Rebuild` level so its fixed cell constants
1992    /// recompute). Fires on `set_user_text_scale` / theme / OS-pref change.
1993    pub fn text_scale_signal(&self) -> crate::signal::Signal<f32> {
1994        self.text_scale_signal.clone()
1995    }
1996
1997    /// Set the user-controlled global text-scale factor (`1.0` = 100 %).
1998    ///
1999    /// The factor multiplies with the OS accessibility text-scale preference to
2000    /// produce the rendered scale. Recomputes the effective theme and marks all
2001    /// widgets dirty so every text widget grows on the next pass; no rebuild,
2002    /// so focus/scroll/interaction state survive. Values outside `[0.25, 8.0]`
2003    /// are clamped. Persisted by the application via
2004    /// `teksilo_settings::TEXT_SCALE_KEY`.
2005    pub fn set_user_text_scale(&mut self, factor: f32) {
2006        let clamped = factor.clamp(0.25, 8.0);
2007        if (self.user_text_scale - clamped).abs() < f32::EPSILON {
2008            return;
2009        }
2010        self.user_text_scale = clamped;
2011        self.recompute_effective_theme();
2012        self.arena.mark_all_dirty();
2013        // Same reasoning as `set_theme`: bigger text re-wraps inside a box
2014        // whose size the parent may hold fixed.
2015        self.a11y_dirty = true;
2016    }
2017
2018    /// The current user-controlled text-scale factor (`1.0` = 100 %).
2019    pub fn user_text_scale(&self) -> f32 {
2020        self.user_text_scale
2021    }
2022
2023    /// After a rebuild that destroyed subtrees — or after a `visible_when` /
2024    /// `Switcher` pass parks the focused widget dormant — drop any interaction
2025    /// state (focus, hover) whose target `WidgetId` is no longer active.
2026    ///
2027    /// **FocusLost is load-bearing.** Clearing `self.focused` alone leaves the
2028    /// widget's own `on_focus` / `has_focus` / caret-blink state thinking it is
2029    /// still focused. A rich-text editor in that state keeps scheduling
2030    /// `wake_at` caret toggles and re-arming `frame_request` from its tick
2031    /// effect — and because `frame_tick` observers are **not** gated on
2032    /// dormancy, every open tab's editor (TabWidget mounts them all) still runs
2033    /// on those wakes. Rapid tab switches that park a focused editor without a
2034    /// real focus move (programmatic selection, race with pointer focus) used
2035    /// to accumulate stuck "focused" editors and unbounded frame work. Dispatch
2036    /// `FocusLost` first so widgets clear that state, then drop the tree's
2037    /// focus pointer.
2038    ///
2039    /// Preserves state when the target still exists *and* is active. Called from
2040    /// data-driven rebuild paths (`process_state_changes`) and after every
2041    /// rebuild drain; theme and locale switches no longer rebuild.
2042    pub(crate) fn revalidate_interaction_state(&mut self, ops: &mut dyn crate::window::WindowOps) {
2043        if let Some(id) = self.focused
2044            && !self.arena.is_active(id)
2045        {
2046            let old = self.focused;
2047            // Deliver FocusLost while the node still exists (dormant or about to
2048            // be torn down). Skip if the node is already gone — destroy paths
2049            // take care of bookkeeping without a deliverable target.
2050            if self.arena.get(id).is_some() {
2051                // Direct: no bubble through dormant ancestors, no overlay
2052                // dismiss side-effects — this is a teardown signal, not a
2053                // user-driven focus move.
2054                self.dispatch_to_widget_direct(
2055                    id,
2056                    &crate::event::WidgetEvent::FocusLost,
2057                    &mut *ops,
2058                );
2059            }
2060            self.set_focused(None);
2061            self.focus_origin = None;
2062            self.update_focus_within_signals(old, None);
2063            self.update_view_focus_signals(old, None);
2064            self.a11y_dirty = true;
2065        }
2066        if self.focused.is_none() {
2067            self.focus_origin = None;
2068        }
2069        if let Some(id) = self.hovered
2070            && !self.arena.is_active(id)
2071        {
2072            let old = self.hovered;
2073            self.set_hovered(None);
2074            self.update_hover_within_signals(old, None);
2075        }
2076        // Pointer capture anchored at a destroyed widget would otherwise
2077        // swallow every subsequent Move/Up — dispatch_to_widget rejects
2078        // inactive targets. Drop the capture so events resume normal
2079        // hit-test dispatch. Same for any in-flight drag session whose
2080        // source was torn down: the user sees the drag "stick".
2081        if let Some(id) = self.pointer_captured_by
2082            && !self.arena.is_active(id)
2083        {
2084            self.pointer_captured_by = None;
2085        }
2086        // External (OS) drags have no in-app source widget, so they are never
2087        // torn down by source destruction — only internal drags are salvaged.
2088        let source_gone = self
2089            .active_drag
2090            .as_ref()
2091            .and_then(|s| s.source_widget)
2092            .is_some_and(|sw| !self.arena.is_active(sw));
2093        if source_gone {
2094            // `cancel_active_drag` fires on_drag_leave on the current
2095            // target before cleanup — the same contract as Escape.
2096            self.cancel_active_drag(&mut *ops);
2097        } else {
2098            // The drag *source* survived but its current hover **target** was
2099            // torn down by the rebuild (e.g. a side disabled / collapsed
2100            // mid-drag destroyed the panel under the pointer). Clear the stale
2101            // target id so a subsequent drop doesn't resolve to a destroyed
2102            // widget (and silently vanish); the next move — or the drop's own
2103            // re-hit-test — re-engages a live target.
2104            let stale_target = self
2105                .active_drag
2106                .as_ref()
2107                .and_then(|d| d.current_target)
2108                .is_some_and(|t| !self.arena.is_active(t));
2109            if stale_target && let Some(drag) = self.active_drag.as_mut() {
2110                drag.current_target = None;
2111            }
2112        }
2113    }
2114
2115    /// Rebuild a single composite widget: destroy old children, re-run `build()`,
2116    /// and wire up new children. Called from `process_state_changes()` when a
2117    /// binding at `BindingLevel::Rebuild` fires (data-driven rebuild). Theme
2118    /// and locale changes do **not** rebuild — they update reactive signals
2119    /// that widgets bind to via `theme_signal()` / `locale_signal()`.
2120    /// Test-only: force-mark a widget for rebuild on the next layout
2121    /// pass. Lets regression tests exercise the rebuild path without
2122    /// needing to trip a Signal binding. Exposed cross-crate (not
2123    /// `#[cfg(test)]`-gated) so widget-crate tests in `teksilo-widgets`
2124    /// and elsewhere can also drive rebuilds; the `_for_testing`
2125    /// suffix marks it as not intended for application code.
2126    pub fn arena_mark_needs_rebuild_for_testing(&mut self, id: WidgetId) {
2127        self.arena.mark_needs_rebuild(id);
2128    }
2129
2130    /// Force a [`DeferredSubtree`](crate::deferred_subtree::DeferredSubtree) at
2131    /// `id` to build its content now. A no-op for any other widget.
2132    ///
2133    /// The framework's own door into deferred content, for the case where the
2134    /// decision to show is the tree's rather than a widget's: a tooltip whose
2135    /// dwell has just matured has no open signal anyone could have handed over.
2136    pub(crate) fn materialize_deferred(&mut self, id: WidgetId) {
2137        let forced = self
2138            .arena
2139            .get_mut(id)
2140            .and_then(|n| n.widget.as_any_mut())
2141            .and_then(|any| any.downcast_mut::<crate::deferred_subtree::DeferredSubtree>())
2142            .map(|deferred| {
2143                let needed = !deferred.is_materialized();
2144                deferred.force();
2145                needed
2146            })
2147            .unwrap_or(false);
2148        if forced {
2149            self.rebuild_single_widget(id);
2150        }
2151    }
2152
2153    pub(crate) fn rebuild_single_widget(&mut self, widget_id: WidgetId) {
2154        // Per §9.4.5, drop the source handle first (stops further source-side
2155        // dispatch) and then remove the UI-side callback. Either order gives
2156        // the same user-visible outcome for events that get posted between
2157        // the two steps, but dropping the source handle first stops the
2158        // publisher thread's work sooner.
2159        //
2160        // ⚠ That reasoning is about the two steps below, and it used to be
2161        // read as covering the whole problem. It does not. The dangerous gap
2162        // is not the microseconds between these two lines, it is the whole
2163        // span from *publish* to *dispatch*: a backend event is posted with
2164        // the id its publisher captured and is handled by the UI thread
2165        // frames later, so any rebuild in between used to strand it. The ids
2166        // are therefore carried across into the new build (see
2167        // `BuildContext::reusable_sub_ids`) rather than being retired here.
2168        // Skribisto's Analysis pane hit this every time it was the restored
2169        // view at project open: it starts a long operation in `build()` and
2170        // sets its own `Rebuild`-bound state signal, the operation finished
2171        // inside its own rebuild, and the pane sat on "Reading the
2172        // manuscript…" for the rest of the session with nothing logged.
2173        // Cancel any looping/one-shot animations owned by this widget
2174        // before build() runs. Without this, a widget that creates a
2175        // fresh `animated_signal` in build() would leak the previous
2176        // instance's scheduler entry: the old Signal<f32> clone lives
2177        // in `animations` forever, ticking against an orphaned signal
2178        // (silent CPU waste) and, for looping animations, doubling up
2179        // when the new one registers.
2180        self.animation_scheduler.cancel_by_widget(widget_id);
2181        // Same pattern for shader-driven animated quads: free the
2182        // widget's slot(s) so `build()` can allocate fresh handles.
2183        // The old cached_paint (if any) carries stale slot indices —
2184        // clear it so paint() re-runs and re-emits DrawCommands with
2185        // the newly-allocated slot.
2186        self.animated_quads.cancel_by_widget(widget_id);
2187
2188        let drained_subs = if let Some(node) = self.arena.get_mut(widget_id) {
2189            node.effect_handles.clear();
2190            node.actions.clear();
2191            node.dirty.needs_rebuild = false;
2192            node.cached_paint = None;
2193            node.dirty.needs_paint = true;
2194            // Reset only the OWN handler bucket so `apply_self_handlers`
2195            // during this build's fresh build() starts from empty and
2196            // doesn't stack N-fold handler chains across rebuilds.
2197            // `external_handlers` — set by the `WidgetBuilder` chain at
2198            // creation time or by a composing parent's
2199            // `apply_handlers(child_id, ...)` — persists: those handlers
2200            // come from outside the widget and aren't re-emitted by its
2201            // own `build()`.
2202            //
2203            // `node_focusable` / `node_tab_index` / `node_cursor` /
2204            // `clips_children` / `context_menu_factory` are simple
2205            // values, not accumulating closures. Leave them alone —
2206            // apply_self_handlers rewrites them if the new build
2207            // specifies non-None values; otherwise values from the
2208            // creation site survive the rebuild.
2209            node.handlers = crate::event_handlers::EventHandlers::new();
2210            std::mem::take(&mut node.subscription_handles)
2211        } else {
2212            Vec::new()
2213        };
2214        // Rebuild wiped the OWN handler bucket above (the gesture arena
2215        // lived there), so the widget no longer owns any recognizers.
2216        // The next pointer hit re-runs `ensure_gesture_arena` and
2217        // re-inserts if the new build still wires gesture handlers.
2218        // External handlers (set via the builder chain at creation time)
2219        // persist, but `external_handlers` never carries a gesture arena
2220        // directly — it's always built by `ensure_gesture_arena` into
2221        // the OWN bucket.
2222        self.gesture_owners.remove(&widget_id);
2223        // Shortcuts the widget declared are torn down too — they will
2224        // be re-registered during the upcoming `build()` call. User
2225        // overrides live in a separate map keyed by id, so user
2226        // rebindings survive this round-trip (see ShortcutRegistry
2227        // graveyard semantics).
2228        self.shortcut_registry.unregister_all_for_owner(widget_id);
2229        self.global_actions.retain(|(owner, _)| *owner != widget_id);
2230        self.text_surfaces.remove(widget_id);
2231        // Re-apply `Widget::declare_shortcuts` so the static metadata
2232        // survives the rebuild (the unregister above wiped both
2233        // declared and build-registered entries; build() will refill
2234        // the handler-bearing ones, but it can't be relied on to
2235        // refill the metadata-only declarations).
2236        self.apply_declared_shortcuts(widget_id);
2237        // Drop any signal→widget bindings from the previous build
2238        // cycle so `build()` can re-register a fresh set without
2239        // accumulating duplicates across rebuilds.
2240        self.binding_registry.unregister_for_widget(widget_id);
2241        // Kept, in order, and handed to the upcoming `build()` so it re-subscribes under
2242        // the same ids. A subscription's id is what a publisher captured and posted with;
2243        // minting new ones here would leave every event already queued for this widget
2244        // naming an id nothing answers to. See `BuildContext::reusable_sub_ids`.
2245        let mut reusable_sub_ids = Vec::with_capacity(drained_subs.len());
2246        for (sub_id, handle) in drained_subs {
2247            drop(handle);
2248            self.app_context
2249                .subscription_callbacks
2250                .borrow_mut()
2251                .remove(&sub_id);
2252            self.app_context
2253                .subscription_ctx_callbacks
2254                .borrow_mut()
2255                .remove(&sub_id);
2256            reusable_sub_ids.push(sub_id);
2257        }
2258
2259        // Decide how to treat the existing children. Two modes:
2260        //
2261        // * Default (`preserves_children_on_rebuild() == false`): the widget
2262        //   re-derives its whole subtree, so tear down every old child up
2263        //   front and let `build()` produce a fresh set.
2264        //
2265        // * Reconcile (`preserves_children_on_rebuild() == true`): the widget
2266        //   re-attaches the children it keeps (by id) and drops the rest. We
2267        //   snapshot the old children, run `build()`, then destroy only the
2268        //   old children the new build did NOT re-attach and did NOT re-parent
2269        //   elsewhere. Re-attached children keep their state (focus, scroll,
2270        //   text, subscriptions); dropped children are reaped rather than left
2271        //   as stranded, still-active orphans.
2272        let preserve_children = self
2273            .arena
2274            .get(widget_id)
2275            .map(|n| n.widget.preserves_children_on_rebuild())
2276            .unwrap_or(false);
2277        let old_children: Vec<WidgetId> = self.arena.children(widget_id).to_vec();
2278        if !preserve_children {
2279            for child_id in &old_children {
2280                self.destroy_subtree(*child_id);
2281            }
2282        }
2283
2284        // The parentless nodes the *previous* build owned. Taken now so
2285        // `build()` records its new set into an empty list, and destroyed after
2286        // it returns — by then the widget's own fields point at the new nodes,
2287        // so tearing the old ones down cannot strand a live id in the widget.
2288        // Both the `preserve_children` reconcile and the plain path want this:
2289        // detached content is rebuilt wholesale either way (it is not addressed
2290        // by id from the outside, so there is nothing to preserve).
2291        let old_detached: Vec<WidgetId> = self
2292            .arena
2293            .get_mut(widget_id)
2294            .map(|node| std::mem::take(&mut node.detached))
2295            .unwrap_or_default();
2296
2297        let mut widget_box = match self.arena.take_widget(widget_id) {
2298            Some(widget) => widget,
2299            None => return,
2300        };
2301
2302        let mut build_ctx = crate::build_context::BuildContext {
2303            tree: self,
2304            composite_id: Some(widget_id),
2305            effect_handles: Vec::new(),
2306            subscription_handles: Vec::new(),
2307            reusable_sub_ids,
2308        };
2309        let new_children = widget_box.build(&mut build_ctx);
2310        let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
2311        let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);
2312
2313        self.arena.restore_widget(widget_id, widget_box);
2314
2315        for &child_id in &new_children {
2316            if let Some(child_node) = self.arena.get_mut(child_id) {
2317                child_node.parent = Some(widget_id);
2318            }
2319        }
2320
2321        // Reconcile the preserve path: reap any old child the new build
2322        // dropped (not in `new_children`) and did not re-parent elsewhere
2323        // (its `parent` still points here). Authoritative parent pointers mean
2324        // a kept subtree re-parented out of a dropped sibling survives. Runs
2325        // before `node.children` is overwritten so the destroy walk can't see
2326        // the new list. Re-parented survivors already have their new parent by
2327        // now (`ctx.add` builds nested widgets synchronously and re-homes their
2328        // children), so the `parent == widget_id` test correctly excludes them.
2329        if preserve_children {
2330            let new_set: std::collections::HashSet<WidgetId> =
2331                new_children.iter().copied().collect();
2332            for &old_c in &old_children {
2333                if !new_set.contains(&old_c) && self.arena.parent(old_c) == Some(widget_id) {
2334                    self.destroy_subtree_inner(old_c, true);
2335                }
2336            }
2337        }
2338
2339        if let Some(node) = self.arena.get_mut(widget_id) {
2340            node.children = new_children;
2341            node.effect_handles = effect_handles;
2342            node.subscription_handles = subscription_handles;
2343        }
2344
2345        // Reap the previous build's parentless content, now that the fresh set
2346        // is recorded and the widget points at it.
2347        for id in old_detached {
2348            self.destroy_subtree_inner(id, false);
2349        }
2350    }
2351
2352    /// Record that `owner` created and owns the parentless node `detached` —
2353    /// pre-built overlay content that is deliberately not a child. See
2354    /// [`BuildContext::add_detached`](crate::build_context::BuildContext::add_detached)
2355    /// and [`WidgetNode::detached`](crate::arena::WidgetNode).
2356    pub(crate) fn record_detached(&mut self, owner: WidgetId, detached: WidgetId) {
2357        if let Some(node) = self.arena.get_mut(owner) {
2358            node.detached.push(detached);
2359        }
2360    }
2361
2362    /// Destroy every parentless node `owner` owns, and forget them.
2363    ///
2364    /// Taken out of the node first: the destroy walk below can re-enter this
2365    /// function (a detached node may own detached nodes of its own — a rich
2366    /// tooltip's cascade children each pre-build their own), and it must not
2367    /// see a list it is halfway through consuming.
2368    fn destroy_detached_of(&mut self, owner: WidgetId) {
2369        let detached = self
2370            .arena
2371            .get_mut(owner)
2372            .map(|node| std::mem::take(&mut node.detached))
2373            .unwrap_or_default();
2374        for id in detached {
2375            self.destroy_subtree_inner(id, false);
2376        }
2377    }
2378
2379    /// Recursively destroy a subtree, dropping per-widget subscription
2380    /// handles and removing their UI-side callbacks. Use this in place of
2381    /// `arena.destroy()` whenever a widget that may have subscribed to
2382    /// events is being torn down.
2383    pub(crate) fn destroy_subtree(&mut self, widget_id: WidgetId) {
2384        self.destroy_subtree_inner(widget_id, false);
2385    }
2386
2387    /// Shared teardown for [`destroy_subtree`](Self::destroy_subtree) and the
2388    /// reconciling rebuild path. When `reparent_aware` is `true`, recursion
2389    /// descends into a child only if that child's `parent` still points at
2390    /// `widget_id`.
2391    ///
2392    /// A reconciling rebuild (a [`preserves_children_on_rebuild`] widget) may
2393    /// re-parent a kept subtree *out* of a dropped sibling and *into* the new
2394    /// tree. The dropped sibling's `children` list still lists that subtree
2395    /// (stale), so following it would tear down a node that is actually alive
2396    /// elsewhere. Following the authoritative `parent` pointer instead stops
2397    /// at the boundary of what genuinely still belongs to the node being
2398    /// destroyed. The per-node teardown ends with `arena.remove_node` (a
2399    /// single-node removal), NOT `arena.destroy` (which would re-recurse the
2400    /// stale `children` list and undo the skip).
2401    ///
2402    /// [`preserves_children_on_rebuild`]: crate::widget::Widget::preserves_children_on_rebuild
2403    fn destroy_subtree_inner(&mut self, widget_id: WidgetId, reparent_aware: bool) {
2404        // See the matching cancel in `rebuild_single_widget` — the
2405        // scheduler holds strong Signal<f32> clones, so the animation
2406        // would outlive its widget without this explicit cancellation.
2407        self.animation_scheduler.cancel_by_widget(widget_id);
2408        // Release the animated-quad slot(s) too.
2409        self.animated_quads.cancel_by_widget(widget_id);
2410        // A tooltip's content widget is parentless (`ctx.add`), so the child
2411        // walk below never reaches it — reap it explicitly or the entry and
2412        // its node outlive the anchor for the lifetime of the tree.
2413        self.retire_tooltips_of_destroyed_anchor(widget_id);
2414        // Same reasoning, one level up: every *other* parentless node this
2415        // widget built (a dropdown menu, a calendar, a tooltip's nested
2416        // cascade children) is unreachable from the child walk and dies here
2417        // or never.
2418        self.destroy_detached_of(widget_id);
2419
2420        let children: Vec<WidgetId> = self.arena.children(widget_id).to_vec();
2421        for child in children {
2422            if reparent_aware && self.arena.parent(child) != Some(widget_id) {
2423                // Re-parented into the surviving tree by this rebuild — leave it.
2424                continue;
2425            }
2426            self.destroy_subtree_inner(child, reparent_aware);
2427        }
2428        let drained_subs = self
2429            .arena
2430            .get_mut(widget_id)
2431            .map(|node| std::mem::take(&mut node.subscription_handles))
2432            .unwrap_or_default();
2433        for (sub_id, handle) in drained_subs {
2434            drop(handle);
2435            self.app_context
2436                .subscription_callbacks
2437                .borrow_mut()
2438                .remove(&sub_id);
2439            self.app_context
2440                .subscription_ctx_callbacks
2441                .borrow_mut()
2442                .remove(&sub_id);
2443        }
2444        // Drop any shortcuts the destroyed widget owned. Unlike
2445        // `rebuild_single_widget`, destruction is permanent; if the
2446        // user had overrides, they stay in the graveyard.
2447        self.shortcut_registry.unregister_all_for_owner(widget_id);
2448        self.global_actions.retain(|(owner, _)| *owner != widget_id);
2449        self.text_surfaces.remove(widget_id);
2450        // Bindings from this widget stop being relevant; clean them
2451        // up so the registry doesn't leak dead entries for the
2452        // lifetime of the app.
2453        self.binding_registry.unregister_for_widget(widget_id);
2454        // Keep `gesture_owners` honest — destroying the widget tears
2455        // down its handlers, so the per-frame gesture pass must stop
2456        // visiting it.
2457        self.gesture_owners.remove(&widget_id);
2458        // If focus pointed at the widget about to disappear, drop it
2459        // so later dispatch doesn't anchor intent walks at a dead id
2460        // (which would silently swallow the intent).
2461        if self.focused == Some(widget_id) {
2462            let old = self.focused;
2463            self.set_focused(None);
2464            self.focus_origin = None;
2465            self.update_focus_within_signals(old, None);
2466            self.update_view_focus_signals(old, None);
2467        }
2468        if self.hovered == Some(widget_id) {
2469            let old = self.hovered;
2470            self.set_hovered(None);
2471            self.update_hover_within_signals(old, None);
2472        }
2473        // Symmetric with focus/hover above: a pointer capture anchored at the
2474        // widget about to disappear would otherwise swallow every subsequent
2475        // Move/Up (dispatch rejects inactive targets) until the next layout
2476        // pass runs `revalidate_interaction_state`. Drop it eagerly so capture
2477        // never outlives its owner, even when a destroy happens mid-gesture.
2478        if self.pointer_captured_by == Some(widget_id) {
2479            self.pointer_captured_by = None;
2480        }
2481        // Single-node removal: this function already recursed into the
2482        // children above (honouring re-parenting when `reparent_aware`).
2483        // `arena.destroy` would re-recurse the now-stale `children` list and
2484        // tear down a survivor re-homed out of this subtree.
2485        self.arena.remove_node(widget_id);
2486    }
2487
2488    /// Set the layout direction (LTR/RTL). Marks all widgets as needing layout.
2489    pub fn set_layout_direction(&mut self, direction: crate::environment::LayoutDirection) {
2490        self.layout_direction = direction;
2491        self.arena.mark_all_dirty();
2492    }
2493
2494    /// The current layout direction.
2495    pub fn layout_direction(&self) -> crate::environment::LayoutDirection {
2496        self.layout_direction
2497    }
2498
2499    /// Set OS-level accessibility preferences.
2500    ///
2501    /// Called by `teksilo-app` after querying the platform layer. Updates the
2502    /// values fed into `PaintContext` and `Environment` on subsequent frames.
2503    /// Marks all widgets dirty so the new preferences take effect immediately.
2504    pub fn set_accessibility_preferences(
2505        &mut self,
2506        high_contrast: bool,
2507        reduced_motion: bool,
2508        text_scale_factor: f64,
2509    ) {
2510        let changed = self.prefers_high_contrast != high_contrast
2511            || self.prefers_reduced_motion != reduced_motion
2512            || (self.text_scale_factor - text_scale_factor).abs() > f64::EPSILON;
2513
2514        if changed {
2515            self.prefers_high_contrast = high_contrast;
2516            self.prefers_reduced_motion = reduced_motion;
2517            self.text_scale_factor = text_scale_factor;
2518            // The OS factor feeds the effective text scale (multiplied with the
2519            // user factor), so refresh the cached scaled typography.
2520            self.recompute_effective_theme();
2521            self.arena.mark_all_dirty();
2522        }
2523    }
2524
2525    /// Whether the OS has requested high-contrast mode.
2526    pub fn prefers_high_contrast(&self) -> bool {
2527        self.prefers_high_contrast
2528    }
2529
2530    /// Whether the OS has requested reduced motion.
2531    pub fn prefers_reduced_motion(&self) -> bool {
2532        self.prefers_reduced_motion
2533    }
2534
2535    /// OS text scaling factor (1.0 = normal).
2536    pub fn text_scale_factor(&self) -> f64 {
2537        self.text_scale_factor
2538    }
2539
2540    /// Set the host window HiDPI device scale (physical px per logical px).
2541    /// Called by `teksilo-app` before each layout from
2542    /// `platform_window.scale_factor()`. Surfaced to widgets via
2543    /// `LayoutContext::scale_factor`. No dirty-marking: it rides the layout
2544    /// pass that follows, and a scale change already triggers a relayout.
2545    pub fn set_device_scale_factor(&mut self, scale_factor: f32) {
2546        self.device_scale_factor = scale_factor;
2547    }
2548
2549    /// The host window HiDPI device scale most recently set (1.0 by default).
2550    pub fn device_scale_factor(&self) -> f32 {
2551        self.device_scale_factor
2552    }
2553
2554    /// Mark a widget as clipping its children to its bounds (scroll areas).
2555    pub fn set_clips_children(&mut self, id: WidgetId, clips: bool) {
2556        self.arena.set_clips_children(id, clips);
2557    }
2558
2559    /// Apply a `HandlerSet` to an existing node in the arena, routed
2560    /// into the rebuild-cleared `handlers` slot (the widget's own
2561    /// self-applied handlers).
2562    /// Register any *bound* builder-level accessibility Props
2563    /// (`access_hidden` / `access_label` / `access_description` /
2564    /// `access_value`) at `BindingLevel::AccessibilityOnly`, so that a change
2565    /// to the underlying signal flips `a11y_dirty` and the AccessKit tree
2566    /// re-walks — re-resolving the announced hidden-state / name / description
2567    /// / value — without a visual relayout. Static Props are ignored by
2568    /// `register_if_bound`. Takes the registry explicitly (rather than `&self`)
2569    /// so insertion-path callers can keep a disjoint `&mut self.arena` borrow
2570    /// on the node alive.
2571    fn register_access_prop_bindings(
2572        access: &crate::widget_builder::AccessibilityOverrides,
2573        id: WidgetId,
2574        registry: &crate::binding::BindingRegistry,
2575    ) {
2576        use crate::binding::BindingLevel::AccessibilityOnly;
2577        if let Some(p) = access.hidden.as_ref() {
2578            p.register_if_bound(id, registry, AccessibilityOnly);
2579        }
2580        if let Some(p) = access.label.as_ref() {
2581            p.register_if_bound(id, registry, AccessibilityOnly);
2582        }
2583        if let Some(p) = access.description.as_ref() {
2584            p.register_if_bound(id, registry, AccessibilityOnly);
2585        }
2586        if let Some(p) = access.value.as_ref() {
2587            p.register_if_bound(id, registry, AccessibilityOnly);
2588        }
2589    }
2590
2591    pub(crate) fn apply_self_handler_set(
2592        &mut self,
2593        id: WidgetId,
2594        mut handler_set: crate::widget_builder::HandlerSet,
2595    ) {
2596        // `visible_when` needs the binding registry (which the arena lacks), so
2597        // pull it out here and apply it via `self.visible_when` after.
2598        let visible_when = handler_set.visible_when.take();
2599        // Same reason for the reactive access Props: the arena can't reach the
2600        // registry, so register them here before handing the set to the arena.
2601        if let Some(access) = handler_set.access.as_ref() {
2602            Self::register_access_prop_bindings(access, id, &self.binding_registry);
2603        }
2604        self.arena
2605            .apply_handler_set(id, handler_set, crate::arena::HandlerScope::Own);
2606        if let Some(prop) = visible_when {
2607            self.visible_when(id, prop);
2608        }
2609    }
2610
2611    /// Apply a `HandlerSet` to an existing node as *external* handlers —
2612    /// the kind attached by a composing parent via
2613    /// `BuildContext::apply_handlers(child_id, ...)` or by the
2614    /// `WidgetBuilder` chain at insertion time. These persist across
2615    /// the target widget's own rebuilds.
2616    pub(crate) fn apply_external_handler_set(
2617        &mut self,
2618        id: WidgetId,
2619        mut handler_set: crate::widget_builder::HandlerSet,
2620    ) {
2621        // See `apply_self_handler_set`: route `visible_when` and the reactive
2622        // access Props through the registry (the arena can't reach it).
2623        let visible_when = handler_set.visible_when.take();
2624        if let Some(access) = handler_set.access.as_ref() {
2625            Self::register_access_prop_bindings(access, id, &self.binding_registry);
2626        }
2627        self.arena
2628            .apply_handler_set(id, handler_set, crate::arena::HandlerScope::External);
2629        if let Some(prop) = visible_when {
2630            self.visible_when(id, prop);
2631        }
2632    }
2633
2634    /// Append an accessibility `labelled_by` relation onto an already-mounted
2635    /// node, *preserving* any overrides the widget already carries — unlike
2636    /// `apply_external_handler_set`, which replaces the whole override struct.
2637    /// Used by container widgets (e.g. `FormLayout`) to name a field after its
2638    /// label once both ids are known. Idempotent-ish: re-adding the same target
2639    /// pushes a duplicate, so call once per pairing.
2640    pub(crate) fn push_access_labelled_by(&mut self, id: WidgetId, label_id: WidgetId) {
2641        if let Some(node) = self.arena.get_mut(id) {
2642            let overrides = node.access_overrides.get_or_insert_with(|| {
2643                Box::new(crate::widget_builder::AccessibilityOverrides::default())
2644            });
2645            // A composite that names itself from its content re-registers the
2646            // relation on every rebuild. Appending blindly would concatenate the
2647            // title into the name once per rebuild.
2648            if overrides.labelled_by.contains(&label_id) {
2649                return;
2650            }
2651            overrides.labelled_by.push(label_id);
2652            self.a11y_dirty = true;
2653        }
2654    }
2655
2656    /// Append an accessibility `described_by` relation onto an already-mounted
2657    /// node, preserving existing overrides (the `described_by` counterpart of
2658    /// [`push_access_labelled_by`](Self::push_access_labelled_by)).
2659    pub(crate) fn push_access_described_by(&mut self, id: WidgetId, target_id: WidgetId) {
2660        if let Some(node) = self.arena.get_mut(id) {
2661            let overrides = node.access_overrides.get_or_insert_with(|| {
2662                Box::new(crate::widget_builder::AccessibilityOverrides::default())
2663            });
2664            if overrides.described_by.contains(&target_id) {
2665                return;
2666            }
2667            overrides.described_by.push(target_id);
2668            self.a11y_dirty = true;
2669        }
2670    }
2671
2672    /// Set a per-child alignment override on a widget.
2673    pub fn set_alignment(&mut self, id: WidgetId, alignment: teksilo_tokens::Alignment) {
2674        self.arena.set_alignment_override(id, alignment);
2675    }
2676
2677    /// Get the binding registry for registering State→Widget bindings.
2678    pub fn binding_registry(&self) -> &crate::binding::BindingRegistry {
2679        &self.binding_registry
2680    }
2681
2682    /// Shared access to the shortcut registry. Widgets register their
2683    /// default shortcuts through here during `build()` (via
2684    /// `BuildContext::register_shortcut`); settings UIs and
2685    /// persistence layers read and mutate overrides directly.
2686    pub fn shortcut_registry(&self) -> &crate::shortcut::ShortcutRegistry {
2687        &self.shortcut_registry
2688    }
2689
2690    pub fn shortcut_registry_mut(&mut self) -> &mut crate::shortcut::ShortcutRegistry {
2691        &mut self.shortcut_registry
2692    }
2693
2694    /// Install a one-shot key-capture callback, returning a
2695    /// [`CaptureHandle`](crate::shortcut::CaptureHandle) whose `Drop`
2696    /// cancels the capture if it hasn't already fired. The next
2697    /// `KeyDown` the tree receives bypasses shortcut-registry lookup
2698    /// and invokes the callback with:
2699    /// - the captured [`KeyStroke`](crate::shortcut::KeyStroke)
2700    /// - mutable access to the registry (rebind in-place)
2701    /// - a mutable [`EventContext`] (so the handler can also emit
2702    ///   commands, send intents, dismiss overlays, …)
2703    ///
2704    /// Calling this while a previous capture is armed creates a
2705    /// **separate** slot; the prior handle, when eventually dropped,
2706    /// cancels only its own (now-orphaned) slot. The new capture
2707    /// wins.
2708    pub fn begin_key_capture(
2709        &mut self,
2710        callback: impl FnOnce(
2711            crate::shortcut::KeyStroke,
2712            &mut crate::shortcut::ShortcutRegistry,
2713            &mut EventContext,
2714        ) + 'static,
2715    ) -> crate::shortcut::CaptureHandle {
2716        let slot: crate::shortcut::KeyCaptureSlot =
2717            std::rc::Rc::new(std::cell::RefCell::new(Some(Box::new(callback))));
2718        self.key_capture = Some(slot.clone());
2719        crate::shortcut::CaptureHandle::new(slot)
2720    }
2721
2722    /// Cancel any currently-armed key capture without invoking it.
2723    /// Equivalent to dropping the [`CaptureHandle`](crate::shortcut::CaptureHandle),
2724    /// but exposed here so callers that lost the handle (or never
2725    /// kept one) can still bail out.
2726    pub fn cancel_key_capture(&mut self) {
2727        if let Some(slot) = self.key_capture.take() {
2728            slot.borrow_mut().take();
2729        }
2730    }
2731
2732    /// Whether a key-capture callback is currently armed.
2733    pub fn is_capturing_keys(&self) -> bool {
2734        self.key_capture
2735            .as_ref()
2736            .map(|slot| slot.borrow().is_some())
2737            .unwrap_or(false)
2738    }
2739
2740    /// Consume any pending key-capture callback. Used internally by
2741    /// the dispatch path — returns the boxed closure so the caller
2742    /// can invoke it once the KeyStroke has been constructed. Also
2743    /// drops the outer `Option<Rc<...>>` so `is_capturing_keys` goes
2744    /// back to `false`.
2745    pub(crate) fn take_key_capture(&mut self) -> Option<crate::shortcut::KeyCaptureCallback> {
2746        let slot = self.key_capture.take()?;
2747        slot.borrow_mut().take()
2748    }
2749
2750    /// Append an [`Action`](crate::action::Action) to a widget's arena
2751    /// node. Invoked by `BuildContext::register_action`; not meant
2752    /// to be called directly.
2753    /// Record that `widget_id` edits text. Replaces any previous registration
2754    /// from the same widget, so a rebuild re-points rather than accumulating.
2755    pub(crate) fn push_text_surface(
2756        &mut self,
2757        widget_id: WidgetId,
2758        surface: std::rc::Rc<dyn crate::text_surface::TextSurface>,
2759    ) {
2760        self.text_surfaces.insert(widget_id, surface);
2761    }
2762
2763    /// A cloneable view of this tree's text surfaces, for a caller that must ask
2764    /// the question later, without a `&WidgetTree` in hand.
2765    pub fn text_surfaces(&self) -> crate::text_surface::TextSurfaces {
2766        self.text_surfaces.clone()
2767    }
2768
2769    /// The text-editing widget that currently holds the keyboard focus.
2770    ///
2771    /// `None` when focus is elsewhere — or nowhere — which is exactly what a
2772    /// host needs in order to know that a text chord is safe to route itself.
2773    pub fn focused_text_surface(
2774        &self,
2775    ) -> Option<std::rc::Rc<dyn crate::text_surface::TextSurface>> {
2776        self.text_surfaces.focused()
2777    }
2778
2779    /// Is the keyboard focus inside a widget that edits text?
2780    ///
2781    /// The cheap half of [`focused_text_surface`](Self::focused_text_surface),
2782    /// for a host that only needs to decide whether to step aside.
2783    pub fn focused_is_text_surface(&self) -> bool {
2784        self.text_surfaces.focused_is_text_surface()
2785    }
2786
2787    pub(crate) fn push_action(&mut self, widget_id: WidgetId, action: crate::action::Action) {
2788        if let Some(node) = self.arena.get_mut(widget_id) {
2789            node.actions.push(action);
2790        }
2791    }
2792
2793    /// Telemetry dispatch tap. Looks up a registered
2794    /// [`crate::telemetry::TelemetryContext`] in `app_state` and emits
2795    /// an `intent.dispatched` event with the intent's name. No-op when
2796    /// no telemetry is configured. Errors and consent gating are
2797    /// handled inside the reporter — this site only needs to call
2798    /// `record`.
2799    fn tap_intent_dispatched(&self, intent: &crate::intent::Intent) {
2800        let Some(tcx) = self
2801            .app_context()
2802            .app_state::<crate::telemetry::TelemetryContext>()
2803        else {
2804            return;
2805        };
2806        let install_id = tcx.reporter.install_id();
2807        let props = [
2808            crate::telemetry::Prop {
2809                key: "name",
2810                value: crate::telemetry::PropValue::StaticStr(intent.name),
2811            },
2812            crate::telemetry::Prop {
2813                key: "source",
2814                value: crate::telemetry::PropValue::Enum {
2815                    variant: intent.source.as_str(),
2816                },
2817            },
2818        ];
2819        let event = crate::telemetry::Event {
2820            name: "intent.dispatched",
2821            category: crate::telemetry::EventCategory::Intent,
2822            timestamp: std::time::SystemTime::now(),
2823            install_id,
2824            session_id: &tcx.session_id,
2825            schema_version: tcx.schema_version,
2826            props: &props,
2827        };
2828        tcx.reporter.record(&event);
2829    }
2830
2831    /// Histogram of widget concrete-type names across the active
2832    /// arena. Used by the `widget.census` telemetry emitter to surface
2833    /// "which widgets does this app actually use" data back to the
2834    /// framework. Keyed by
2835    /// `std::any::type_name::<T>()` of the concrete widget — a
2836    /// dotted, fully-qualified path like
2837    /// `teksilo_widgets::button::Button`.
2838    ///
2839    /// `&'static str` keys: `type_name_of_val` returns a
2840    /// compile-time string, so the histogram preserves the static
2841    /// lifetime all the way to the wire-format prop. This avoids
2842    /// any allocation for the type-name strings themselves.
2843    ///
2844    /// Cost: one `Box<dyn Widget>` indirection per active node plus
2845    /// a `HashMap` insert. Sub-millisecond on arenas with thousands
2846    /// of widgets. Safe to call every frame in tests; in production
2847    /// gate behind a periodic ticker (hourly or on-idle).
2848    pub fn widget_type_histogram(&self) -> std::collections::HashMap<&'static str, u32> {
2849        let mut out = std::collections::HashMap::<&'static str, u32>::new();
2850        for id in self.arena.active_ids_iter() {
2851            if let Some(node) = self.arena.get(id) {
2852                // `Widget::type_name` is monomorphized per impl, so
2853                // calling through the vtable correctly resolves to
2854                // the concrete type — `type_name_of_val(&*widget)`
2855                // alone would collapse to `"dyn teksilo_core::widget::Widget"`.
2856                let name: &'static str = node.widget.type_name();
2857                *out.entry(name).or_insert(0) += 1;
2858            }
2859        }
2860        out
2861    }
2862
2863    /// Number of active widgets in the arena. Cheap; matches the
2864    /// totals returned by `widget_type_histogram` when summed.
2865    pub fn active_widget_count(&self) -> usize {
2866        self.arena.active_ids_iter().count()
2867    }
2868
2869    /// Enqueue an intent for dispatch from `source`. Called from
2870    /// `collect_from_ctx` after a handler runs `ctx.send_intent(...)`
2871    /// and from the KeyDown shortcut-interception path.
2872    pub(crate) fn enqueue_intent(
2873        &mut self,
2874        source: WidgetId,
2875        intent: crate::intent::Intent,
2876        propagate_when_disabled: bool,
2877    ) {
2878        self.pending_intents
2879            .push((source, intent, propagate_when_disabled));
2880    }
2881
2882    /// Dispatch every queued intent. Handlers may call
2883    /// `ctx.send_intent(...)` to enqueue more; the loop consumes
2884    /// those too until the queue drains. No ordering guarantee
2885    /// beyond "first-enqueued is first-dispatched"; the `pop` path
2886    /// uses `remove(0)` to keep that FIFO behavior.
2887    pub(crate) fn drain_pending_intents(&mut self, ops: &mut dyn crate::window::WindowOps) {
2888        while !self.pending_intents.is_empty() {
2889            let (source, intent, propagate) = self.pending_intents.remove(0);
2890            self.dispatch_intent(source, intent, propagate, &mut *ops);
2891        }
2892    }
2893
2894    /// Walk `source → root` invoking any [`Action`](crate::action::Action)
2895    /// whose `intent` name matches. The first enabled, `Handled`
2896    /// response stops the walk. A `Propagated` or disabled action
2897    /// (when the shortcut's `propagate_when_disabled` is true) lets
2898    /// the walk continue. A disabled action with
2899    /// `propagate_when_disabled == false` consumes the intent at that
2900    /// level without invoking a handler.
2901    pub(crate) fn dispatch_intent(
2902        &mut self,
2903        source: WidgetId,
2904        intent: crate::intent::Intent,
2905        propagate_when_disabled: bool,
2906        ops: &mut dyn crate::window::WindowOps,
2907    ) {
2908        // Telemetry tap. Single insertion point catches every intent
2909        // — shortcut-driven, programmatic via `send_intent`, etc. —
2910        // because every dispatch funnels through here. A no-op when
2911        // no `TelemetryContext` is registered.
2912        self.tap_intent_dispatched(&intent);
2913
2914        // Pre-compute the source → root chain so the walk doesn't
2915        // need to hold any arena borrow while invoking handlers.
2916        let chain: Vec<WidgetId> = {
2917            let mut v = vec![source];
2918            let mut current = self.arena.parent(source);
2919            while let Some(id) = current {
2920                v.push(id);
2921                current = self.arena.parent(id);
2922            }
2923            v
2924        };
2925
2926        for id in chain {
2927            if !self.arena.is_active(id) || !self.arena.is_enabled(id) {
2928                continue;
2929            }
2930
2931            // Take out the first matching action by intent name so
2932            // we can invoke its FnMut handler without holding an
2933            // arena-wide borrow. The action is reinserted at its
2934            // original position so declaration order is preserved
2935            // for any follow-on dispatch.
2936            let Some((mut action, idx, enabled)) = self.arena.get_mut(id).and_then(|node| {
2937                let idx = node.actions.iter().position(|a| a.intent == intent.name)?;
2938                let enabled = node.actions[idx].is_enabled();
2939                Some((node.actions.remove(idx), idx, enabled))
2940            }) else {
2941                continue;
2942            };
2943
2944            if !enabled {
2945                // Return the action untouched.
2946                if let Some(node) = self.arena.get_mut(id) {
2947                    node.actions.insert(idx, action);
2948                }
2949                if propagate_when_disabled {
2950                    continue;
2951                }
2952                return;
2953            }
2954
2955            let mut ctx = self.make_event_context(&mut *ops);
2956            let response = (action.handler)(&intent, &mut ctx);
2957            if let Some(node) = self.arena.get_mut(id) {
2958                node.actions.insert(idx, action);
2959            }
2960            self.collect_from_ctx(ctx, id);
2961
2962            match response {
2963                crate::intent::IntentResponse::Handled => return,
2964                crate::intent::IntentResponse::Propagated => continue,
2965            }
2966        }
2967
2968        // Fallback: window-global actions (registered via
2969        // `register_action_global`). The source→root walk found no consuming
2970        // node action, so consult app-global commands — reachable regardless of
2971        // where the intent originated (menu-bar overlay, content, shortcut).
2972        let mut i = 0;
2973        while i < self.global_actions.len() {
2974            let matches = {
2975                let (_, action) = &self.global_actions[i];
2976                action.intent == intent.name && action.is_enabled()
2977            };
2978            if !matches {
2979                i += 1;
2980                continue;
2981            }
2982            // Take the action out so the FnMut handler can run without holding a
2983            // borrow on `self`; reinsert at its slot afterwards.
2984            let (owner, mut action) = self.global_actions.remove(i);
2985            let mut ctx = self.make_event_context(&mut *ops);
2986            let response = (action.handler)(&intent, &mut ctx);
2987            self.global_actions.insert(i, (owner, action));
2988            self.collect_from_ctx(ctx, owner);
2989            match response {
2990                crate::intent::IntentResponse::Handled => return,
2991                crate::intent::IntentResponse::Propagated => {
2992                    i += 1;
2993                    continue;
2994                }
2995            }
2996        }
2997    }
2998
2999    /// Register a window-global [`Action`](crate::action::Action) owned by
3000    /// `owner`. Consulted as a dispatch fallback (see [`Self::dispatch_intent`]);
3001    /// torn down when `owner` rebuilds or is destroyed. Backs
3002    /// [`BuildContext::register_action_global`](crate::BuildContext::register_action_global).
3003    pub(crate) fn push_global_action(&mut self, owner: WidgetId, action: crate::action::Action) {
3004        self.global_actions.push((owner, action));
3005    }
3006
3007    // --- Window-close request (drained by the app loop) ---
3008
3009    /// Drain the "close this window" flag set by
3010    /// [`EventContext::close_window`] during dispatch. A *guarded* close
3011    /// — the app routes it through the window's close guard.
3012    pub fn take_close_window_request(&mut self) -> bool {
3013        std::mem::replace(&mut self.close_window_requested, false)
3014    }
3015
3016    /// Drain the "close this window, no questions asked" flag set by
3017    /// [`EventContext::close_window_forced`] during dispatch. An
3018    /// *unconditional* close that bypasses the window's close guard.
3019    pub fn take_force_close_request(&mut self) -> bool {
3020        std::mem::replace(&mut self.force_close_requested, false)
3021    }
3022
3023    /// Drain the pending locale switch raised by
3024    /// [`EventContext::set_locale`] during dispatch. The app layer
3025    /// (`WindowManager::drain_pending_locale_requests`) parses the
3026    /// result and routes it through `WindowManager::set_locale` so the
3027    /// `I18nManager`'s active locale, version signal, and layout
3028    /// direction all stay in sync with the tree.
3029    pub fn take_pending_locale_request(&mut self) -> Option<String> {
3030        self.pending_locale_request.take()
3031    }
3032
3033    /// Drain the pending theme switch raised by
3034    /// [`EventContext::set_theme`] during dispatch. The app layer
3035    /// (`WindowManager::drain_pending_theme_requests`) routes it through
3036    /// `WindowManager::set_theme` so the new theme is applied to every
3037    /// window, not just the one whose handler requested it.
3038    pub fn take_pending_theme_request(&mut self) -> Option<crate::styles::Theme> {
3039        self.pending_theme_request.take()
3040    }
3041
3042    /// Drain the pending "follow OS theme" request raised by
3043    /// [`EventContext::follow_system_theme`] during dispatch. The app layer
3044    /// (`WindowManager::drain_pending_follow_system_requests`) switches to
3045    /// `ThemeMode::Native` and recomputes the theme from the OS for every
3046    /// window. Returns `true` if a request was pending.
3047    pub fn take_pending_follow_system_request(&mut self) -> bool {
3048        std::mem::take(&mut self.pending_follow_system_request)
3049    }
3050
3051    /// Drain the pending text-scale change raised by
3052    /// [`EventContext::set_text_scale`] during dispatch. The app layer
3053    /// (`WindowManager::drain_pending_text_scale_requests`) routes it through
3054    /// `WindowManager::set_text_scale` so the new factor is applied to every
3055    /// window, not just the one whose handler requested it.
3056    pub fn take_pending_text_scale_request(&mut self) -> Option<f32> {
3057        self.pending_text_scale_request.take()
3058    }
3059
3060    /// Drain all pending modal requests recorded during event handling.
3061    ///
3062    /// Each request includes the originating widget so higher layers can
3063    /// resolve routing and focus behavior relative to the source tree.
3064    pub fn drain_pending_modal_requests(&mut self) -> Vec<crate::modal::QueuedModalRequest> {
3065        std::mem::take(&mut self.pending_modal_requests)
3066    }
3067
3068    /// Drain whether the current native modal window should be dismissed.
3069    pub fn drain_pending_modal_dismissal(&mut self) -> bool {
3070        std::mem::replace(&mut self.pending_modal_dismissal, false)
3071    }
3072
3073    // --- Widget insertion ---
3074
3075    /// Walk `Widget::declare_shortcuts` for an already-inserted widget
3076    /// and register every returned shortcut with the registry, owned
3077    /// by `id`. Called at insertion AND at rebuild so the declared
3078    /// metadata survives across rebuilds (which `unregister_all_for_owner`
3079    /// would otherwise wipe). Build-time `ctx.register_shortcut` calls
3080    /// upsert handlers on top; the registry is idempotent on id.
3081    pub(crate) fn apply_declared_shortcuts(&mut self, id: WidgetId) {
3082        let declared = self
3083            .arena
3084            .get(id)
3085            .map(|n| n.widget.declare_shortcuts())
3086            .unwrap_or_default();
3087        for shortcut in declared {
3088            self.shortcut_registry.register_owned(shortcut, id);
3089        }
3090    }
3091
3092    /// Internal: insert a widget, call build(), wire children, register clips.
3093    fn insert_widget(&mut self, widget: Box<dyn Widget>) -> WidgetId {
3094        let id = self.arena.insert(widget);
3095
3096        {
3097            if let Some(mut widget_box) = self.arena.take_widget(id) {
3098                if let Some(handler_set) = widget_box.take_handler_set() {
3099                    self.arena.restore_widget(id, widget_box);
3100                    if let Some(node) = self.arena.get_mut(id) {
3101                        // Handlers attached at the widget's creation site
3102                        // are external from its own perspective — keep
3103                        // them out of the rebuild-cleared `handlers`
3104                        // slot so they survive data-driven rebuilds.
3105                        node.external_handlers = handler_set.handlers;
3106                        node.node_focusable = handler_set.focusable;
3107                        node.node_tab_index = handler_set.tab_index;
3108                        node.node_cursor = handler_set.cursor;
3109                        // `clips_children` and `event_pass_through` are
3110                        // node-level flags on `WidgetNode` — they must
3111                        // be mirrored here too. Without this an
3112                        // `Inner::new().event_pass_through(true)` chain
3113                        // silently no-ops (the flag stays at default
3114                        // `false`), and any widget wrapped with it
3115                        // catches every pointer event in its bounds.
3116                        if let Some(clips) = handler_set.clips_children {
3117                            node.clips_children = clips;
3118                        }
3119                        if let Some(pass_through) = handler_set.event_pass_through {
3120                            node.event_pass_through = pass_through;
3121                        }
3122                        if let Some(dead_zone) = handler_set.gesture_dead_zone {
3123                            node.gesture_dead_zone = dead_zone;
3124                        }
3125                        if let Some(keyboard_capture) = handler_set.keyboard_capture {
3126                            node.keyboard_capture = keyboard_capture;
3127                        }
3128                        if let Some(hit_transparent) = handler_set.hit_transparent {
3129                            node.hit_transparent = hit_transparent;
3130                        }
3131                        if handler_set.context_menu_factory.is_some() {
3132                            node.context_menu_factory = handler_set.context_menu_factory;
3133                        }
3134                        if let Some(sig) = handler_set.focus_within {
3135                            node.focus_within_signal = Some(sig);
3136                        }
3137                        if let Some(sig) = handler_set.hover_within {
3138                            node.hover_within_signal = Some(sig);
3139                        }
3140                        // Builder-chained `visible_when: prop`. Mirror of
3141                        // `WidgetTree::visible_when`: register a bound prop at
3142                        // Relayout, then store it on the node. (Disjoint field
3143                        // borrow, like `access_hidden` below.)
3144                        if let Some(prop) = handler_set.visible_when {
3145                            prop.register_if_bound(
3146                                id,
3147                                &self.binding_registry,
3148                                crate::binding::BindingLevel::Relayout,
3149                            );
3150                            node.visible_state = Some(prop);
3151                        }
3152                        // Builder-level accessibility overrides + subtree
3153                        // mode. Mirrored here because this insertion path
3154                        // bypasses `apply_handler_set`.
3155                        if handler_set.access.is_some() {
3156                            // Register any bound access_hidden/label/description/
3157                            // value Props at AccessibilityOnly so the AT tree
3158                            // re-walks when they flip. (Disjoint field borrow:
3159                            // `node` borrows `self.arena`, this reads
3160                            // `self.binding_registry`.)
3161                            if let Some(access) = handler_set.access.as_ref() {
3162                                Self::register_access_prop_bindings(
3163                                    access,
3164                                    id,
3165                                    &self.binding_registry,
3166                                );
3167                            }
3168                            node.access_overrides = handler_set.access;
3169                        }
3170                        if let Some(mode) = handler_set.access_subtree {
3171                            node.access_subtree = mode;
3172                        }
3173                    }
3174                } else {
3175                    self.arena.restore_widget(id, widget_box);
3176                }
3177            }
3178        }
3179
3180        // Walk Widget::declare_shortcuts before build() so the
3181        // declared metadata lands in the registry first; if build()
3182        // also registers the same id with a real on_activate, the
3183        // registry upserts (preserving any user override).
3184        self.apply_declared_shortcuts(id);
3185
3186        {
3187            let mut widget_box = match self.arena.take_widget(id) {
3188                Some(widget) => widget,
3189                None => return id,
3190            };
3191            let mut build_ctx = crate::build_context::BuildContext {
3192                tree: self,
3193                composite_id: Some(id),
3194                effect_handles: Vec::new(),
3195                subscription_handles: Vec::new(),
3196                // A first mount has no previous build to inherit ids from.
3197                reusable_sub_ids: Vec::new(),
3198            };
3199            let built_children = widget_box.build(&mut build_ctx);
3200            let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
3201            let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);
3202
3203            self.arena.restore_widget(id, widget_box);
3204
3205            // Transfer per-widget handles to the node. Both lists are
3206            // stored unconditionally — a leaf widget that registers an
3207            // effect in its build() still needs its ObserverHandle to
3208            // persist (otherwise the effect unregisters the moment
3209            // BuildContext drops).
3210            if let Some(node) = self.arena.get_mut(id) {
3211                node.subscription_handles = subscription_handles;
3212                node.effect_handles = effect_handles;
3213            }
3214
3215            if !built_children.is_empty() {
3216                for &child_id in &built_children {
3217                    if let Some(child_node) = self.arena.get_mut(child_id) {
3218                        child_node.parent = Some(id);
3219                    }
3220                }
3221                if let Some(node) = self.arena.get_mut(id) {
3222                    node.children = built_children;
3223                }
3224            }
3225        }
3226
3227        let clips = self
3228            .arena
3229            .get(id)
3230            .is_some_and(|node| node.widget.clips_children());
3231        if clips {
3232            self.arena.set_clips_children(id, true);
3233        }
3234
3235        id
3236    }
3237
3238    /// Add a widget to the tree.
3239    pub fn add(&mut self, widget: impl Widget + 'static) -> WidgetId {
3240        self.insert_widget(Box::new(widget))
3241    }
3242
3243    /// Add a pre-boxed widget to the tree.
3244    pub fn add_boxed(&mut self, widget: Box<dyn Widget>) -> WidgetId {
3245        self.insert_widget(widget)
3246    }
3247
3248    /// The widget that paints `id`'s title, when it has one.
3249    ///
3250    /// See [`Widget::accessible_title_node`].
3251    pub fn widget_accessible_title_node(&self, id: WidgetId) -> Option<WidgetId> {
3252        self.arena.get(id)?.widget.accessible_title_node()
3253    }
3254
3255    /// Add a widget as a child of another widget.
3256    pub fn add_child(&mut self, parent: WidgetId, widget: impl Widget + 'static) -> WidgetId {
3257        let boxed: Box<dyn Widget> = Box::new(widget);
3258
3259        let id = self.arena.insert_child(parent, boxed);
3260
3261        {
3262            if let Some(mut widget_box) = self.arena.take_widget(id) {
3263                if let Some(handler_set) = widget_box.take_handler_set() {
3264                    self.arena.restore_widget(id, widget_box);
3265                    if let Some(node) = self.arena.get_mut(id) {
3266                        // Creation-site handlers are external (persist
3267                        // across the widget's own rebuilds) — see the
3268                        // matching block in `insert_widget`.
3269                        node.external_handlers = handler_set.handlers;
3270                        node.node_focusable = handler_set.focusable;
3271                        node.node_tab_index = handler_set.tab_index;
3272                        node.node_cursor = handler_set.cursor;
3273                        if let Some(clips) = handler_set.clips_children {
3274                            node.clips_children = clips;
3275                        }
3276                        if let Some(pass_through) = handler_set.event_pass_through {
3277                            node.event_pass_through = pass_through;
3278                        }
3279                        if let Some(dead_zone) = handler_set.gesture_dead_zone {
3280                            node.gesture_dead_zone = dead_zone;
3281                        }
3282                        if let Some(keyboard_capture) = handler_set.keyboard_capture {
3283                            node.keyboard_capture = keyboard_capture;
3284                        }
3285                        if let Some(hit_transparent) = handler_set.hit_transparent {
3286                            node.hit_transparent = hit_transparent;
3287                        }
3288                        if handler_set.context_menu_factory.is_some() {
3289                            node.context_menu_factory = handler_set.context_menu_factory;
3290                        }
3291                        if let Some(sig) = handler_set.focus_within {
3292                            node.focus_within_signal = Some(sig);
3293                        }
3294                        if let Some(sig) = handler_set.hover_within {
3295                            node.hover_within_signal = Some(sig);
3296                        }
3297                        // Builder-chained `visible_when: prop`. Same as in
3298                        // `insert_widget`.
3299                        if let Some(prop) = handler_set.visible_when {
3300                            prop.register_if_bound(
3301                                id,
3302                                &self.binding_registry,
3303                                crate::binding::BindingLevel::Relayout,
3304                            );
3305                            node.visible_state = Some(prop);
3306                        }
3307                        // Builder-level accessibility overrides + subtree
3308                        // mode. Same rationale as in `insert_widget`.
3309                        if handler_set.access.is_some() {
3310                            // Register any bound access_hidden/label/description/
3311                            // value Props at AccessibilityOnly so the AT tree
3312                            // re-walks when they flip. (Disjoint field borrow:
3313                            // `node` borrows `self.arena`, this reads
3314                            // `self.binding_registry`.)
3315                            if let Some(access) = handler_set.access.as_ref() {
3316                                Self::register_access_prop_bindings(
3317                                    access,
3318                                    id,
3319                                    &self.binding_registry,
3320                                );
3321                            }
3322                            node.access_overrides = handler_set.access;
3323                        }
3324                        if let Some(mode) = handler_set.access_subtree {
3325                            node.access_subtree = mode;
3326                        }
3327                    }
3328                } else {
3329                    self.arena.restore_widget(id, widget_box);
3330                }
3331            }
3332        }
3333
3334        // Same shortcut-declaration walk as `insert_widget` — keeps
3335        // metadata visible from the moment the child mounts, before
3336        // build() runs.
3337        self.apply_declared_shortcuts(id);
3338
3339        {
3340            if let Some(mut widget_box) = self.arena.take_widget(id) {
3341                let mut build_ctx = crate::build_context::BuildContext {
3342                    tree: self,
3343                    composite_id: Some(id),
3344                    effect_handles: Vec::new(),
3345                    subscription_handles: Vec::new(),
3346                    // A first mount has no previous build to inherit ids from.
3347                    reusable_sub_ids: Vec::new(),
3348                };
3349                let built_children = widget_box.build(&mut build_ctx);
3350                let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
3351                let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);
3352
3353                self.arena.restore_widget(id, widget_box);
3354
3355                // Transfer per-widget handles to the node. See the
3356                // matching block in `insert_widget` — effect and
3357                // subscription handles must persist for leaf widgets
3358                // too, not only composite ones.
3359                if let Some(node) = self.arena.get_mut(id) {
3360                    node.subscription_handles = subscription_handles;
3361                    node.effect_handles = effect_handles;
3362                }
3363
3364                if !built_children.is_empty() {
3365                    for &child_id in &built_children {
3366                        if let Some(child_node) = self.arena.get_mut(child_id) {
3367                            child_node.parent = Some(id);
3368                        }
3369                    }
3370                    if let Some(node) = self.arena.get_mut(id) {
3371                        node.children = built_children;
3372                    }
3373                }
3374            }
3375        }
3376
3377        let clips = self
3378            .arena
3379            .get(id)
3380            .is_some_and(|node| node.widget.clips_children());
3381        if clips {
3382            self.arena.set_clips_children(id, true);
3383        }
3384
3385        id
3386    }
3387
3388    // --- Property bindings ---
3389
3390    /// Bind a widget's visibility to a boolean prop or compatibility state binding.
3391    /// When false, the widget is set dormant; when true, it is activated.
3392    /// Accepts `Signal<bool>`, `Prop<bool>`, compatibility state bindings, or plain `bool`.
3393    pub fn visible_when(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
3394        let prop = state.into();
3395        prop.register_if_bound(
3396            id,
3397            &self.binding_registry,
3398            crate::binding::BindingLevel::Relayout,
3399        );
3400        if let Some(node) = self.arena.get_mut(id) {
3401            node.visible_state = Some(prop);
3402        }
3403    }
3404
3405    /// Install (or reuse) the activation signal on a node and return a
3406    /// handle to it. The framework sets it to `false` when the node is
3407    /// parked dormant (`Switcher` / `visible_when`) and `true` when it is
3408    /// re-activated — see [`crate::arena::WidgetArena::set_dormant`] /
3409    /// [`activate`](crate::arena::WidgetArena::activate). The returned
3410    /// signal is initialised to the node's current active state. Used by
3411    /// widgets owning a resource outside the paint pass (a native subview)
3412    /// that must hide/show it in lockstep with framework activation.
3413    pub fn activation_signal(&mut self, id: WidgetId) -> crate::signal::Signal<bool> {
3414        if let Some(node) = self.arena.get_mut(id) {
3415            if let Some(existing) = node.activation_signal.clone() {
3416                return existing;
3417            }
3418            let active = node.activation == crate::arena::ActivationState::Active;
3419            let sig = crate::signal::Signal::new(active);
3420            node.activation_signal = Some(sig.clone());
3421            sig
3422        } else {
3423            // Node missing (shouldn't happen in build) — hand back a
3424            // detached signal so the caller still gets a valid handle.
3425            crate::signal::Signal::new(true)
3426        }
3427    }
3428
3429    /// Fire the `activation_signal` of every node that transitioned
3430    /// Active↔Dormant since the last flush. Called at a well-defined tree-level
3431    /// point (the end of `process_state_changes`), never from inside the arena
3432    /// recursion — so an observer (e.g. a `WebView` calling the engine's
3433    /// `set_visible`) runs after the visibility pass has fully committed,
3434    /// matching the `focus_within` / `hover_within` update discipline.
3435    pub(crate) fn flush_activation_signals(&mut self) {
3436        let changes = self.arena.take_activation_changes();
3437        for (id, _recorded) in changes {
3438            // Re-read the node at flush time; it still exists (the transition
3439            // was recorded in the same synchronous operation).
3440            let Some(node) = self.arena.get(id) else {
3441                continue;
3442            };
3443            let Some(sig) = node.activation_signal.clone() else {
3444                continue;
3445            };
3446            // Fire the node's **current** state, not the value recorded at the
3447            // transition, and only when it differs from what observers last
3448            // saw. `pending_activation_changes` is an append-only queue: a
3449            // node parked and re-activated inside one batch records both
3450            // edges, and replaying them in order hands observers a `false`
3451            // that was never observable — the node is already Active by the
3452            // time anyone is told anything.
3453            //
3454            // The in-tree modal path does exactly that on every open: build
3455            // the content, `set_dormant` it, mount the scrim, `activate` it,
3456            // then move focus in. Both edges land in one batch and flush
3457            // *after* the focus dispatch, so the stale `false` arrives last
3458            // and observers act on a state that has already been superseded.
3459            // For a text editor that meant its dormancy handler wiped the
3460            // `has_focus` it had just been granted, and the dialog opened with
3461            // no caret; a `WebView` would have taken a real `set_visible(false)`
3462            // OS call for a subview that never left the screen.
3463            //
3464            // Collapsing to the final state also makes the flush idempotent
3465            // over duplicate ids: the first iteration syncs the signal, the
3466            // rest find it already equal and skip. `Signal::set` notifies
3467            // unconditionally, so the equality guard is what stops the
3468            // redundant fanout.
3469            let active = node.activation == crate::arena::ActivationState::Active;
3470            if sig.get() != active {
3471                sig.set(active);
3472            }
3473        }
3474    }
3475
3476    /// Bind an opacity multiplier (0..1) to a widget. The render walker
3477    /// emits `SetOpacity(value)` before painting the widget's subtree
3478    /// and `RestoreOpacity` afterwards, so the multiplier composes
3479    /// correctly with ancestor opacity scopes via the canvas's stacked
3480    /// opacity model. Bound at `Repaint` level: opacity changes never
3481    /// trigger relayout. Pass any `Prop<f32>` or `Signal<f32>` source
3482    /// (typically an animated signal driven by a `Fade` wrapper).
3483    pub fn set_opacity(&mut self, id: WidgetId, opacity: impl Into<crate::signal::Prop<f32>>) {
3484        let prop = opacity.into();
3485        prop.register_if_bound(
3486            id,
3487            &self.binding_registry,
3488            crate::binding::BindingLevel::RepaintOnly,
3489        );
3490        if let Some(node) = self.arena.get_mut(id) {
3491            node.opacity_prop = Some(prop);
3492        }
3493    }
3494
3495    /// Bind a 2D affine transform to a widget. The render walker emits
3496    /// `PushTransform(value)` before painting the widget's subtree and
3497    /// `PopTransform` afterwards; the renderer composes the transform
3498    /// onto its stack so nested wrappers and widget-internal canvas
3499    /// transforms compose correctly. Bound at `Repaint` level: visual-
3500    /// only transforms never trigger relayout. Wrappers that want the
3501    /// transform's *value change* to also drive layout (e.g.
3502    /// `Scale::reflow(true)`) must additionally bind the *driver*
3503    /// signal to themselves at `Relayout` level — the transform prop
3504    /// itself stays at Repaint. Pass a `Transform2D`, `Signal<Transform2D>`,
3505    /// or `Prop<Transform2D>`.
3506    pub fn set_transform(
3507        &mut self,
3508        id: WidgetId,
3509        transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
3510    ) {
3511        let prop = transform.into();
3512        prop.register_if_bound(
3513            id,
3514            &self.binding_registry,
3515            crate::binding::BindingLevel::RepaintOnly,
3516        );
3517        if let Some(node) = self.arena.get_mut(id) {
3518            node.transform_prop = Some(prop);
3519            // A plain transform is a *self* transform — clear any prior
3520            // content-transform marker so the flag can never go stale if a
3521            // node switches from `set_content_transform` to `set_transform`.
3522            node.content_transform = false;
3523        }
3524    }
3525
3526    /// Like [`set_transform`](Self::set_transform), but marks the transform as
3527    /// a **content** transform: it positions the node's content within a fixed
3528    /// parent-space viewport (the node's bounds) rather than transforming the
3529    /// node itself. Hit-testing then keeps the whole viewport interactive at
3530    /// any pan / zoom. Used by `SceneView`; see
3531    /// `WidgetNode::content_transform`.
3532    pub fn set_content_transform(
3533        &mut self,
3534        id: WidgetId,
3535        transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
3536    ) {
3537        let prop = transform.into();
3538        prop.register_if_bound(
3539            id,
3540            &self.binding_registry,
3541            crate::binding::BindingLevel::RepaintOnly,
3542        );
3543        if let Some(node) = self.arena.get_mut(id) {
3544            node.transform_prop = Some(prop);
3545            node.content_transform = true;
3546        }
3547    }
3548
3549    /// Bind a Gaussian-equivalent blur radius to a widget. The render
3550    /// walker emits `BeginBlurredSubtree { bounds, radius }` before
3551    /// painting the widget's subtree and `EndBlurredSubtree` afterwards;
3552    /// the renderer redirects drawing into an intermediate texture, runs
3553    /// a dual-Kawase blur chain at the requested radius, and composites
3554    /// the blurred result back into the parent pass. Bound at `Repaint`
3555    /// level: blur radius changes never trigger relayout. Sub-perceptual
3556    /// radii (< 0.5) skip the Begin/End pair entirely so animated
3557    /// enable/disable patterns have zero per-frame cost when fully off.
3558    /// Pass any `Prop<f32>` or `Signal<f32>` source.
3559    pub fn set_blur(&mut self, id: WidgetId, radius: impl Into<crate::signal::Prop<f32>>) {
3560        let prop = radius.into();
3561        prop.register_if_bound(
3562            id,
3563            &self.binding_registry,
3564            crate::binding::BindingLevel::RepaintOnly,
3565        );
3566        if let Some(node) = self.arena.get_mut(id) {
3567            node.blur_prop = Some(prop);
3568        }
3569    }
3570
3571    /// Bind a widget's enabled state to a boolean prop or compatibility state binding.
3572    /// When false, the widget and its entire subtree ignore all events but remain
3573    /// visible. Focus traversal skips disabled subtrees and AccessKit marks their
3574    /// nodes as disabled. Accepts `Signal<bool>`, `Prop<bool>`, compatibility state
3575    /// bindings, or plain `bool`.
3576    ///
3577    /// The bound signal registers at `BindingLevel::SubtreeRepaint`: when
3578    /// it flips, the entire subtree rooted at `id` is marked for repaint
3579    /// (not relayout — geometry doesn't change). Leaves like
3580    /// `IconWidget` then re-resolve their role color
3581    /// using the new `PaintContext::effective_enabled` value, so a
3582    /// disabled subtree's icons and text dim automatically.
3583    pub fn enabled_when(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
3584        let prop = state.into();
3585        // SubtreeRepaint propagates the visual dirty mark through the
3586        // disabled subtree so leaves re-resolve their role colors.
3587        prop.register_if_bound(
3588            id,
3589            &self.binding_registry,
3590            crate::binding::BindingLevel::SubtreeRepaint,
3591        );
3592        // AccessibilityOnly is orthogonal — it flips `a11y_dirty` so
3593        // AccessKit's `disabled` flag refreshes on the next a11y sync
3594        // (the accessibility walker reads `arena.is_enabled(id)`,
3595        // which is already correct via the prop, but the tree needs
3596        // to be told to rebuild).
3597        prop.register_if_bound(
3598            id,
3599            &self.binding_registry,
3600            crate::binding::BindingLevel::AccessibilityOnly,
3601        );
3602        if let Some(node) = self.arena.get_mut(id) {
3603            node.enabled_state = Some(prop);
3604        }
3605    }
3606
3607    /// Reactive view of "is this widget effectively enabled?" — the AND
3608    /// of the widget's own `enabled_state` and every ancestor's.
3609    /// [`Self::is_enabled`] is the non-reactive equivalent; this method
3610    /// gives composite widgets a `Signal<bool>` for derived state.
3611    ///
3612    /// Leaves (`IconWidget`, `TextWidget`, `RectWidget`) do NOT need this
3613    /// — they receive the resolved bool via
3614    /// [`crate::widget::PaintContext::effective_enabled`] at paint time.
3615    /// This method is for composites that want to derive cursor / custom
3616    /// paint roles / etc. reactively.
3617    ///
3618    /// Install-or-reuse, exactly like [`Self::activation_signal`]: the signal
3619    /// lives on the node and the framework refreshes it from the live arena
3620    /// once per state-change pass (`flush_effective_enabled_signals`).
3621    ///
3622    /// It is deliberately NOT a signal derived by walking the ancestor chain
3623    /// here. A widget's `parent` is still `None` while its own `build()` runs
3624    /// — `insert_widget` inserts the node parentless and wires the parent link
3625    /// only after `build()` returns — so an ancestor walk performed from
3626    /// inside `build()` (which is how every caller uses this) sees an empty
3627    /// chain and would capture the widget's OWN `enabled` prop as the whole
3628    /// answer, permanently. That was a real bug: a Button inside a disabled
3629    /// form stayed painted as if enabled.
3630    ///
3631    /// The value is seeded from the live arena and corrected on the next
3632    /// flush, so a first-`build()` caller (parent not yet wired) and a
3633    /// rebuild caller (parent wired) both converge before anything paints.
3634    pub fn effective_enabled_signal(&mut self, id: WidgetId) -> crate::signal::Signal<bool> {
3635        if let Some(existing) = self
3636            .arena
3637            .get(id)
3638            .and_then(|n| n.effective_enabled_signal.clone())
3639        {
3640            return existing;
3641        }
3642        // Seed from the live tree. Mid-`build()` the parent is not wired yet,
3643        // so this is the widget's own state only; `flush_effective_enabled_signals`
3644        // corrects it against the fully-wired tree before the first paint.
3645        let seed = self.arena.is_enabled(id);
3646        let sig = crate::signal::Signal::new(seed);
3647        let Some(node) = self.arena.get_mut(id) else {
3648            // Node missing (shouldn't happen in build) — hand back a detached
3649            // handle so the caller still gets a valid signal.
3650            return crate::signal::Signal::new(true);
3651        };
3652        node.effective_enabled_signal = Some(sig.clone());
3653        self.arena.watch_effective_enabled(id);
3654        sig
3655    }
3656
3657    /// Refresh every node-resident `effective_enabled_signal` against the live
3658    /// arena, firing observers only where the value actually changed.
3659    ///
3660    /// Unlike [`Self::flush_activation_signals`] this cannot be driven off a
3661    /// change queue: a node's `enabled_state` is a `Prop<bool>` that may be
3662    /// bound to an app `Signal` which flips without the arena being notified,
3663    /// so there is no mutation site at which to record a transition. Instead
3664    /// this recomputes the (cheap, `O(depth)`) ancestor AND for each opted-in
3665    /// node and diffs. Only nodes that called
3666    /// [`Self::effective_enabled_signal`] are visited, so a tree with no
3667    /// interactive widgets pays nothing.
3668    ///
3669    /// Values are collected first and set afterwards: a `Signal::set` observer
3670    /// may mutate the tree, and must not run while the arena is being walked —
3671    /// the same discipline as `flush_activation_signals` and the
3672    /// `focus_within` / `hover_within` updates.
3673    pub(crate) fn flush_effective_enabled_signals(&mut self) {
3674        self.arena.prune_effective_enabled_watchers();
3675        let mut updates: Vec<(crate::signal::Signal<bool>, bool)> = Vec::new();
3676        for id in self.arena.effective_enabled_watchers() {
3677            let Some(sig) = self
3678                .arena
3679                .get(id)
3680                .and_then(|n| n.effective_enabled_signal.clone())
3681            else {
3682                continue;
3683            };
3684            let now = self.arena.is_enabled(id);
3685            if sig.get() != now {
3686                updates.push((sig, now));
3687            }
3688        }
3689        for (sig, value) in updates {
3690            sig.set(value);
3691        }
3692    }
3693
3694    /// Whether a widget is effectively enabled. Returns `false` if the widget
3695    /// itself or any ancestor has `enabled_state` bound to `false`.
3696    pub fn is_enabled(&self, id: WidgetId) -> bool {
3697        self.arena.is_enabled(id)
3698    }
3699
3700    /// Bind a widget's Tab-key participation to a boolean prop or
3701    /// compatibility state binding. When false, the widget is removed
3702    /// from Tab / Shift+Tab traversal (`cycle_focus`) but remains
3703    /// reachable via `request_focus` and arrow-key navigation that
3704    /// calls `request_focus`. Implements the ARIA roving-tabindex
3705    /// pattern (HTML `tabindex="-1"` semantics). Accepts
3706    /// `Signal<bool>`, `Prop<bool>`, or plain `bool`.
3707    /// Publish what a data view's `Space` should do when the row containing
3708    /// `id` holds the keyboard cursor. See
3709    /// [`WidgetNode::keyboard_toggle`](crate::arena::WidgetNode).
3710    pub fn set_keyboard_toggle(&mut self, id: WidgetId, f: std::rc::Rc<dyn Fn()>) {
3711        if let Some(node) = self.arena.get_mut(id) {
3712            node.keyboard_toggle = Some(f);
3713        }
3714    }
3715
3716    /// The first keyboard-toggle action published in `root`'s subtree, in
3717    /// traversal order.
3718    ///
3719    /// Searched per keypress rather than cached: a data view rebuilds its rows
3720    /// as they realize, so an id recorded at build time would outlive the
3721    /// widget it named.
3722    pub fn keyboard_toggle_in(&self, root: WidgetId) -> Option<std::rc::Rc<dyn Fn()>> {
3723        if let Some(f) = self.arena.get(root).and_then(|n| n.keyboard_toggle.clone()) {
3724            return Some(f);
3725        }
3726        for &child in self.arena.children(root) {
3727            if let Some(found) = self.keyboard_toggle_in(child) {
3728                return Some(found);
3729            }
3730        }
3731        None
3732    }
3733
3734    pub fn set_tab_stop(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
3735        let prop = state.into();
3736        // Bind at the lightest level — tab-stop changes never affect
3737        // layout or paint; cycle_focus reads the current value on
3738        // each Tab keypress.
3739        prop.register_if_bound(
3740            id,
3741            &self.binding_registry,
3742            crate::binding::BindingLevel::RepaintOnly,
3743        );
3744        if let Some(node) = self.arena.get_mut(id) {
3745            node.tab_stop = Some(prop);
3746        }
3747    }
3748
3749    /// Current Tab-key participation for a widget. Returns the value
3750    /// of the `tab_stop` prop if bound, or `true` (the default) when
3751    /// no binding is present. Mirrors the filter used by
3752    /// `cycle_focus` — primarily for tests asserting the
3753    /// roving-tabindex contract.
3754    pub fn tab_stop(&self, id: WidgetId) -> bool {
3755        self.arena
3756            .get(id)
3757            .and_then(|node| node.tab_stop.as_ref())
3758            .map(|prop| prop.get())
3759            .unwrap_or(true)
3760    }
3761
3762    /// Declare `id` as a **traversal-scope boundary** with the given policy.
3763    /// `cycle_focus` then treats the node's subtree as an independent Tab
3764    /// group: `tab_index` values inside it are scoped (they never collide
3765    /// with sibling scopes) and `policy` governs Tab at the scope's ends.
3766    ///
3767    /// The scope node is forced non-focusable — it is a transparent boundary,
3768    /// never itself a Tab stop. Called from `BuildContext::set_traversal_scope`
3769    /// (which the `FocusScope` wrapper widget invokes during `build`), and
3770    /// directly usable from tests with no dependency on the widgets crate.
3771    pub fn set_traversal_scope(
3772        &mut self,
3773        id: WidgetId,
3774        policy: crate::focus::TraversalScopePolicy,
3775    ) {
3776        if let Some(node) = self.arena.get_mut(id) {
3777            node.node_traversal_scope = Some(policy);
3778            node.node_focusable = Some(false);
3779        }
3780    }
3781
3782    /// Remove a previously set traversal-scope marker from `id` (rebuild
3783    /// paths where a `FocusScope` is replaced by a non-scope widget). Leaves
3784    /// `node_focusable` untouched — a later handler-set application resets it.
3785    pub fn clear_traversal_scope(&mut self, id: WidgetId) {
3786        if let Some(node) = self.arena.get_mut(id) {
3787            node.node_traversal_scope = None;
3788        }
3789    }
3790
3791    /// Current traversal-scope policy on `id`, if any. For tests asserting
3792    /// the scope marker contract.
3793    pub fn traversal_scope(&self, id: WidgetId) -> Option<crate::focus::TraversalScopePolicy> {
3794        self.arena
3795            .get(id)
3796            .and_then(|node| node.node_traversal_scope)
3797    }
3798
3799    // --- Theme override ---
3800
3801    /// Set a theme override on a widget. All descendants of this widget
3802    /// will see the modified theme during layout and paint.
3803    /// The override function receives a mutable `Theme` to modify.
3804    ///
3805    /// ```
3806    /// # use teksilo_core::{Widget, LayoutResponse, LayoutContext, widget_tree::WidgetTree};
3807    /// # use teksilo_canvas::{Size, SizeProposal};
3808    /// # use teksilo_tokens::ColorTokens;
3809    /// # #[derive(Debug)] struct MinWidget;
3810    /// # impl Widget for MinWidget {
3811    /// #     fn layout_response(&self, _: SizeProposal, _: &LayoutContext) -> LayoutResponse {
3812    /// #         Size::new(0.0, 0.0).into()
3813    /// #     }
3814    /// # }
3815    /// # let mut tree = WidgetTree::new();
3816    /// # let panel_id = tree.add(MinWidget);
3817    /// tree.set_theme_override(panel_id, |theme| {
3818    ///     theme.colors = ColorTokens::dark_default();
3819    /// });
3820    /// ```
3821    pub fn set_theme_override(
3822        &mut self,
3823        id: WidgetId,
3824        f: impl Fn(&mut crate::styles::Theme) + 'static,
3825    ) {
3826        let had_override = self
3827            .arena
3828            .get(id)
3829            .is_some_and(|n| n.theme_override.is_some());
3830        if let Some(node) = self.arena.get_mut(id) {
3831            node.theme_override = Some(crate::environment::ThemeOverride { func: Box::new(f) });
3832            node.dirty.needs_layout = true;
3833            node.dirty.needs_paint = true;
3834        }
3835        if !had_override {
3836            self.arena.theme_override_count += 1;
3837        }
3838    }
3839
3840    /// Get the resolved theme for a specific widget (applying ancestor overrides).
3841    pub fn resolved_theme(&self, id: WidgetId) -> crate::styles::Theme {
3842        self.arena.resolve_theme(id, &self.theme).into_owned()
3843    }
3844}
3845
3846impl Default for WidgetTree {
3847    fn default() -> Self {
3848        Self::new()
3849    }
3850}
3851
3852#[cfg(test)]
3853mod activation_signal_tests {
3854    use super::*;
3855    use crate::build_context::BuildContext;
3856    use crate::signal::Signal;
3857    use crate::widget::{LayoutContext, LayoutResponse, Widget};
3858    use teksilo_canvas::SizeProposal;
3859
3860    /// A leaf that, on build, opts into its activation signal and mirrors it
3861    /// into an out-of-band signal the test can read.
3862    #[derive(Debug)]
3863    struct ActivationProbe {
3864        log: Signal<Vec<bool>>,
3865    }
3866
3867    impl Widget for ActivationProbe {
3868        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
3869            let id = ctx.self_id();
3870            let vis = ctx.activation_signal(id);
3871            let log = self.log.clone();
3872            ctx.effect(&vis, move |active| {
3873                let mut v = log.get();
3874                v.push(*active);
3875                log.set(v);
3876            });
3877            Vec::new()
3878        }
3879
3880        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
3881            proposal.resolve(10.0, 10.0).into()
3882        }
3883    }
3884
3885    /// A widget that enqueues a post-mount action via `run_after_mount` has it
3886    /// run exactly once, with a real `EventContext`, when the tree drains
3887    /// (`run_mount_actions`) — and `has_pending_mount_actions` reflects the
3888    /// queue state.
3889    #[derive(Debug)]
3890    struct MountActionProbe {
3891        ran: Signal<u32>,
3892    }
3893
3894    impl Widget for MountActionProbe {
3895        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
3896            let ran = self.ran.clone();
3897            ctx.run_after_mount(move |_ectx| ran.set(ran.get() + 1));
3898            Vec::new()
3899        }
3900
3901        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
3902            proposal.resolve(10.0, 10.0).into()
3903        }
3904    }
3905
3906    #[test]
3907    fn run_after_mount_runs_once_on_drain() {
3908        let mut tree = WidgetTree::new();
3909        let ran = Signal::new(0_u32);
3910        tree.add(MountActionProbe { ran: ran.clone() });
3911        tree.layout(SizeProposal::exact(100.0, 100.0));
3912
3913        // Queued during build, not yet run.
3914        assert!(tree.has_pending_mount_actions());
3915        assert_eq!(ran.get(), 0);
3916
3917        // Drain with a Noop sink (as headless callers do).
3918        tree.run_mount_actions(&mut crate::window::NoopWindowOps);
3919        assert_eq!(ran.get(), 1);
3920        assert!(!tree.has_pending_mount_actions());
3921
3922        // Draining again is a no-op (the queue is empty).
3923        tree.run_mount_actions(&mut crate::window::NoopWindowOps);
3924        assert_eq!(ran.get(), 1);
3925    }
3926
3927    #[test]
3928    fn activation_signal_fires_on_dormant_and_reactivate() {
3929        let mut tree = WidgetTree::new();
3930        let log = Signal::new(Vec::<bool>::new());
3931        let probe = tree.add(ActivationProbe { log: log.clone() });
3932
3933        // Gate the probe's visibility on a signal.
3934        let visible = Signal::new(true);
3935        tree.visible_when(probe, visible.clone());
3936        tree.layout(SizeProposal::exact(100.0, 100.0));
3937
3938        // Initially active: effect registration alone fires nothing.
3939        assert_eq!(log.get(), Vec::<bool>::new());
3940
3941        // Hide → dormant → activation signal false.
3942        visible.set(false);
3943        tree.layout(SizeProposal::exact(100.0, 100.0));
3944        assert_eq!(log.get(), vec![false]);
3945
3946        // Show → active → activation signal true.
3947        visible.set(true);
3948        tree.layout(SizeProposal::exact(100.0, 100.0));
3949        assert_eq!(log.get(), vec![false, true]);
3950
3951        // Redundant relayout while active fires nothing new.
3952        tree.layout(SizeProposal::exact(100.0, 100.0));
3953        assert_eq!(log.get(), vec![false, true]);
3954    }
3955
3956    /// **A node parked and re-woken before the flush never looks dormant.**
3957    ///
3958    /// `pending_activation_changes` is an append-only queue, so this records
3959    /// two edges; replaying them in order would hand observers a `false` that
3960    /// was already superseded — for a state signal that is a lie, not a
3961    /// history. `present_in_tree_modal_request` takes exactly this route on
3962    /// every dialog (build the content, park it, mount the scrim, wake it,
3963    /// *then* move focus in), and the stale `false` landed after the focus
3964    /// dispatch: a text editor's dormancy handler wiped the focus it had just
3965    /// been granted and the dialog opened with no caret.
3966    #[test]
3967    fn a_park_and_wake_inside_one_batch_fires_nothing() {
3968        let mut tree = WidgetTree::new();
3969        let log = Signal::new(Vec::<bool>::new());
3970        let probe = tree.add(ActivationProbe { log: log.clone() });
3971        tree.layout(SizeProposal::exact(100.0, 100.0));
3972        assert_eq!(log.get(), Vec::<bool>::new());
3973
3974        tree.set_dormant(probe);
3975        tree.activate(probe);
3976        tree.layout(SizeProposal::exact(100.0, 100.0));
3977        assert_eq!(
3978            log.get(),
3979            Vec::<bool>::new(),
3980            "a node that ends the batch where it started never observably \
3981             changed — firing the intermediate `false` makes observers act on \
3982             a state that was never visible",
3983        );
3984
3985        // The converse still reports: a batch with a *net* transition fires
3986        // once, with the state the node actually ended in.
3987        tree.activate(probe);
3988        tree.set_dormant(probe);
3989        tree.layout(SizeProposal::exact(100.0, 100.0));
3990        assert_eq!(
3991            log.get(),
3992            vec![false],
3993            "a net Active→Dormant batch must still report, exactly once",
3994        );
3995
3996        tree.activate(probe);
3997        tree.layout(SizeProposal::exact(100.0, 100.0));
3998        assert_eq!(log.get(), vec![false, true]);
3999    }
4000}
4001
4002#[cfg(test)]
4003mod visible_when_builder_tests {
4004    use super::*;
4005    use crate::signal::{Prop, Signal};
4006    use crate::widget_builder::WidgetBuilder;
4007
4008    /// `.visible_when(signal)` on any widget builder threads a *bound*
4009    /// visibility prop onto the inserted node — so `teksu!`'s property form
4010    /// (`Widget { visible_when: sig }`) reaches the same `node.visible_state`
4011    /// slot as the imperative `ctx.visible_when(id, sig)`.
4012    #[test]
4013    fn visible_when_builder_binds_node_visibility() {
4014        let mut tree = WidgetTree::new();
4015        let shown = Signal::new(false);
4016        let id = tree.add(crate::test_widgets::FillWidget::new().visible_when(shown.clone()));
4017
4018        let node = tree.arena.get(id).expect("node exists");
4019        assert!(
4020            matches!(node.visible_state, Some(Prop::Bound(_))),
4021            "`.visible_when(Signal)` must store a bound visibility prop"
4022        );
4023    }
4024
4025    /// A static `bool` is accepted too (`Prop::Static`), matching
4026    /// `ctx.visible_when` / `access_hidden` semantics.
4027    #[test]
4028    fn visible_when_builder_accepts_static_bool() {
4029        let mut tree = WidgetTree::new();
4030        let id = tree.add(crate::test_widgets::FillWidget::new().visible_when(false));
4031
4032        let node = tree.arena.get(id).expect("node exists");
4033        assert!(matches!(node.visible_state, Some(Prop::Static(false))));
4034    }
4035}
4036
4037#[cfg(test)]
4038mod text_scale_tests {
4039    use super::*;
4040
4041    #[test]
4042    fn effective_theme_starts_equal_to_theme() {
4043        let tree = WidgetTree::new();
4044        assert_eq!(
4045            tree.effective_theme.typography.body.size,
4046            tree.theme.typography.body.size
4047        );
4048        assert_eq!(tree.user_text_scale(), 1.0);
4049    }
4050
4051    #[test]
4052    fn effective_text_scale_and_signal_track_the_combined_factor() {
4053        let mut tree = WidgetTree::new();
4054        assert_eq!(tree.effective_text_scale(), 1.0);
4055        assert_eq!(tree.text_scale_signal().get(), 1.0);
4056
4057        tree.set_user_text_scale(1.5);
4058        assert!((tree.effective_text_scale() - 1.5).abs() < 0.001);
4059        assert!((tree.text_scale_signal().get() - 1.5).abs() < 0.001);
4060
4061        // OS preference multiplies in.
4062        tree.set_accessibility_preferences(false, false, 2.0);
4063        assert!((tree.effective_text_scale() - 3.0).abs() < 0.01);
4064        assert!((tree.text_scale_signal().get() - 3.0).abs() < 0.01);
4065    }
4066
4067    #[test]
4068    fn set_user_text_scale_scales_effective_typography() {
4069        let mut tree = WidgetTree::new();
4070        let base = tree.theme.typography.body.size;
4071        tree.set_user_text_scale(1.5);
4072        assert!((tree.effective_theme.typography.body.size - base * 1.5).abs() < 0.001);
4073        // The unscaled base theme is untouched.
4074        assert_eq!(tree.theme.typography.body.size, base);
4075    }
4076
4077    #[test]
4078    fn set_theme_preserves_existing_user_scale() {
4079        let mut tree = WidgetTree::new();
4080        tree.set_user_text_scale(2.0);
4081        let dark = crate::presets::intui::dark();
4082        let dark_base = dark.typography.body.size;
4083        tree.set_theme(dark);
4084        assert!((tree.effective_theme.typography.body.size - dark_base * 2.0).abs() < 0.001);
4085    }
4086
4087    #[test]
4088    fn os_text_scale_multiplies_with_user_scale() {
4089        let mut tree = WidgetTree::new();
4090        let base = tree.theme.typography.body.size;
4091        tree.set_user_text_scale(1.5);
4092        // OS preference reports 1.2 → combined 1.8.
4093        tree.set_accessibility_preferences(false, false, 1.2);
4094        assert!((tree.effective_theme.typography.body.size - base * 1.8).abs() < 0.01);
4095    }
4096
4097    #[test]
4098    fn same_scale_is_a_noop_and_factor_is_clamped() {
4099        let mut tree = WidgetTree::new();
4100        tree.set_user_text_scale(1.5);
4101        // Re-setting the same value should not panic / change anything.
4102        tree.set_user_text_scale(1.5);
4103        assert_eq!(tree.user_text_scale(), 1.5);
4104        // Out-of-range clamps into [0.25, 8.0].
4105        tree.set_user_text_scale(100.0);
4106        assert_eq!(tree.user_text_scale(), 8.0);
4107    }
4108}
4109
4110/// Covers the `WidgetTree`-level facts
4111/// `teksilo_app::WindowManager::request_redraw_needing_render` (the
4112/// targeted cross-window redraw added for shared-`Signal` dispatch
4113/// fan-out) relies on. Neither test touches windows at all — they exist
4114/// to pin down `needs_render()`'s contract in isolation, since
4115/// teksilo-app cannot stand up a real `PlatformWindow` in a unit test.
4116#[cfg(test)]
4117mod cross_window_redraw_signal_tests {
4118    use super::*;
4119    use crate::signal::Signal;
4120    use crate::test_widgets::{FillWidget, StackWidget};
4121    use teksilo_canvas::SizeProposal;
4122
4123    /// The premise the fix acts on, AND the trap a naive fix would fall
4124    /// into. A `Signal` shared by two independent trees (standing in for
4125    /// two windows) is supposed to dirty both when mutated, even though
4126    /// only one of them is the tree whose dispatch made the mutation —
4127    /// but `Signal::set` only flips a dirty flag on the signal itself and
4128    /// in the `BindingRegistry`; nothing walks that into a tree's
4129    /// `needs_layout` / `needs_paint` bits (what `needs_render()` reads)
4130    /// except that tree's OWN `process_state_changes`, run at the top of
4131    /// its OWN `layout()`. So immediately after the mutation, with
4132    /// neither tree having re-run `layout()`, BOTH read clean — a naive
4133    /// "just check `needs_render()`" cross-window redraw would see
4134    /// nothing to do and stay a permanent no-op. Once tree B's `layout()`
4135    /// runs (what `request_redraw_needing_render` does for every window
4136    /// before checking it), the same mutation is finally visible there.
4137    ///
4138    /// Each tree wraps its gated leaf in a `StackWidget` parent (rather
4139    /// than gating a bare root leaf) so the fact under test — an ACTIVE
4140    /// widget ending up dirty — is unambiguous: `any_needs_layout()` /
4141    /// `any_needs_paint()` only ever look at `Active` nodes, and a leaf
4142    /// that itself goes dormant is deliberately excluded from both (a
4143    /// hidden widget has nothing to paint). What must go dirty here is
4144    /// the STILL-ACTIVE stack, via `mark_ancestors_need_layout` — the
4145    /// same mechanism that makes a real window's content re-flow around
4146    /// a child that just appeared or disappeared.
4147    #[test]
4148    fn a_shared_signal_mutation_only_shows_up_after_that_trees_own_layout_reconciles_it() {
4149        let shared = Signal::new(true);
4150
4151        let mut tree_a = WidgetTree::new();
4152        let stack_a = tree_a.add(StackWidget::new());
4153        let id_a = tree_a.add_child(stack_a, FillWidget::new());
4154        tree_a.visible_when(id_a, shared.clone());
4155
4156        let mut tree_b = WidgetTree::new();
4157        let stack_b = tree_b.add(StackWidget::new());
4158        let id_b = tree_b.add_child(stack_b, FillWidget::new());
4159        tree_b.visible_when(id_b, shared.clone());
4160
4161        let proposal = SizeProposal::exact(100.0, 100.0);
4162
4163        // Bring both to the same clean baseline a real event loop reaches
4164        // after its initial layout + paint.
4165        tree_a.layout(proposal);
4166        tree_a.render();
4167        tree_b.layout(proposal);
4168        tree_b.render();
4169        assert!(!tree_a.needs_render(), "precondition: tree A starts clean");
4170        assert!(!tree_b.needs_render(), "precondition: tree B starts clean");
4171
4172        // Simulate a handler mutating the shared Signal during tree A's
4173        // dispatch. Neither tree re-runs layout() here yet.
4174        shared.set(false);
4175
4176        assert!(
4177            !tree_a.needs_render(),
4178            "the mutation alone does not retroactively dirty tree A either — \
4179             a Signal write cannot poke an arena directly, only the next \
4180             process_state_changes (inside layout()) can"
4181        );
4182        assert!(
4183            !tree_b.needs_render(),
4184            "and tree B reads exactly as clean as tree A does at this point — \
4185             checking needs_render() without reconciling first cannot tell them apart"
4186        );
4187
4188        // ...but they are NOT indistinguishable to `needs_reconcile()`,
4189        // which is what `request_redraw_needing_render` actually gates
4190        // its reconcile on. Both trees observe the shared Signal, so
4191        // both report pending reactive work here — and asking is
4192        // read-only, so asking tree A first does not answer for tree B.
4193        assert!(
4194            tree_a.needs_reconcile() && tree_b.needs_reconcile(),
4195            "both trees must report pending reactive work from the shared write"
4196        );
4197
4198        // This is what `request_redraw_needing_render` does for a window
4199        // whose gate is open — reconcile at the window's OWN current
4200        // size, which is what walks a pending Signal-driven change into
4201        // the arena.
4202        tree_b.layout(proposal);
4203
4204        assert!(
4205            tree_b.needs_render(),
4206            "tree B, which merely OBSERVES the shared Signal, is now dirty — \
4207             this is the cross-tree fan-out request_redraw_needing_render \
4208             exists to notice (via its own reconcile-then-check) and repaint"
4209        );
4210        assert!(
4211            !tree_b.needs_reconcile(),
4212            "and having reconciled, tree B's gate closes again"
4213        );
4214        assert!(
4215            tree_a.needs_reconcile(),
4216            "while tree A — which has NOT reconciled — is still waiting; one \
4217             window's reconcile must never close another's gate"
4218        );
4219    }
4220
4221    /// The gate `request_redraw_needing_render` gained must stay SHUT for
4222    /// a window with nothing reactive pending, or it saves nothing: that
4223    /// method runs after every dispatched event, and an open gate costs
4224    /// a full `layout_with_ops` — a dozen per-frame passes (pending
4225    /// animations, frame tick, scheduler tick, drag tick,
4226    /// `process_state_changes`, tooltips, four overlay passes) before it
4227    /// reaches the geometry short-circuit, for every open window, on
4228    /// every event of a fast mouse-move stream.
4229    #[test]
4230    fn needs_reconcile_is_false_for_an_idle_tree() {
4231        let shared = Signal::new(true);
4232        let mut idle = WidgetTree::new();
4233        let stack = idle.add(StackWidget::new());
4234        let leaf = idle.add_child(stack, FillWidget::new());
4235        idle.visible_when(leaf, shared.clone());
4236        idle.layout(SizeProposal::exact(100.0, 100.0));
4237        idle.render();
4238
4239        assert!(!idle.needs_reconcile(), "nothing written — gate shut");
4240        assert!(!idle.needs_reconcile(), "and asking does not open it");
4241
4242        shared.set(false);
4243        assert!(idle.needs_reconcile(), "a write opens it");
4244        idle.layout(SizeProposal::exact(100.0, 100.0));
4245        assert!(!idle.needs_reconcile(), "reconciling closes it again");
4246    }
4247
4248    /// A *pending* `animate_to` contributes no scheduler deadline until
4249    /// `process_pending_animations` promotes it, so `next_timer_deadline`
4250    /// (and therefore `request_redraw_due`) cannot see it. The gate must,
4251    /// or arming an animation from another window's handler would leave
4252    /// it parked until something unrelated woke the window.
4253    #[test]
4254    fn needs_reconcile_sees_an_animation_armed_but_not_yet_started() {
4255        let mut tree = WidgetTree::new();
4256        let id = tree.add(FillWidget::new());
4257        tree.layout(SizeProposal::exact(50.0, 50.0));
4258        tree.render();
4259
4260        // Registered but deliberately NOT bound to any widget, so the
4261        // binding-registry term cannot be what notices it.
4262        let anim = Signal::new_animated(0.0_f32);
4263        tree.register_animated_signal(&anim, id);
4264        assert!(!tree.needs_reconcile(), "precondition: nothing armed yet");
4265
4266        anim.animate_to(
4267            1.0,
4268            std::time::Duration::from_millis(200),
4269            teksilo_tokens::Easing::Linear,
4270        );
4271        assert!(
4272            tree.needs_reconcile(),
4273            "an armed-but-unstarted animation is reactive work only layout() can pick up"
4274        );
4275        assert!(
4276            anim.has_pending_animation(),
4277            "and asking must not have consumed the request"
4278        );
4279
4280        tree.layout(SizeProposal::exact(50.0, 50.0));
4281        assert!(
4282            !anim.has_pending_animation(),
4283            "the reconcile promoted it into the scheduler"
4284        );
4285    }
4286
4287    /// An animation armed *after* the wall clock has overtaken the simulated one
4288    /// must still run.
4289    ///
4290    /// `layout` promotes a pending `animate_to` into the scheduler; once
4291    /// `tick_animations` is driving the tree, the scheduler is only ever ticked at
4292    /// `sim_clock`. Stamping the promotion with `Instant::now()` therefore put the
4293    /// start in the scheduler's *future* the moment a test's real time outran the
4294    /// simulated time it had asked for — and a start in the future does not run
4295    /// slow, it does not run at all. That made animated headless tests a function
4296    /// of machine load: green run alone, frozen once a full suite filled the cores
4297    /// and stretched each test's wall-clock time past its simulated budget.
4298    ///
4299    /// The two clocks below are the shape of that: 90 ms simulated against at
4300    /// least 100 ms real, so simulated time never catches up. Under the old
4301    /// behaviour the animation stays pinned at its start value forever.
4302    #[test]
4303    fn an_animation_armed_after_real_time_outran_the_sim_clock_still_runs() {
4304        let mut tree = WidgetTree::new();
4305        let id = tree.add(FillWidget::new());
4306        tree.layout(SizeProposal::exact(50.0, 50.0));
4307
4308        // Put the tree in simulated time, then let the wall clock get ahead of it.
4309        tree.tick_animations(std::time::Duration::from_millis(10));
4310        std::thread::sleep(std::time::Duration::from_millis(100));
4311
4312        let anim = Signal::new_animated(0.0_f32);
4313        tree.register_animated_signal(&anim, id);
4314        anim.animate_to(
4315            1.0,
4316            std::time::Duration::from_millis(50),
4317            teksilo_tokens::Easing::Linear,
4318        );
4319        tree.layout(SizeProposal::exact(50.0, 50.0));
4320
4321        // 80 ms of simulated time against a 50 ms animation: comfortably finished
4322        // on the only clock the scheduler is ever ticked with, and still short of
4323        // the ~100 ms of real time that has passed.
4324        tree.tick_animations(std::time::Duration::from_millis(80));
4325
4326        assert_eq!(
4327            anim.get(),
4328            1.0,
4329            "the animation must be measured against the clock it is ticked with, \
4330             not the wall clock that has already run past it"
4331        );
4332        assert!(
4333            !tree.has_active_animations(),
4334            "and having reached its target it must be off the scheduler"
4335        );
4336    }
4337
4338    /// `needs_render()` (paint/layout dirt only) must stay `false` while a
4339    /// per-frame `Signal<f32>` animation is merely *running*, with nothing
4340    /// new to paint. `request_redraw_needing_render` filters on
4341    /// `needs_render()`, not the broader `needs_redraw()`, precisely so a
4342    /// window with a live animation isn't forced into an extra immediate
4343    /// redraw on every sibling-window event — that would defeat the 60 Hz
4344    /// `WaitUntil` pacing those animations already get elsewhere and
4345    /// reintroduce the uncapped free-running redraw bug that pacing was
4346    /// written to remove.
4347    #[test]
4348    fn needs_render_excludes_a_running_animation_with_no_dirty_paint() {
4349        let mut tree = WidgetTree::new();
4350        let id = tree.add(FillWidget::new());
4351        tree.layout(SizeProposal::exact(50.0, 50.0));
4352        tree.render();
4353        assert!(!tree.needs_render(), "precondition: tree starts clean");
4354        assert!(!tree.needs_redraw(), "precondition: nothing running yet");
4355
4356        let anim = Signal::new_animated(0.0_f32);
4357        tree.register_animated_signal(&anim, id);
4358        anim.animate_to(
4359            1.0,
4360            std::time::Duration::from_millis(200),
4361            teksilo_tokens::Easing::Linear,
4362        );
4363        // `process_pending_animations` (which picks up the pending
4364        // `animate_to` and starts it on the scheduler) runs inside `layout`.
4365        tree.layout(SizeProposal::exact(50.0, 50.0));
4366
4367        assert!(
4368            tree.needs_redraw(),
4369            "an animation just started, so needs_redraw() (has_running()) must be true"
4370        );
4371        assert!(
4372            !tree.needs_render(),
4373            "but nothing is actually dirty for paint/layout — needs_render() must stay false, \
4374             which is the whole point of using it (not needs_redraw()) as the cross-window filter"
4375        );
4376    }
4377}
4378
4379#[cfg(test)]
4380mod effective_enabled_signal_tests {
4381    use super::*;
4382    use crate::build_context::BuildContext;
4383    use crate::signal::Signal;
4384    use crate::widget::{LayoutContext, LayoutResponse, Widget};
4385    use std::cell::RefCell;
4386    use std::rc::Rc;
4387    use teksilo_canvas::SizeProposal;
4388
4389    type SignalSlot = Rc<RefCell<Option<Signal<bool>>>>;
4390
4391    /// A leaf that opts into `effective_enabled_signal` from inside its own
4392    /// `build()` — the only way real widgets use it, and the case that was
4393    /// broken. It publishes the handle so the test can read the live value.
4394    #[derive(Debug)]
4395    struct EnabledProbe {
4396        out: SignalSlot,
4397    }
4398
4399    impl Widget for EnabledProbe {
4400        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
4401            let id = ctx.self_id();
4402            let sig = ctx.effective_enabled_signal(id);
4403            // Also pins that the signal is *mutable*: the previous derived
4404            // implementation panicked here with "observe() is only supported
4405            // on mutable signals", which is why widgets could not use
4406            // `ctx.effect` to react to being disabled.
4407            ctx.effect(&sig, |_| {});
4408            *self.out.borrow_mut() = Some(sig);
4409            Vec::new()
4410        }
4411
4412        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
4413            proposal.resolve(10.0, 10.0).into()
4414        }
4415    }
4416
4417    /// A composite that adds the probe through the ordinary `ctx.add` idiom, so
4418    /// the child is inserted PARENTLESS and builds before its parent link is
4419    /// wired — the exact situation that defeated the old
4420    /// walk-the-ancestors-at-call-time implementation.
4421    #[derive(Debug)]
4422    struct Form {
4423        enabled: Signal<bool>,
4424        out: SignalSlot,
4425    }
4426
4427    impl Widget for Form {
4428        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
4429            let id = ctx.self_id();
4430            ctx.enabled_when(id, self.enabled.clone());
4431            vec![ctx.add(EnabledProbe {
4432                out: self.out.clone(),
4433            })]
4434        }
4435
4436        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
4437            proposal.resolve(10.0, 10.0).into()
4438        }
4439    }
4440
4441    fn mount_form(enabled: Signal<bool>) -> (WidgetTree, WidgetId, Signal<bool>) {
4442        let out: SignalSlot = Rc::new(RefCell::new(None));
4443        let mut tree = WidgetTree::new();
4444        let form = tree.add(Form {
4445            enabled,
4446            out: out.clone(),
4447        });
4448        tree.layout(SizeProposal::exact(100.0, 100.0));
4449        let sig = out.borrow().clone().expect("probe published its signal");
4450        (tree, form, sig)
4451    }
4452
4453    /// THE REGRESSION. A widget whose own `enabled` is untouched must still
4454    /// report disabled when an ANCESTOR is disabled. This failed before the
4455    /// signal became node-resident: `insert_widget` inserts the node with
4456    /// `parent: None` and wires the parent only after `build()` returns, so an
4457    /// ancestor walk done during `build()` saw an empty chain and captured
4458    /// "enabled" for the widget's whole life.
4459    #[test]
4460    fn tracks_an_ancestor_disabled_before_mount() {
4461        let (_tree, _form, sig) = mount_form(Signal::new(false));
4462        assert!(
4463            !sig.get(),
4464            "a child of a disabled ancestor must report effectively-disabled"
4465        );
4466    }
4467
4468    /// The live case: the ancestor's bound signal flips after mount. The child
4469    /// must follow, in both directions, with no rebuild.
4470    #[test]
4471    fn follows_an_ancestor_flipping_after_mount() {
4472        let enabled = Signal::new(true);
4473        let (mut tree, _form, sig) = mount_form(enabled.clone());
4474        assert!(sig.get(), "starts enabled");
4475
4476        enabled.set(false);
4477        tree.layout(SizeProposal::exact(100.0, 100.0));
4478        assert!(!sig.get(), "child follows the ancestor going disabled");
4479
4480        enabled.set(true);
4481        tree.layout(SizeProposal::exact(100.0, 100.0));
4482        assert!(sig.get(), "and follows it coming back");
4483    }
4484
4485    /// A widget's own `enabled_state` still works on its own.
4486    #[test]
4487    fn honours_the_widgets_own_state() {
4488        let out: SignalSlot = Rc::new(RefCell::new(None));
4489        let mut tree = WidgetTree::new();
4490        let probe = tree.add(EnabledProbe { out: out.clone() });
4491        tree.enabled_when(probe, false);
4492        tree.layout(SizeProposal::exact(100.0, 100.0));
4493        let sig = out.borrow().clone().unwrap();
4494        assert!(!sig.get(), "own enabled_state alone disables");
4495    }
4496
4497    /// The signal must agree with the paint-time bool the render walker
4498    /// computes. If they disagreed, role-driven chrome (which dims from
4499    /// `PaintContext::effective_enabled`) and signal-driven chrome (which dims
4500    /// from this signal) would grey out at different moments.
4501    #[test]
4502    fn agrees_with_the_paint_time_effective_enabled() {
4503        let enabled = Signal::new(true);
4504        let (mut tree, form, sig) = mount_form(enabled.clone());
4505        let probe = tree.children(form)[0];
4506
4507        for value in [false, true, false] {
4508            enabled.set(value);
4509            tree.layout(SizeProposal::exact(100.0, 100.0));
4510            assert_eq!(
4511                sig.get(),
4512                tree.is_enabled(probe),
4513                "signal and the arena's live is_enabled must agree (enabled={value})"
4514            );
4515        }
4516    }
4517
4518    /// Install-or-reuse: asking twice hands back the same signal.
4519    #[test]
4520    fn is_install_or_reuse() {
4521        let out: SignalSlot = Rc::new(RefCell::new(None));
4522        let mut tree = WidgetTree::new();
4523        let id = tree.add(EnabledProbe { out });
4524        let a = tree.effective_enabled_signal(id);
4525        let b = tree.effective_enabled_signal(id);
4526        assert!(
4527            Signal::same(&a, &b),
4528            "must hand back the same signal handle"
4529        );
4530    }
4531}