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