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