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