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