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