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