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