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_impl;
16mod drag_drop_impl;
17mod event_dispatch_impl;
18mod focus_impl;
19mod gesture_dispatch_impl;
20mod layout_impl;
21mod overlay_impl;
22mod query_impl;
23mod rendering_impl;
24mod test_api;
25
26/// The main widget tree orchestrating arena, layout, events, accessibility, and paint.
27/// Provides both the runtime API and the headless test API.
28struct AnimatedRegistration {
29 weak: crate::signal::WeakAnimatedSignal,
30 owner: WidgetId,
31}
32
33impl AnimatedRegistration {
34 fn same_signal(&self, signal: &crate::signal::Signal<f32>) -> bool {
35 self.weak.same_signal(signal)
36 }
37
38 fn is_alive(&self) -> bool {
39 self.weak.upgrade().is_some()
40 }
41
42 fn take_pending_animation(
43 &self,
44 ) -> Option<(
45 crate::signal::Signal<f32>,
46 crate::animation::AnimationRequest,
47 WidgetId,
48 )> {
49 let signal = self.weak.upgrade()?;
50 let request = signal.take_pending_animation()?;
51 Some((signal, request, self.owner))
52 }
53
54 /// Non-consuming counterpart to [`take_pending_animation`](Self::take_pending_animation),
55 /// for [`WidgetTree::needs_reconcile`] — which must be able to ask
56 /// "is there work here?" without doing any.
57 fn has_pending_animation(&self) -> bool {
58 self.weak
59 .upgrade()
60 .is_some_and(|signal| signal.has_pending_animation())
61 }
62}
63
64#[allow(clippy::type_complexity)]
65pub struct WidgetTree {
66 arena: WidgetArena,
67 /// Current theme value cached for `&Theme` accessors used by layout/paint
68 /// contexts and by widgets that need an immediate read. The reactive source
69 /// of truth is `theme_signal`; both are updated in lockstep by `set_theme`.
70 theme: Theme,
71 /// Reactive theme signal. Widgets that want their visual or derived state
72 /// to track theme changes bind to this signal or build derived signals via
73 /// `zip`/`map`. `set_theme` updates the signal (firing observers) without
74 /// rebuilding the widget tree, so interaction state (focus, scroll, expanded
75 /// panels, …) survives theme switches.
76 theme_signal: crate::signal::Signal<Theme>,
77 /// User-controlled global text-scale factor (`1.0` = 100 %). Layered on top
78 /// of the OS `text_scale_factor`: the two multiply. Set via
79 /// `set_user_text_scale`; persisted by the application through
80 /// `teksilo_settings::TEXT_SCALE_KEY`.
81 user_text_scale: f32,
82 /// Cached projection of `theme` whose `typography` is scaled by
83 /// `user_text_scale * text_scale_factor`. Recomputed by
84 /// `recompute_effective_theme` whenever the theme or either scale factor
85 /// changes; the layout and paint walkers read this instead of `theme` so
86 /// all text grows uniformly. Equal to `theme` when the combined factor is 1.
87 effective_theme: Theme,
88 /// The combined `user_text_scale * text_scale_factor`, cached so the
89 /// layout/paint context construction sites don't recompute it. The single
90 /// scalar published to widgets that size from a source *other* than
91 /// `Theme.typography` (icons, the rich-text engine, calendar constants,
92 /// scene text). Written alongside `effective_theme` in
93 /// `recompute_effective_theme`.
94 effective_text_scale: f32,
95 /// Reactive mirror of `effective_text_scale`, for build-time binders that
96 /// must react to a scale change without a rebuild path of their own (e.g.
97 /// `Calendar` binds this at `Rebuild` level). Fired by
98 /// `recompute_effective_theme`.
99 text_scale_signal: crate::signal::Signal<f32>,
100 /// Reactive window-active state (`focused AND not occluded`), the
101 /// occlusion-aware companion to `WindowState::focused` (which is raw OS
102 /// focus only). The single source of truth for "is this window active",
103 /// read by `is_window_active()` and published to widgets via
104 /// `window_active_signal()` / `BuildContext::window_active*` /
105 /// `PaintContext::window_active`. Drives caret hiding, selection
106 /// desaturation and `DimWhenInactive`. Starts `true` — winit may not send
107 /// `Focused(true)` for the first window, so a window must not be born
108 /// inactive. Mutated only by `set_window_active`.
109 window_active_signal: crate::signal::Signal<bool>,
110 text_backend: Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
111 focused: Option<WidgetId>,
112 /// Reactive mirror of `focused`. Same pattern as `hovered_signal`
113 /// — kept in sync via `set_focused`. Drives the inspector's Focus
114 /// tab without polling.
115 focused_signal: crate::signal::Signal<Option<WidgetId>>,
116 hovered: Option<WidgetId>,
117 /// Reactive mirror of `hovered`. Set whenever `hovered` changes
118 /// during dispatch / hit-test recovery so external observers
119 /// (notably the debug inspector's hover tooltip) can react without
120 /// polling. Held by handle so the field is a cheap clone.
121 hovered_signal: crate::signal::Signal<Option<WidgetId>>,
122 /// Last known pointer position from `PointerMove`. Used by
123 /// `revalidate_interaction_state` to re-hit-test the hover after
124 /// a rebuild shifts content under a stationary cursor — without
125 /// this, the next `Scroll` event routes to `focused` (or falls
126 /// through to an ancestor scrollable) instead of the item the
127 /// user is actually pointing at.
128 last_pointer_position: Option<teksilo_canvas::Point>,
129 /// A rebuild destroyed the focused widget: the subtree that owned focus,
130 /// remembered so the end of the layout pass can land focus back inside it.
131 ///
132 /// A rebuild allocates fresh `WidgetId`s for its children, so the focused
133 /// node dies and `revalidate_interaction_state` drops focus to `None`.
134 /// Leaving it there kicks the user out of the widget they were in — most
135 /// visibly, a popover that refreshes its content when it opens would throw
136 /// away the very row the popover had just focused, so the menu comes up with
137 /// no keyboard focus at all. Focus is re-entered *after* the layout walk
138 /// (see the tail of `layout_with_ops`), once the fresh children have real
139 /// bounds for the focus-driven scroll-into-view — the same shape as the
140 /// post-layout hover refresh next to it.
141 pending_focus_restore: Option<WidgetId>,
142 last_proposal: SizeProposal,
143 pending_modal_requests: Vec<crate::modal::QueuedModalRequest>,
144 pending_modal_dismissal: bool,
145 shortcut_registry: crate::shortcut::ShortcutRegistry,
146 /// Queue of intents awaiting dispatch. Populated either by the
147 /// keystroke interception path (`dispatch_event` for KeyDown) or
148 /// by handlers calling `ctx.send_intent(...)`. Drained between
149 /// event-handler calls by [`WidgetTree::drain_pending_intents`].
150 /// The tuple carries the source widget (dispatch anchor), the
151 /// intent itself, and the firing shortcut's
152 /// `propagate_when_disabled` policy.
153 pending_intents: Vec<(WidgetId, crate::intent::Intent, bool)>,
154 /// Window-global actions registered via
155 /// [`BuildContext::register_action_global`](crate::BuildContext::register_action_global).
156 /// Consulted as a fallback at the end of every intent dispatch — *after* the
157 /// source→root walk finds no consuming node action — so an app-global command
158 /// is reachable no matter where the intent originated (a menu-bar dropdown
159 /// overlay, deep content, a global shortcut anchored at the root). Each entry
160 /// is owned by the registering widget and torn down on its rebuild/destroy,
161 /// mirroring `register_shortcut_global`.
162 global_actions: Vec<(WidgetId, crate::action::Action)>,
163 /// The widgets that edit text, registered via
164 /// [`BuildContext::register_text_surface`](crate::BuildContext::register_text_surface).
165 ///
166 /// Owned by the registering widget and torn down on its rebuild/destroy,
167 /// exactly like `global_actions` above. Read through
168 /// [`WidgetTree::focused_text_surface`] by a host that has taken a text
169 /// chord — `Ctrl+Z`, `Ctrl+C` — for itself and owes every text widget in the
170 /// tree an answer about what happens to it. See
171 /// [`crate::text_surface`] for why the framework is the only place that
172 /// question can be answered completely.
173 text_surfaces: crate::text_surface::TextSurfaces,
174 /// Currently-armed key-capture slot. `Some` when
175 /// [`WidgetTree::begin_key_capture`] has been called and the
176 /// returned [`CaptureHandle`](crate::shortcut::CaptureHandle)
177 /// is still alive. The slot is shared (via `Rc`) with the handle
178 /// so dropping the handle cancels the capture, and calling
179 /// `begin_key_capture` again creates a fresh slot without
180 /// touching the previous one (whose handle, if dropped later,
181 /// only clears its own orphaned slot).
182 key_capture: Option<crate::shortcut::KeyCaptureSlot>,
183 binding_registry: crate::binding::BindingRegistry,
184 idle_queue: crate::idle::IdleQueue,
185 /// Simulated clock for deterministic time-dependent testing.
186 sim_clock: std::time::Instant,
187 /// Whether [`tick_animations`](Self::tick_animations) has ever driven this
188 /// tree — i.e. whether [`Self::sim_clock`], rather than the wall clock, is
189 /// the one animations are measured against. See [`Self::animation_clock`].
190 sim_driven: bool,
191 /// Overlay manager for tooltips, menus, popovers.
192 pub(crate) overlay_manager: crate::overlay::OverlayManager,
193 /// Tooltip attachments: (anchor_id, content_id, text, delay, hover_start, overlay_id).
194 tooltips: Vec<TooltipEntry>,
195 /// Simulated-clock end of the tooltip "reshow session". While any tip is
196 /// visible, or until this instant after the last tip dismissed, subsequent
197 /// anchors use `MotionTokens::tooltip_reshow_delay` instead of the full
198 /// initial delay (Windows `TTDT_RESHOW` behaviour).
199 tooltip_session_until_sim: Option<std::time::Instant>,
200 /// Real-clock counterpart of [`Self::tooltip_session_until_sim`].
201 tooltip_session_until_real: Option<std::time::Instant>,
202 /// The tooltip currently surfaced by keyboard menu navigation
203 /// (`show_highlight_tooltip`): `(overlay_id, content_id)`. At most one
204 /// is shown at a time; moving the highlight or closing the menu clears
205 /// it. Distinct from the hover/focus tooltip paths — this one is a
206 /// `Manual`-dismiss child of the menu overlay so a single Escape closes
207 /// the menu (and cascades the tooltip) rather than only the tooltip.
208 highlight_tooltip: Option<(crate::overlay::OverlayId, WidgetId)>,
209 /// How the currently focused widget gained focus.
210 focus_origin: Option<crate::focus::FocusOrigin>,
211 /// Input-modality "focus-visible" state: `true` after keyboard input,
212 /// `false` after pointer input. Focus rings (e.g. `StandardItem`'s current
213 /// row) show only while this is `true`, the standard `:focus-visible`
214 /// behaviour — so a mouse click selects without a ring, and keyboard
215 /// navigation reveals it.
216 focus_visible: crate::signal::Signal<bool>,
217 /// Active focus-scope stack during build. A data view pushes its scope
218 /// (`begin_view_focus`) around its row loop so each row reads *its view's*
219 /// focus deterministically — independent of arena parenting, which may not
220 /// be wired yet while rows build (docked / virtualized content). Drives
221 /// focus-aware selection + focus rings in `StandardItem`.
222 view_focus_stack: Vec<crate::signal::Signal<bool>>,
223 /// Layout direction for RTL/LTR support.
224 layout_direction: crate::environment::LayoutDirection,
225 /// Animation scheduler for smooth animated state and signal transitions.
226 animation_scheduler: crate::animation::AnimationScheduler,
227 /// Weakly tracked animated values from both state and signal APIs.
228 animated_values: Vec<AnimatedRegistration>,
229 /// Registry of shader-driven animated quads (opt-in alternative to
230 /// `Signal<f32>::animate_looping` for decorative motion — progress
231 /// sweeps, sprite-atlas frame cycling, future pulse/shimmer). The
232 /// scheduler-style signal path stays for everything else. Per-slot
233 /// `AnimParams` are ticked and attached to every `RenderFrame`
234 /// produced by `render()` — the renderer reads them from there.
235 animated_quads: crate::animated_quad::AnimatedQuadRegistry,
236 /// Per-frame-effect scheduler. Owns the registry of widgets that
237 /// asked for a frame-tick subscription (Pulse, Cycle, …). Sits
238 /// alongside `animation_scheduler` and `animated_quads` as the
239 /// third visibility-aware motion source — they all consult the
240 /// same [`motion_visibility`](crate::motion_visibility) helpers.
241 /// After every `render()` the tree calls
242 /// `FrameTickScheduler::should_arm_frame_tick` and re-arms
243 /// `frame_tick_requested` if any subscriber's owner was painted
244 /// this frame.
245 pub(crate) frame_tick_scheduler: crate::frame_tick_scheduler::FrameTickScheduler,
246 /// Monotonic counter bumped at the start of each `render()` call.
247 /// Each widget's `last_painted_epoch` is set to this value whenever
248 /// the paint pass (or the cache-hit early-out) confirms the widget
249 /// intersects the window viewport. The animation scheduler uses it
250 /// to detect and pause animations for widgets that have scrolled
251 /// off-screen. Starts at `0`, which serves as the "never painted"
252 /// sentinel; tests that only call `layout()` see the gate bypass.
253 paint_epoch: u64,
254 /// Cached accessibility tree update — only rebuilt when layout changes.
255 cached_a11y: Option<accesskit::TreeUpdate>,
256 /// Whether the accessibility tree needs rebuilding (set when layout runs).
257 a11y_dirty: bool,
258 /// Snapshot of `shortcut_registry.version()` at the last
259 /// `sync_accessibility` call. When the live version differs the
260 /// AT cache is dirtied, so widgets that bound their announced
261 /// shortcut via `access_shortcut_id(id)` track user rebinds
262 /// without any explicit signaling from the settings UI.
263 last_synced_shortcut_version: u64,
264 /// Snapshot of `locale_signal` at the last `sync_accessibility`
265 /// call. When the locale differs the AT cache is dirtied, so
266 /// `access_label(tr!(...))` (stored as a locale-bound
267 /// `Prop<String>`) re-resolves into the announced node — even on a
268 /// same-direction switch that doesn't rebuild the composite.
269 last_synced_locale: Option<String>,
270 /// Reverse map from synthetic (widget-emitted) AccessKit NodeIds
271 /// to the WidgetId that owns them. Rebuilt on every full
272 /// accessibility walk. `handle_accessibility_actions` uses this
273 /// to route an `ActionRequest` targeting a TextRun child back
274 /// to the owning rich-text editor, since synthetic NodeIds
275 /// can't be decoded back to a WidgetId by value alone.
276 pub(crate) synthetic_parent_map: std::collections::HashMap<accesskit::NodeId, WidgetId>,
277 /// Cached full render frame — reused when no widget needs painting.
278 /// `Rc<RenderFrame>` rather than `RenderFrame` so cache-hit frames
279 /// cost an atomic refcount bump instead of a deep clone of every
280 /// draw-command Vec. `render()` uses `Rc::make_mut` to update
281 /// `anim_params` in place when the tree is the sole owner (the
282 /// common case — the caller usually drops the previous frame
283 /// before calling render() again).
284 cached_frame: Option<std::rc::Rc<RenderFrame>>,
285 /// Widget that has captured the pointer (receives all PointerMove/PointerUp
286 /// regardless of hit-test). Set via `EventContext::capture_pointer()`.
287 pointer_captured_by: Option<WidgetId>,
288 /// Strict ancestors of the captured widget that carry a drag/swipe
289 /// recognizer, armed on `PointerDown` so an ancestor drag can still start
290 /// while a descendant tap holds the capture (tap-vs-drag disambiguation
291 /// across the hit-path). Innermost-first. Drained when a drag latches or
292 /// the pointer sequence ends. See `arm_drag_observers`.
293 drag_observers: Vec<WidgetId>,
294 /// Current cursor selected by hover/interaction routing.
295 current_cursor: crate::widget::CursorIcon,
296 /// Delayed overlay requests (e.g., submenu hover-open delay).
297 pending_delayed_overlays: Vec<PendingDelayedOverlay>,
298 /// Reusable scratch buffer for active-id snapshots taken on hot
299 /// paths that mutate per-widget state inside the loop
300 /// (`tick_gestures_with_ops`, post-render dirty-bit clear,
301 /// post-layout `needs_layout` clear). Cleared and refilled on
302 /// every use via `WidgetArena::fill_active_ids`. Previously these
303 /// sites called the allocating `arena.active_ids()` per frame,
304 /// which `perf record` ranked at ~13 % of CPU on the
305 /// `widget_catalog --tab animations` scene.
306 active_ids_scratch: Vec<WidgetId>,
307 /// Widgets currently carrying a non-`None` `EventHandlers::gesture_arena`.
308 /// Updated on attach (`ensure_gesture_arena` install) and on
309 /// teardown (rebuild / destroy / handler-clear). Every per-frame
310 /// gesture pass (`tick_gestures_with_ops`, `next_gesture_deadline`)
311 /// iterates this set instead of every active widget — most active
312 /// widgets have `gesture_arena = None`, so the savings come from
313 /// not even visiting them. Filtered by `arena.is_active(id)` at
314 /// iteration time so dormant entries don't fire (a widget can be
315 /// dormant while still holding its handlers).
316 gesture_owners: std::collections::HashSet<WidgetId>,
317 /// OS-level accessibility preferences (high contrast, reduced motion, text scale).
318 prefers_high_contrast: bool,
319 prefers_reduced_motion: bool,
320 text_scale_factor: f64,
321 /// Host window HiDPI device scale (physical px per logical px), fed by
322 /// `teksilo-app` before each layout. Surfaced to widgets via
323 /// `LayoutContext::scale_factor`. The widget tree is otherwise fully
324 /// logical (the renderer applies this scale at the vertex stage); this is
325 /// the escape hatch for widgets that must size a device-pixel OS resource
326 /// (e.g. a `WebView`'s native subview). 1.0 in headless / test contexts.
327 device_scale_factor: f32,
328 /// Active drag-and-drop session, if any.
329 pub(crate) active_drag: Option<crate::drag_state::DragSession>,
330 /// Source widget of an in-flight OS (outbound) drag that escalated past
331 /// the window boundary. Set only on the window that *started* the drag.
332 /// The in-app `active_drag` session is torn down at escalation (the OS owns
333 /// the pointer); this remembers who started it so the eventual `DragEnded`
334 /// can fire the source's `on_drag_ended`.
335 pub(crate) outbound_drag_source: Option<WidgetId>,
336 /// True while *this* window currently holds the re-entered internal session
337 /// for an in-flight app-originated OS drag (the OS drag wandered back over
338 /// this window — possibly a different window than the source — and we
339 /// restored the original typed payload). Distinguishes that session from a
340 /// plain internal drag so leaving again re-stashes instead of starting a
341 /// second OS drag, and dropping doesn't double-fire `on_drag_ended`.
342 pub(crate) os_drag_reentered: bool,
343 /// Optional platform host for custom window chrome (set when the
344 /// application opts in via `WindowConfig::custom_chrome(true)`). Stored
345 /// here so that the root-builder closure has access during widget
346 /// construction; the same `Rc` is also held by `WindowManager` so it
347 /// outlives the widget tree if needed.
348 title_bar_host: Option<Rc<dyn crate::PlatformTitleBarHost>>,
349 /// App-level subscription state: registered event source adapter,
350 /// proxy poster, UI-side subscription callbacks. Default is empty;
351 /// teksilo-app installs a populated context when an event source is
352 /// registered on the builder.
353 pub(crate) app_context: Rc<crate::event_source::TreeAppContext>,
354 /// Active locale identifier. Cached for `Option<&str>` accessors; the
355 /// reactive source of truth is `locale_signal`. Both are updated in
356 /// lockstep by `set_locale`.
357 pub(crate) locale: Option<String>,
358 /// Reactive locale signal. Widgets and `LocalizedString` adapters bind to
359 /// this signal to react to locale changes; `set_locale` updates the signal
360 /// without rebuilding the widget tree.
361 pub(crate) locale_signal: crate::signal::Signal<Option<String>>,
362 /// Per-frame delta-seconds signal, advanced by `layout()` **only when
363 /// a widget has explicitly requested a frame** via `request_frame()`.
364 /// This preserves Teksilo's draw-when-needed model: idle trees stay
365 /// idle even if widgets have registered observers on this signal.
366 pub(crate) frame_tick: crate::signal::Signal<f32>,
367 /// Set by `request_frame()`; consumed by `advance_frame_tick()` on
368 /// the next `layout()`. Observers that need another tick after the
369 /// current one must re-request. Stored as `Rc<Cell>` so observers
370 /// fired from inside the layout pass (`ctx.effect` closures on
371 /// `frame_tick`) can chain-request without needing &mut access
372 /// to the tree — see `FrameRequestHandle`.
373 pub(crate) frame_tick_requested: std::rc::Rc<std::cell::Cell<bool>>,
374 /// Debug-only re-entrancy flag: `true` while a focus-change dispatch
375 /// (`FocusGained` / `FocusLost` handlers) is running. Threaded into each
376 /// `EventContext` so `open_window` / `focus_window` can warn if a handler
377 /// changes context merely because a control gained focus (WCAG 3.2.1). A
378 /// shared `Rc<Cell<bool>>` (like `frame_tick_requested`) so the flag is
379 /// readable from an `EventContext` that holds no `&mut` to the tree.
380 pub(crate) in_focus_dispatch: std::rc::Rc<std::cell::Cell<bool>>,
381 /// Shared "accessibility re-walk requested" flag. Set via
382 /// [`request_accessibility_update`](Self::request_accessibility_update)
383 /// (or its `BuildContext` / `EventContext` wrappers) and drained at the
384 /// top of [`sync_accessibility`](Self::sync_accessibility) into
385 /// `a11y_dirty`. A relayout no longer re-walks the AT tree on its own, so
386 /// widgets that restructure their subtree in an AT-affecting way (e.g.
387 /// `SceneView` materialising / destroying scene widgets) need this lever.
388 /// `Rc<Cell>` so the shared `&self` paths can toggle it like
389 /// `frame_tick_requested`.
390 pub(crate) a11y_update_requested: std::rc::Rc<std::cell::Cell<bool>>,
391 /// Delayed frame wake-up deadline. Widgets that need to schedule
392 /// a future frame without pumping at full framerate (caret blink,
393 /// etc.) store the target instant here via
394 /// [`wake_at_handle`](Self::wake_at_handle). `next_timer_deadline`
395 /// rolls it into the event loop's WaitUntil; when reached, the
396 /// next `layout()` automatically re-arms `frame_tick_requested`
397 /// so the frame-tick effects run on the wake-up pass.
398 pub(crate) pending_wake_at: std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>,
399 /// One-shot post-mount actions enqueued during `build()` via
400 /// [`BuildContext::run_after_mount`](crate::BuildContext::run_after_mount), drained by the app loop (and by
401 /// tests) through [`WidgetTree::run_mount_actions`] with a real
402 /// [`EventContext`] — the only place a widget
403 /// can read the OS parent handle / app-state / poster together after it is
404 /// mounted. Used by widgets that own a native resource needing a window
405 /// handle to initialise (a `WebView`'s engine subview).
406 pub(crate) pending_mount_actions: Vec<Box<dyn FnOnce(&mut crate::widget::EventContext)>>,
407 /// Wall-clock time of the previous `layout()` call (for delta computation).
408 pub(crate) last_frame_time: Option<std::time::Instant>,
409 /// Set by [`EventContext::close_window`] during dispatch; drained
410 /// by the application event loop after each event via
411 /// [`WidgetTree::take_close_window_request`]. A *guarded* close
412 /// request — the app routes it through the window's close guard.
413 pub(crate) close_window_requested: bool,
414 /// Set by [`EventContext::close_window_forced`] during dispatch;
415 /// drained via [`WidgetTree::take_force_close_request`]. An
416 /// *unconditional* close request that bypasses the window's close
417 /// guard.
418 pub(crate) force_close_requested: bool,
419 /// Raised by [`EventContext::set_locale`] during dispatch; drained by
420 /// the application event loop (see
421 /// `WindowManager::drain_pending_locale_requests`) so the switch can be
422 /// routed through the `I18nManager` (active locale + version signal +
423 /// RTL direction). `WidgetTree::set_locale` alone would only update the
424 /// tree's local locale signal — the i18n thread-local would stay put
425 /// and `tr!` lookups would not re-resolve.
426 pub(crate) pending_locale_request: Option<String>,
427 /// Raised by [`EventContext::set_theme`] during dispatch; drained by
428 /// the application event loop (see
429 /// `WindowManager::drain_pending_theme_requests`) so the switch is
430 /// routed through `WindowManager::set_theme`, which fans the new theme
431 /// out to *every* window. Applying via `WidgetTree::set_theme` inline
432 /// would only re-theme the originating window — the rest of the app
433 /// would stay on the old theme. Mirrors `pending_locale_request`.
434 pub(crate) pending_theme_request: Option<crate::styles::Theme>,
435 /// Raised by [`EventContext::follow_system_theme`] during dispatch;
436 /// drained by the application event loop (see
437 /// `WindowManager::drain_pending_follow_system_requests`), which switches
438 /// the app to `ThemeMode::Native` and recomputes the theme from current
439 /// OS colours. Mirrors `pending_theme_request`.
440 pub(crate) pending_follow_system_request: bool,
441 /// Raised by [`EventContext::set_text_scale`] during dispatch; drained by
442 /// the application event loop (see
443 /// `WindowManager::drain_pending_text_scale_requests`) so the change is
444 /// routed through `WindowManager::set_text_scale`, fanning the new factor
445 /// out to *every* window. Mirrors `pending_theme_request`.
446 pub(crate) pending_text_scale_request: Option<f32>,
447 /// Monotonic version counter bumped after every *real* accessibility
448 /// rebuild in [`Self::sync_accessibility`] (cache hits don't bump).
449 /// Mirror of [`crate::shortcut::ShortcutRegistry::version`]: an
450 /// automation / test harness can poll it to know whether the AT tree
451 /// changed without diffing the whole `TreeUpdate`.
452 at_version: crate::signal::Signal<u64>,
453 /// Ring buffer of captured live-region announcements (see
454 /// [`crate::accessibility::Announcement`]). Filled by a `&mut self`
455 /// post-pass in `sync_accessibility` that diffs `Live::{Polite,
456 /// Assertive}` nodes against `automation_last_text`. Capped at
457 /// [`AUTOMATION_ANNOUNCE_CAP`]; drained by
458 /// [`Self::announcements_since`].
459 automation_announcements: std::collections::VecDeque<crate::accessibility::Announcement>,
460 /// Monotonic sequence number for the next announcement (starts at 0;
461 /// the first announcement is assigned `1`).
462 automation_announce_seq: u64,
463 /// Last announced text per live-region node, so a re-sync only emits a
464 /// new announcement when the text actually changes. Pruned each pass
465 /// to the set of currently-present live nodes, so a node that
466 /// disappears and reappears with the same text re-announces.
467 automation_last_text: std::collections::HashMap<accesskit::NodeId, String>,
468 /// Whether the `WidgetEvent::AccessAction` currently being dispatched was
469 /// consumed by a handler. Written by the dispatcher's `AccessAction` arm,
470 /// read (and reset) by [`Self::dispatch_access_action`], which is the only
471 /// caller that can answer "did anything happen?" to its own caller.
472 ///
473 /// A side channel because `dispatch_event_with_ops` returns `()` for every
474 /// event kind, and routing AT actions through the *same* path as everything
475 /// else is load-bearing (it is what lets an action open a window). The
476 /// alternative — reporting success whenever a live widget merely *existed*
477 /// at the target — is how an unhandled action came to look like a
478 /// successful one to every automation client.
479 access_action_handled: bool,
480 /// The `WindowState` for this tree's hosting window. Populated
481 /// by the app-level window manager when the tree is registered;
482 /// `None` for standalone trees. Cloned into every `EventContext`
483 /// and `BuildContext` so widgets can bind to the current window's
484 /// signals via `ctx.window()`.
485 pub(crate) window_state: Option<crate::window::WindowState>,
486}
487
488/// Maximum number of live-region [`crate::accessibility::Announcement`]s
489/// the [`WidgetTree`] retains. The oldest is evicted when the buffer is
490/// full; `announcements_since` only ever returns the retained tail.
491const AUTOMATION_ANNOUNCE_CAP: usize = 256;
492
493/// How long the shortened reshow delay stays active after the last tooltip
494/// dismisses. Long enough to cover moving between adjacent toolbar icons;
495/// short enough that a later, deliberate hover still pays the full initial
496/// delay. Not a theme token — it is session bookkeeping, not a visual feel.
497const TOOLTIP_SESSION_GRACE: std::time::Duration = std::time::Duration::from_millis(1000);
498
499/// Number of visible steps a sticky-on-dwell tooltip's promotion window is
500/// divided into.
501///
502/// The tree uses this only to decide *how often to wake* while a dwell is
503/// running — one redraw per step boundary rather than a free-run — but it must
504/// match the step count the content widget actually renders, or the indicator
505/// would advance on a different beat from the wake-ups driving it.
506/// `teksilo-widgets`' `DWELL_STEPS` is pinned to this value by a compile-time
507/// assertion; the per-step *duration* is derived from each entry's own
508/// `sticky_after`, so a caller that picks a non-default promotion window still
509/// gets correctly-spaced wake-ups.
510pub const TOOLTIP_DWELL_STEPS: u32 = 4;
511
512/// Max pointer travel (logical px) from the hover-origin before a pending
513/// tooltip timer restarts. Mirrors Windows hover-tracking slop
514/// (`SPI_GETMOUSEHOVERWIDTH` / height, typically ~4 px): the tip waits for a
515/// *paused* pointer, not merely "entered the bounds."
516const TOOLTIP_STATIONARY_SLOP: f32 = 4.0;
517
518/// A tooltip attachment managed by the WidgetTree.
519struct TooltipEntry {
520 anchor_id: WidgetId,
521 content_id: WidgetId,
522 /// The widget whose accessibility node should carry this tooltip's
523 /// description, which is not always the node the overlay hangs off.
524 ///
525 /// A composing control anchors the *overlay* on an inner chrome node it
526 /// built -- the thing with the right bounds to open a tooltip against --
527 /// while its role, its name and its focusability live on its own outer
528 /// node. A description on the inner one is a description an assistive
529 /// technology never reads, because it never lands there.
530 ///
531 /// Recorded by `BuildContext`'s `attach_tooltip*` wrappers as the widget
532 /// that was building at the time. Defaults to `anchor_id`, which is both
533 /// the historic behaviour and the right answer for a widget that anchors
534 /// its tooltip on itself.
535 ///
536 /// Naming an owner is a *claim*, not a guarantee: the accessibility walk
537 /// honours it only where exactly one tooltip claims that node. See
538 /// `WidgetTree::build_accessibility_recursive`.
539 description_owner_id: WidgetId,
540 delay: std::time::Duration,
541 /// Simulated hover start (for deterministic tests via advance_time).
542 hover_start: Option<std::time::Instant>,
543 /// Real hover start (for windowed apps via layout).
544 real_hover_start: Option<std::time::Instant>,
545 /// Pointer position when the current pending hover started. Used to
546 /// restart the delay if the pointer keeps moving inside the anchor
547 /// (stationary-pointer intent filter).
548 hover_origin: Option<teksilo_canvas::Point>,
549 overlay_id: Option<crate::overlay::OverlayId>,
550 /// When set, the tooltip auto-promotes to "sticky" after this
551 /// much elapsed time since it was shown. The entry stays in the
552 /// table and is just flagged sticky — the difference is that
553 /// pointer-leave no longer dismisses it and the overlay's
554 /// dismiss behavior is swapped to `EscapeOrClickOutside`.
555 sticky_after: Option<std::time::Duration>,
556 /// True when the dwell timer reached `sticky_after`. Causes
557 /// `tooltip_pointer_leave` to skip the dismissal and lets the
558 /// overlay survive pointer-leave until the user explicitly
559 /// dismisses it via Escape or a click outside.
560 is_sticky: bool,
561 /// When the overlay was shown (simulated). Together with
562 /// `sticky_after` drives auto-promotion.
563 shown_at_sim: Option<std::time::Instant>,
564 /// When the overlay was shown (real).
565 shown_at_real: Option<std::time::Instant>,
566 /// Optional shared sink the tooltip widget can read from to
567 /// compute its own dwell progress. Mirrors `shown_at_real`:
568 /// set on show, cleared on dismissal. Used by `RichTooltipWidget`
569 /// to drive the dwell indicator without relying on a fragile
570 /// paint-gap heuristic.
571 shown_at_sink: Option<std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>>,
572 /// True when the tooltip was shown by the keyboard-focus path
573 /// rather than the pointer-hover path. Focus-promoted tooltips
574 /// dismiss when focus moves outside both the anchor and the
575 /// tooltip content subtree (preventing accumulation as the user
576 /// Tabs through a form); pointer-dwelled stickies survive
577 /// focus changes and only dismiss via Escape or click-outside.
578 promoted_by_focus: bool,
579 /// Set while this entry's pending delay was started by keyboard focus
580 /// rather than by the pointer. Decides, at show time, that the surface
581 /// dismisses on `Escape`/click-outside rather than on pointer-leave (there
582 /// is no pointer in the story), and that it counts as focus-shown.
583 armed_by_focus: bool,
584 /// Set when the tip was dismissed while the focus that summoned it is
585 /// still inside its anchor — i.e. Escape on a focus-promoted tooltip.
586 ///
587 /// Escape restores focus to the anchor, and that restore runs the ordinary
588 /// focus path, which ends in `tooltip_focus_enter`. Without this flag the
589 /// tip the user just dismissed re-opens on the same keystroke, because
590 /// `dormant_dismissed_content` has already cleared `overlay_id` by then and
591 /// the entry looks eligible again. Cleared when focus genuinely leaves the
592 /// anchor (`tooltip_focus_leave_outside`), so Tabbing away and back
593 /// re-summons it normally. The hover path needs no equivalent: a stationary
594 /// pointer never re-fires `tooltip_pointer_enter`.
595 suppressed_until_focus_leaves: bool,
596 /// Where the tooltip opens relative to its anchor. `Below` (default)
597 /// for the common case; `Side` for anchors stacked vertically (menu
598 /// items, a vertical tab strip, list/tree rows) so the tooltip does
599 /// not cover the next sibling. Consulted at show time in both the
600 /// hover (`process_tooltips_impl`) and focus (`tooltip_focus_enter`)
601 /// paths.
602 placement: crate::overlay::TooltipPlacement,
603}
604
605/// A delayed overlay request (e.g., submenu hover-open delay).
606struct PendingDelayedOverlay {
607 request: crate::overlay::OverlayRequest,
608 delay: std::time::Duration,
609 focus_target: Option<WidgetId>,
610 /// When the request was made (real time, for windowed apps).
611 real_requested_at: std::time::Instant,
612 /// When the request was made (simulated time, for tests).
613 sim_requested_at: std::time::Instant,
614}
615
616impl WidgetTree {
617 pub fn new() -> Self {
618 let initial_theme = crate::presets::intui::light();
619 // One signal, shared: the field the tree writes and the registry the
620 // application reads must be the same one, or a host mirroring "is the
621 // caret in a text widget" would never see focus move.
622 let focused_signal = crate::signal::Signal::new(None);
623 Self {
624 arena: WidgetArena::new(),
625 theme: initial_theme.clone(),
626 theme_signal: crate::signal::Signal::new(initial_theme.clone()),
627 user_text_scale: 1.0,
628 effective_theme: initial_theme,
629 effective_text_scale: 1.0,
630 text_scale_signal: crate::signal::Signal::new(1.0),
631 // Starts active: winit may not send `Focused(true)` for the first
632 // window, so a window must not be born inactive (caret hidden,
633 // selection muted) before the first focus event arrives.
634 window_active_signal: crate::signal::Signal::new(true),
635 text_backend: None,
636 focused: None,
637 focused_signal: focused_signal.clone(),
638 hovered: None,
639 hovered_signal: crate::signal::Signal::new(None),
640 focus_visible: crate::signal::Signal::new(false),
641 view_focus_stack: Vec::new(),
642 last_pointer_position: None,
643 pending_focus_restore: None,
644 last_proposal: SizeProposal::exact(800.0, 600.0),
645 pending_modal_requests: Vec::new(),
646 pending_modal_dismissal: false,
647 shortcut_registry: crate::shortcut::ShortcutRegistry::new(),
648 pending_intents: Vec::new(),
649 global_actions: Vec::new(),
650 text_surfaces: crate::text_surface::TextSurfaces::new(focused_signal.clone()),
651 key_capture: None,
652 binding_registry: crate::binding::BindingRegistry::new(),
653 idle_queue: crate::idle::IdleQueue::new(),
654 sim_clock: std::time::Instant::now(),
655 sim_driven: false,
656 focus_origin: None,
657 overlay_manager: crate::overlay::OverlayManager::new(),
658 tooltips: Vec::new(),
659 tooltip_session_until_sim: None,
660 tooltip_session_until_real: None,
661 highlight_tooltip: None,
662 layout_direction: crate::environment::LayoutDirection::default(),
663 animation_scheduler: crate::animation::AnimationScheduler::new(),
664 animated_values: Vec::new(),
665 animated_quads: crate::animated_quad::AnimatedQuadRegistry::new(),
666 frame_tick_scheduler: crate::frame_tick_scheduler::FrameTickScheduler::new(),
667 paint_epoch: 0,
668 cached_a11y: None,
669 a11y_dirty: true,
670 last_synced_shortcut_version: 0,
671 last_synced_locale: None,
672 synthetic_parent_map: std::collections::HashMap::new(),
673 cached_frame: None,
674 pointer_captured_by: None,
675 drag_observers: Vec::new(),
676 current_cursor: crate::widget::CursorIcon::Default,
677 pending_delayed_overlays: Vec::new(),
678 active_ids_scratch: Vec::new(),
679 gesture_owners: std::collections::HashSet::new(),
680 prefers_high_contrast: false,
681 prefers_reduced_motion: false,
682 text_scale_factor: 1.0,
683 device_scale_factor: 1.0,
684 active_drag: None,
685 outbound_drag_source: None,
686 os_drag_reentered: false,
687 title_bar_host: None,
688 app_context: Rc::new(crate::event_source::TreeAppContext::empty()),
689 locale: None,
690 locale_signal: crate::signal::Signal::new(None),
691 frame_tick: crate::signal::Signal::new(0.0_f32),
692 frame_tick_requested: std::rc::Rc::new(std::cell::Cell::new(false)),
693 in_focus_dispatch: std::rc::Rc::new(std::cell::Cell::new(false)),
694 a11y_update_requested: std::rc::Rc::new(std::cell::Cell::new(false)),
695 pending_wake_at: std::rc::Rc::new(std::cell::Cell::new(None)),
696 pending_mount_actions: Vec::new(),
697 last_frame_time: None,
698 close_window_requested: false,
699 force_close_requested: false,
700 pending_locale_request: None,
701 pending_theme_request: None,
702 pending_follow_system_request: false,
703 pending_text_scale_request: None,
704 at_version: crate::signal::Signal::new(0),
705 automation_announcements: std::collections::VecDeque::new(),
706 automation_announce_seq: 0,
707 automation_last_text: std::collections::HashMap::new(),
708 access_action_handled: false,
709 window_state: None,
710 }
711 }
712
713 /// Construct an [`EventContext`]
714 /// pre-populated with the tree's app-state registry, hosting
715 /// `WindowState`, and a `&mut dyn WindowOps` handle so handlers
716 /// can synchronously reach the multi-window API. Used by every
717 /// dispatch site.
718 pub(crate) fn make_event_context<'ops>(
719 &self,
720 ops: &'ops mut dyn crate::window::WindowOps,
721 ) -> crate::widget::EventContext<'ops> {
722 let drag_is_external = self.active_drag.as_ref().is_some_and(|d| d.is_external);
723 // Read-only snapshot of tree query state that handlers may
724 // need synchronously. Today this carries the last pointer
725 // position and a (content_id, bounds) slice of open overlays
726 // — both read by the safe-triangle submenu hover gate.
727 let overlay_snapshot: Vec<(crate::widget_id::WidgetId, teksilo_canvas::Rect)> = self
728 .overlay_manager
729 .active_content_ids()
730 .into_iter()
731 .filter_map(|cid| {
732 self.overlay_manager
733 .bounds_for_content(cid)
734 .map(|r| (cid, r))
735 })
736 .collect();
737 crate::widget::EventContext::new()
738 .with_app_context(self.app_context.clone())
739 .with_window_context(ops, self.window_state.clone())
740 .with_drag_external(drag_is_external)
741 .with_query_snapshot(self.last_pointer_position, overlay_snapshot)
742 .with_layout_direction(self.layout_direction)
743 .with_window_active(self.is_window_active())
744 .with_focus_dispatch_flag(self.in_focus_dispatch.clone())
745 }
746
747 /// Run a closure with a fresh [`EventContext`] anchored at this
748 /// tree, then collect any pending operations queued through the
749 /// context (intents, modal requests, frame requests, idle
750 /// callbacks…) so they take effect on the next event-loop tick.
751 ///
752 /// Used by the `teksilo-app` event-loop dispatcher to deliver
753 /// async-result callbacks (file dialogs, future background
754 /// tasks) on the main thread with full handler-equivalent
755 /// semantics. There is no source widget for app-level events,
756 /// so intents are anchored at the tree's first root id (or
757 /// silently dropped when the tree is empty).
758 pub fn run_with_event_context<F>(&mut self, ops: &mut dyn crate::window::WindowOps, f: F)
759 where
760 F: FnOnce(&mut crate::widget::EventContext),
761 {
762 let mut ctx = self.make_event_context(ops);
763 f(&mut ctx);
764 let anchor = self.arena.roots().first().copied();
765 if let Some(anchor_id) = anchor {
766 self.collect_from_ctx(ctx, anchor_id);
767 } else {
768 // Empty tree — nothing to anchor intents on. Drop ctx;
769 // its only side effects (frame requests, cursor) are
770 // not meaningful for an empty tree.
771 drop(ctx);
772 }
773 }
774
775 /// Enqueue a one-shot action to run with a real
776 /// [`EventContext`] after the current build,
777 /// once the tree is mounted under its window. Used via
778 /// [`BuildContext::run_after_mount`](crate::BuildContext::run_after_mount). Drained by
779 /// [`Self::run_mount_actions`].
780 pub(crate) fn queue_mount_action(
781 &mut self,
782 action: Box<dyn FnOnce(&mut crate::widget::EventContext)>,
783 ) {
784 self.pending_mount_actions.push(action);
785 }
786
787 /// Whether any post-mount actions are waiting to run.
788 pub fn has_pending_mount_actions(&self) -> bool {
789 !self.pending_mount_actions.is_empty()
790 }
791
792 /// Drain and run every queued post-mount action with a fresh
793 /// [`EventContext`] built over `ops`. The app
794 /// loop calls this each iteration with a real `WindowOps` sink (so
795 /// `ctx.parent_window_handle()` resolves); headless tests call it with a
796 /// `NoopWindowOps`. Actions enqueued *by* an action (rare) are left for the
797 /// next drain rather than run re-entrantly.
798 pub fn run_mount_actions(&mut self, ops: &mut dyn crate::window::WindowOps) {
799 if self.pending_mount_actions.is_empty() {
800 return;
801 }
802 let actions = std::mem::take(&mut self.pending_mount_actions);
803 self.run_with_event_context(ops, move |ctx| {
804 for action in actions {
805 action(ctx);
806 }
807 });
808 }
809
810 /// Attach the [`WindowState`](crate::window::WindowState) for this
811 /// tree's hosting window. Called by `WindowManager::create_window`.
812 pub fn set_window_state(&mut self, state: crate::window::WindowState) {
813 self.window_state = Some(state);
814 }
815
816 pub fn window_state(&self) -> Option<&crate::window::WindowState> {
817 self.window_state.as_ref()
818 }
819
820 /// Clone the shared "frame requested" flag. Widgets stash this
821 /// in their state and call `.set(true)` from inside frame-tick
822 /// closures to chain-request another frame without needing
823 /// mutable access to the tree. See `RichTextEditor` for the
824 /// canonical use (caret blink, drag-select auto-scroll).
825 pub fn frame_request_handle(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
826 self.frame_tick_requested.clone()
827 }
828
829 /// Clone the shared wake-at deadline cell. Widgets stash this in
830 /// their state and call `request_wake_at` from frame-tick effects
831 /// to schedule a one-shot deadline without keeping the event loop
832 /// in `Poll` mode. On the next `layout()` at or past the deadline,
833 /// the tree auto-arms `frame_tick_requested` so the effect runs on
834 /// the wake-up pass. Canonical use: the rich text editor's caret
835 /// blink schedules a 500 ms wake instead of pumping every frame.
836 pub fn wake_at_handle(&self) -> std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>> {
837 self.pending_wake_at.clone()
838 }
839
840 /// Schedule a one-shot frame wake at `at`. Merges with any existing
841 /// deadline — keeps the earlier instant so the most urgent wake
842 /// wins.
843 pub fn request_wake_at(&self, at: std::time::Instant) {
844 let current = self.pending_wake_at.get();
845 let merged = match current {
846 Some(existing) if existing <= at => existing,
847 _ => at,
848 };
849 self.pending_wake_at.set(Some(merged));
850 }
851
852 /// The per-frame delta-seconds signal. Observers fire **only on frames
853 /// the tree was asked to pump** via [`request_frame`](Self::request_frame);
854 /// merely observing the signal does not keep the event loop awake.
855 /// See `BuildContext::frame_tick` for widget-side access and
856 /// `BuildContext::request_frame` for the opt-in request side.
857 pub fn frame_tick(&self) -> crate::signal::Signal<f32> {
858 self.frame_tick.clone()
859 }
860
861 /// Ask the tree to pump exactly one more frame. `needs_redraw()`
862 /// returns true until the request is consumed by the next
863 /// `layout()` call, which fires the per-frame tick signal and
864 /// clears the flag. Observers that still need more frames (drag
865 /// auto-scroll, caret blink, pending document events) must call
866 /// `request_frame()` again from inside their tick closure.
867 ///
868 /// Takes `&self` on purpose: widget handlers and per-frame effects
869 /// receive a shared reference to the tree via `EventContext` /
870 /// `BuildContext`, and the request flag is a `Cell` specifically so
871 /// those shared paths can toggle it without ceremony.
872 pub fn request_frame(&self) {
873 self.frame_tick_requested.set(true);
874 }
875
876 /// Request that the AccessKit tree be re-walked on the next
877 /// [`sync_accessibility`](Self::sync_accessibility). Takes `&self` (the
878 /// flag is a `Cell`) so handlers and `build()` closures reaching the tree
879 /// through a shared reference can request a re-walk without `&mut` access.
880 /// The drain at the top of `sync_accessibility` flips `a11y_dirty`.
881 pub fn request_accessibility_update(&self) {
882 self.a11y_update_requested.set(true);
883 }
884
885 /// Clone the shared "accessibility re-walk requested" flag, for the same
886 /// stash-and-toggle pattern as [`frame_request_handle`](Self::frame_request_handle).
887 pub fn a11y_request_handle(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
888 self.a11y_update_requested.clone()
889 }
890
891 /// Whether a frame was explicitly requested. Exposed for tests and
892 /// for the event-loop driver that decides when to schedule the next
893 /// wake-up.
894 pub fn frame_requested(&self) -> bool {
895 self.frame_tick_requested.get()
896 }
897
898 /// The next wake-up deadline for the per-frame-effect path, or
899 /// `None` when no per-frame effect is armed.
900 ///
901 /// This is the **60 Hz cap** for continuous per-frame animations
902 /// (`Pulse`, caret blink, drag auto-scroll, `--cycle` drivers). The
903 /// per-frame-effect path used to force `ControlFlow::Poll`, which
904 /// free-runs at the display's refresh rate — so on a 300 Hz panel a
905 /// single `Pulse`/`Cycle` rendered at 300 fps (measured ~45 % CPU) for
906 /// motion that looks identical at 60 fps. Routing it through a fixed
907 /// 16.667 ms deadline (folded into
908 /// [`next_timer_deadline`](Self::next_timer_deadline)) makes it pace at
909 /// 60 Hz regardless of refresh rate, matching the signal-tween
910 /// [`AnimationScheduler`](crate::animation::AnimationScheduler) and
911 /// shader-quad [`AnimatedQuadRegistry`](crate::animated_quad::AnimatedQuadRegistry),
912 /// which already share the same interval.
913 ///
914 /// A **throttled** subscriber (registered via
915 /// [`FrameTickScheduler::subscribe_throttled`](crate::frame_tick_scheduler::FrameTickScheduler::subscribe_throttled)
916 /// — e.g. `Cycle`, whose visible child only changes once per period)
917 /// stretches the deadline to its own interval: the loop then sleeps to
918 /// the period instead of rendering identical 60 fps frames in between.
919 /// The interval used is the **minimum across all currently-visible
920 /// subscribers**, so a `Cycle` next to a `Pulse` still ticks at 60 Hz
921 /// while a lone `Cycle` sleeps to its period. Raw `request_frame`
922 /// consumers with no subscription fall back to 60 Hz.
923 ///
924 /// Paces from `last_frame_time` so the cadence is drift-free; before
925 /// the first render it fires on the next loop turn.
926 pub fn frame_tick_deadline(&self) -> Option<std::time::Instant> {
927 // 60 Hz fallback for raw `request_frame` consumers (no subscriber).
928 const DEFAULT_INTERVAL: std::time::Duration = std::time::Duration::from_micros(16_667);
929 if !self.frame_tick_requested.get() {
930 return None;
931 }
932 let interval = self
933 .frame_tick_scheduler
934 .min_visible_interval(&self.arena, self.paint_epoch)
935 .unwrap_or(DEFAULT_INTERVAL);
936 Some(match self.last_frame_time {
937 Some(prev) => prev + interval,
938 None => std::time::Instant::now(),
939 })
940 }
941
942 /// Subscribe `owner` to the per-frame-effect scheduler. The
943 /// returned [`FrameTickSubscription`](crate::frame_tick_scheduler::FrameTickSubscription)
944 /// is an RAII guard — drop it (typically by replacing the field on
945 /// the owning widget on rebuild, or letting the widget's `Drop`
946 /// run) to remove the subscription. While the guard is alive, the
947 /// tree will keep arming `frame_tick_requested` after every render
948 /// in which `owner` was painted, and stop on frames where it
949 /// wasn't — so a subscribed widget hidden inside a non-selected
950 /// `Switcher` branch contributes zero idle frames.
951 ///
952 /// Apps should not call this directly — use
953 /// [`BuildContext::subscribe_frame_tick`](crate::build_context::BuildContext::subscribe_frame_tick)
954 /// from inside `Widget::build`.
955 pub fn subscribe_frame_tick(
956 &self,
957 owner: WidgetId,
958 ) -> crate::frame_tick_scheduler::FrameTickSubscription {
959 self.frame_tick_scheduler.subscribe(owner)
960 }
961
962 /// Like [`subscribe_frame_tick`](Self::subscribe_frame_tick), but the
963 /// owner only needs to wake **at most once per `interval`** while
964 /// visible. Same visibility gate; between wakes the event loop sleeps
965 /// to the interval deadline instead of rendering identical 60 fps
966 /// frames. Use for effects whose visible output changes far less often
967 /// than 60 Hz — e.g. `Cycle`'s once-per-period index advance.
968 ///
969 /// Apps should not call this directly — use
970 /// [`BuildContext::subscribe_frame_tick_throttled`](crate::build_context::BuildContext::subscribe_frame_tick_throttled)
971 /// from inside `Widget::build`.
972 pub fn subscribe_frame_tick_throttled(
973 &self,
974 owner: WidgetId,
975 interval: std::time::Duration,
976 ) -> crate::frame_tick_scheduler::FrameTickSubscription {
977 self.frame_tick_scheduler
978 .subscribe_throttled(owner, interval)
979 }
980
981 /// Advance the frame tick signal when (and only when) a frame was
982 /// requested. Called by `layout()` before the scheduler tick so the
983 /// per-frame observers fire on the same frame they asked for.
984 pub(crate) fn advance_frame_tick(&mut self, now: std::time::Instant) {
985 if !self.frame_tick_requested.get() {
986 self.last_frame_time = Some(now);
987 return;
988 }
989 self.frame_tick_requested.set(false);
990 let delta = match self.last_frame_time {
991 Some(prev) => {
992 let d = now.saturating_duration_since(prev).as_secs_f32();
993 // Clamp absurd deltas (pause/breakpoint) so observers never see a spike.
994 d.clamp(0.0, 0.1)
995 }
996 None => 0.0,
997 };
998 self.last_frame_time = Some(now);
999 self.frame_tick.set(delta);
1000 }
1001
1002 /// Replace the per-tree app context. Called by `teksilo-app` when
1003 /// constructing a window so the widget tree can reach the registered
1004 /// event source adapter and post subscription events through the
1005 /// event-loop proxy.
1006 pub fn set_app_context(&mut self, app_context: Rc<crate::event_source::TreeAppContext>) {
1007 self.app_context = app_context;
1008 }
1009
1010 /// Get the per-tree app context. Used by `BuildContext::subscribe_event`
1011 /// and by the event-loop handler when dispatching incoming
1012 /// `AppEvent::SubscriptionEvent`.
1013 pub fn app_context(&self) -> &Rc<crate::event_source::TreeAppContext> {
1014 &self.app_context
1015 }
1016
1017 /// Switch the tree-level locale at runtime.
1018 ///
1019 /// Updates `locale_signal` (a reactive `Signal<Option<String>>`) and marks
1020 /// all widgets dirty for relayout and repaint. Widgets are **not** rebuilt:
1021 /// per-string reactivity flows through `LocalizedString::to_signal()` which
1022 /// observes the teksilo-i18n manager, and anything else that depends on the
1023 /// tree-level locale can bind to `locale_signal()`.
1024 pub fn set_locale(&mut self, locale: String) {
1025 if self.locale.as_deref() == Some(locale.as_str()) {
1026 return;
1027 }
1028 let new = Some(locale);
1029 self.locale = new.clone();
1030 self.locale_signal.set(new);
1031 self.arena.mark_all_dirty();
1032 }
1033
1034 /// Currently active locale identifier, if any.
1035 pub fn locale(&self) -> Option<&str> {
1036 self.locale.as_deref()
1037 }
1038
1039 /// Reactive handle on the current locale. Mirrors `locale()` but updates
1040 /// observers when `set_locale` is called.
1041 pub fn locale_signal(&self) -> &crate::signal::Signal<Option<String>> {
1042 &self.locale_signal
1043 }
1044
1045 fn pointer_inside_overlay_region(
1046 &self,
1047 overlay_id: crate::overlay::OverlayId,
1048 position: Point,
1049 ) -> bool {
1050 let Some(overlay) = self
1051 .overlay_manager
1052 .stack
1053 .iter()
1054 .find(|overlay| overlay.id == overlay_id)
1055 else {
1056 return false;
1057 };
1058
1059 if self.arena.is_active(overlay.anchor)
1060 && self.arena.bounds(overlay.anchor).contains(position)
1061 {
1062 return true;
1063 }
1064
1065 self.overlay_manager.stack.iter().any(|candidate| {
1066 (candidate.id == overlay_id
1067 || self
1068 .overlay_manager
1069 .is_descendant_of(candidate.id, overlay_id))
1070 && candidate.bounds.contains(position)
1071 })
1072 }
1073
1074 fn update_pointer_leave_overlays(
1075 &mut self,
1076 position: Point,
1077 ops: &mut dyn crate::window::WindowOps,
1078 ) {
1079 let overlay_ids: Vec<crate::overlay::OverlayId> = self
1080 .overlay_manager
1081 .stack
1082 .iter()
1083 .filter(|overlay| {
1084 matches!(
1085 overlay.dismiss,
1086 crate::overlay::DismissBehavior::PointerLeave { .. }
1087 )
1088 })
1089 .map(|overlay| overlay.id)
1090 .collect();
1091
1092 let real_now = std::time::Instant::now();
1093 let sim_now = self.sim_clock;
1094
1095 for overlay_id in overlay_ids {
1096 let inside = self.pointer_inside_overlay_region(overlay_id, position);
1097 if let Some(overlay) = self
1098 .overlay_manager
1099 .stack
1100 .iter_mut()
1101 .find(|overlay| overlay.id == overlay_id)
1102 {
1103 if inside {
1104 overlay.pointer_leave_started_real = None;
1105 overlay.pointer_leave_started_sim = None;
1106 } else if overlay.pointer_leave_started_real.is_none() {
1107 overlay.pointer_leave_started_real = Some(real_now);
1108 overlay.pointer_leave_started_sim = Some(sim_now);
1109 self.arena.mark_needs_paint(overlay.anchor);
1110 }
1111 }
1112 }
1113
1114 self.process_pointer_leave_overlays_real(&mut *ops);
1115 }
1116
1117 fn process_pointer_leave_overlays(&mut self) {
1118 let sim_now = self.sim_clock;
1119 let mut noop = crate::window::NoopWindowOps;
1120 self.process_pointer_leave_overlays_impl(
1121 |overlay| {
1122 overlay
1123 .pointer_leave_started_sim
1124 .map(|started| sim_now.saturating_duration_since(started))
1125 },
1126 &mut noop,
1127 );
1128 }
1129
1130 fn process_pointer_leave_overlays_real(&mut self, ops: &mut dyn crate::window::WindowOps) {
1131 let real_now = std::time::Instant::now();
1132 self.process_pointer_leave_overlays_impl(
1133 |overlay| {
1134 overlay
1135 .pointer_leave_started_real
1136 .map(|started| real_now.saturating_duration_since(started))
1137 },
1138 &mut *ops,
1139 );
1140 }
1141
1142 fn process_auto_dismiss_overlays(&mut self) {
1143 let sim_now = self.sim_clock;
1144 let mut noop = crate::window::NoopWindowOps;
1145 self.process_auto_dismiss_overlays_impl(
1146 |overlay| {
1147 overlay
1148 .auto_dismiss_after
1149 .map(|_| sim_now.saturating_duration_since(overlay.shown_at_sim))
1150 },
1151 &mut noop,
1152 );
1153 }
1154
1155 fn process_auto_dismiss_overlays_real(&mut self, ops: &mut dyn crate::window::WindowOps) {
1156 let real_now = std::time::Instant::now();
1157 self.process_auto_dismiss_overlays_impl(
1158 |overlay| {
1159 overlay
1160 .auto_dismiss_after
1161 .map(|_| real_now.saturating_duration_since(overlay.shown_at_real))
1162 },
1163 &mut *ops,
1164 );
1165 }
1166
1167 /// Drain overlays whose fade-out tween has completed (set up by
1168 /// `OverlayRequest::with_fade`). Same dormant-and-restore-focus
1169 /// flow as the normal dismiss path; called once per layout pass
1170 /// after `process_auto_dismiss_overlays_real` so an overlay that
1171 /// hits its auto-dismiss deadline kicks off its fade-out tween
1172 /// in the same pass.
1173 pub(crate) fn process_overlay_fade_dismissals_real(
1174 &mut self,
1175 ops: &mut dyn crate::window::WindowOps,
1176 ) {
1177 let now = std::time::Instant::now();
1178 let pending = self.overlay_manager.process_pending_fade_dismissals(now);
1179 for (_id, dismissed, focus_restore) in pending {
1180 self.dormant_dismissed_content(&dismissed, &mut *ops);
1181 if let Some(restore_id) = focus_restore
1182 && self.arena.is_active(restore_id)
1183 {
1184 self.focus_ops(restore_id, &mut *ops);
1185 }
1186 }
1187 }
1188
1189 /// Sim-clock variant for headless tests. Same shape as
1190 /// [`process_overlay_fade_dismissals_real`](Self::process_overlay_fade_dismissals_real)
1191 /// but reads `dismissing_started_sim`.
1192 pub(crate) fn process_overlay_fade_dismissals_sim(&mut self) {
1193 let mut noop = crate::window::NoopWindowOps;
1194 let pending = self
1195 .overlay_manager
1196 .process_pending_fade_dismissals_sim(self.sim_clock);
1197 for (_id, dismissed, focus_restore) in pending {
1198 self.dormant_dismissed_content(&dismissed, &mut noop);
1199 if let Some(restore_id) = focus_restore
1200 && self.arena.is_active(restore_id)
1201 {
1202 self.focus_ops(restore_id, &mut noop);
1203 }
1204 }
1205 }
1206
1207 fn process_auto_dismiss_overlays_impl(
1208 &mut self,
1209 elapsed_fn: impl Fn(&crate::overlay::ActiveOverlay) -> Option<std::time::Duration>,
1210 ops: &mut dyn crate::window::WindowOps,
1211 ) {
1212 let mut to_dismiss = Vec::new();
1213
1214 for overlay in self.overlay_manager.stack.iter().rev() {
1215 let Some(delay) = overlay.auto_dismiss_after else {
1216 continue;
1217 };
1218
1219 if to_dismiss
1220 .iter()
1221 .any(|ancestor| self.overlay_manager.is_descendant_of(overlay.id, *ancestor))
1222 {
1223 continue;
1224 }
1225
1226 if let Some(elapsed) = elapsed_fn(overlay)
1227 && elapsed >= delay
1228 {
1229 to_dismiss.push(overlay.id);
1230 }
1231 }
1232
1233 for overlay_id in to_dismiss {
1234 let (dismissed, focus_restore) =
1235 self.overlay_manager.dismiss_with_focus_restore(overlay_id);
1236 self.dormant_dismissed_content(&dismissed, &mut *ops);
1237 if let Some(restore_id) = focus_restore
1238 && self.arena.is_active(restore_id)
1239 {
1240 self.focus_ops(restore_id, &mut *ops);
1241 }
1242 }
1243 }
1244
1245 fn process_pointer_leave_overlays_impl(
1246 &mut self,
1247 elapsed_fn: impl Fn(&crate::overlay::ActiveOverlay) -> Option<std::time::Duration>,
1248 ops: &mut dyn crate::window::WindowOps,
1249 ) {
1250 let mut to_dismiss = Vec::new();
1251
1252 for overlay in self.overlay_manager.stack.iter().rev() {
1253 let crate::overlay::DismissBehavior::PointerLeave { delay } = overlay.dismiss else {
1254 continue;
1255 };
1256
1257 if to_dismiss
1258 .iter()
1259 .any(|ancestor| self.overlay_manager.is_descendant_of(overlay.id, *ancestor))
1260 {
1261 continue;
1262 }
1263
1264 if let Some(elapsed) = elapsed_fn(overlay)
1265 && elapsed >= delay
1266 {
1267 to_dismiss.push(overlay.id);
1268 }
1269 }
1270
1271 for overlay_id in to_dismiss {
1272 let (dismissed, focus_restore) =
1273 self.overlay_manager.dismiss_with_focus_restore(overlay_id);
1274 self.dormant_dismissed_content(&dismissed, &mut *ops);
1275 if let Some(restore_id) = focus_restore
1276 && self.arena.is_active(restore_id)
1277 {
1278 self.focus_ops(restore_id, &mut *ops);
1279 }
1280 }
1281 }
1282
1283 pub fn with_theme(mut self, theme: Theme) -> Self {
1284 // Update both the cached `Theme` AND the reactive
1285 // `theme_signal` — widgets that observe the signal (e.g.
1286 // `TextInputField` resetting the rich-text engine's default
1287 // text colour) would otherwise see the constructor's
1288 // `light_default()` initial value forever, even when
1289 // `TeksiloAppBuilder.theme(crate::presets::intui::dark())` was used.
1290 // `set_theme` already does this; `with_theme` was the
1291 // builder-time analogue that forgot to keep them aligned.
1292 self.theme = theme.clone();
1293 self.theme_signal.set(theme);
1294 self.recompute_effective_theme();
1295 self
1296 }
1297
1298 pub fn with_text_backend(
1299 mut self,
1300 backend: Rc<RefCell<dyn teksilo_canvas::TextBackend>>,
1301 ) -> Self {
1302 self.text_backend = Some(backend);
1303 self
1304 }
1305
1306 /// Attach a platform host for custom window chrome. Set by the
1307 /// `WindowManager` when the application opts in via
1308 /// `WindowConfig::custom_chrome(true)`. Widgets like `TitleBar` retrieve
1309 /// it from inside the root-builder closure via [`Self::title_bar_host`].
1310 pub fn with_title_bar_host(mut self, host: Rc<dyn crate::PlatformTitleBarHost>) -> Self {
1311 self.title_bar_host = Some(host);
1312 self
1313 }
1314
1315 pub fn set_title_bar_host(&mut self, host: Rc<dyn crate::PlatformTitleBarHost>) {
1316 self.title_bar_host = Some(host);
1317 }
1318
1319 /// Get the platform title bar host, if one was attached. Returns `None`
1320 /// when the application did not opt into custom chrome, or when the
1321 /// platform does not support it (X11 without an EWMH-capable window
1322 /// manager, or a headless build).
1323 pub fn title_bar_host(&self) -> Option<Rc<dyn crate::PlatformTitleBarHost>> {
1324 self.title_bar_host.clone()
1325 }
1326
1327 pub fn theme(&self) -> &Theme {
1328 &self.theme
1329 }
1330
1331 /// Reactive handle on the current theme. Updates fire when `set_theme`
1332 /// is called; widgets that want theme-derived values to stay live should
1333 /// build derived signals via `theme_signal.map(...)` or combine with
1334 /// other inputs using `.zip(...)`.
1335 pub fn theme_signal(&self) -> &crate::signal::Signal<Theme> {
1336 &self.theme_signal
1337 }
1338
1339 /// Whether any widget needs layout or paint (i.e., a redraw would be useful).
1340 ///
1341 /// Uses `has_running` rather than `has_active` so that animations
1342 /// parked by the window-inactive gate stop forcing the event loop
1343 /// into `ControlFlow::WaitUntil`. Without this, an unfocused window
1344 /// would still wake at the animation frame interval and the
1345 /// pause would save nothing. Both the signal scheduler AND the
1346 /// shader-driven animated-quad registry are consulted — a
1347 /// ProgressBar::indeterminate whose widget has no pending paint
1348 /// dirt still needs the loop to keep waking at the animation
1349 /// frame interval so its phase advances.
1350 pub fn needs_redraw(&self) -> bool {
1351 self.arena.any_needs_layout()
1352 || self.arena.any_needs_paint()
1353 || self.animation_scheduler.has_running()
1354 || self.animated_quads.has_running()
1355 || self.frame_tick_requested.get()
1356 }
1357
1358 /// Whether a render pass is needed (any widget needs layout or paint).
1359 pub fn needs_render(&self) -> bool {
1360 self.arena.any_needs_layout() || self.arena.any_needs_paint()
1361 }
1362
1363 /// Whether this tree has reactive work that only a `layout()` pass can
1364 /// turn into arena dirt — i.e. whether reconciling it right now could
1365 /// change what [`needs_render`](Self::needs_render) reports.
1366 ///
1367 /// Read-only and cheap: `O(unique bound sources)` `u64` comparisons
1368 /// plus one peek per registered animated signal. No arena walk, no
1369 /// rebuilds, no geometry. Asking does not consume the answer, so it
1370 /// can be asked every dispatch.
1371 ///
1372 /// # Why this is exactly the right question, and no broader
1373 ///
1374 /// `teksilo_app::WindowManager::request_redraw_needing_render` exists
1375 /// for ONE case: a handler in window A wrote a `Signal` that window
1376 /// B's widgets also bind, and B — which never saw the event — must be
1377 /// reconciled before anyone can tell it needs repainting. Every OTHER
1378 /// thing `layout_with_ops` drives already has its own scheduling
1379 /// path and does not need this sweep:
1380 ///
1381 /// - tooltip dwell + sticky steps, delayed overlays, auto-dismiss,
1382 /// overlay fades, the animation scheduler, animated quads,
1383 /// gestures, `wake_at` and the 60 Hz frame tick are all timing
1384 /// driven, and every one of them contributes to
1385 /// [`next_timer_deadline`](Self::next_timer_deadline) — which
1386 /// `request_redraw_due` polls to wake precisely the due windows;
1387 /// - drag ticks follow that window's own pointer stream;
1388 /// - a handler that called `request_rebuild` marked the arena
1389 /// directly, so `needs_render()` is already true without any
1390 /// reconcile.
1391 ///
1392 /// So the two terms below are what the sweep uniquely covers:
1393 /// binding-registry staleness (the whole point), and a *pending*
1394 /// `animate_to` — which the scheduler has not started yet, so it
1395 /// contributes no deadline, and which only `process_pending_animations`
1396 /// (inside `layout`) can promote into one. The second term is
1397 /// belt-and-braces: arming an animation also advances the signal's
1398 /// generation, so a bound animated signal is already covered by the
1399 /// first — but an animated signal registered without being bound
1400 /// would not be, and this makes that impossible to get wrong.
1401 pub fn needs_reconcile(&self) -> bool {
1402 self.binding_registry.any_dirty()
1403 || self
1404 .animated_values
1405 .iter()
1406 .any(AnimatedRegistration::has_pending_animation)
1407 }
1408
1409 /// Register a `Signal<f32>` for animation support. The framework
1410 /// checks registered signals each frame for pending `animate_to`
1411 /// requests. Called automatically by `BuildContext::animated_signal()`
1412 /// — `owner` is `ctx.self_id()` of the widget whose `build()` created
1413 /// the signal. Used by the scheduler to pause/cancel animations when
1414 /// the owning widget is offscreen, dormant, or destroyed.
1415 pub fn register_animated_signal(
1416 &mut self,
1417 signal: &crate::signal::Signal<f32>,
1418 owner: WidgetId,
1419 ) {
1420 self.animated_values
1421 .retain(|registration| registration.is_alive());
1422 if let Some(existing) = self
1423 .animated_values
1424 .iter_mut()
1425 .find(|registration| registration.same_signal(signal))
1426 {
1427 // Signal may have been registered earlier with a placeholder
1428 // owner (e.g. a widget field constructed pre-build and
1429 // re-registered during build()) — prefer the latest owner.
1430 existing.owner = owner;
1431 return;
1432 }
1433 if let Some(weak_signal) = signal.weak_handle() {
1434 self.animated_values.push(AnimatedRegistration {
1435 weak: weak_signal,
1436 owner,
1437 });
1438 }
1439 }
1440
1441 /// Whether any animation is currently running.
1442 pub fn has_active_animations(&self) -> bool {
1443 self.animation_scheduler.has_active()
1444 }
1445
1446 /// The clock a newly promoted animation must be stamped with: the same one
1447 /// the scheduler will later be ticked against.
1448 ///
1449 /// Normally the wall clock. But once [`tick_animations`](Self::tick_animations)
1450 /// has driven this tree, the scheduler is *only* ever ticked at
1451 /// [`Self::sim_clock`] — so an animation stamped `Instant::now()` is measured
1452 /// against a clock that may never reach its start. A headless test
1453 /// interleaving `layout()` (which promotes) with `tick_animations()` (which
1454 /// ticks) advances the two clocks independently: simulated time by whatever
1455 /// the test asks for, real time by however long the test actually takes. The
1456 /// moment real time overtakes simulated time, every animation armed from then
1457 /// on has a start in the scheduler's future and its progress **freezes** —
1458 /// not slowly, completely, and no number of further ticks recovers it.
1459 ///
1460 /// That made animated layout tests fail as a function of machine load rather
1461 /// than of behaviour: green run alone or on a couple of threads, red once the
1462 /// runner filled the cores and each test's wall-clock time stretched past the
1463 /// simulated time it was asking for. The overlay manager already keeps its
1464 /// real and simulated timestamps apart for this reason; animations now agree
1465 /// on one clock the same way.
1466 fn animation_clock(&self) -> std::time::Instant {
1467 if self.sim_driven {
1468 self.sim_clock
1469 } else {
1470 std::time::Instant::now()
1471 }
1472 }
1473
1474 /// Pick up pending `animate_to` requests from registered signals
1475 /// and start them on the animation scheduler.
1476 fn process_pending_animations(&mut self) {
1477 let now = self.animation_clock();
1478 self.process_pending_animations_at(now);
1479 }
1480
1481 /// Pick up pending animations using the given time (for sim clock).
1482 fn process_pending_animations_at(&mut self, now: std::time::Instant) {
1483 let mut pending = Vec::new();
1484 self.animated_values.retain(|registration| {
1485 if let Some(animation) = registration.take_pending_animation() {
1486 pending.push(animation);
1487 true
1488 } else {
1489 registration.is_alive()
1490 }
1491 });
1492
1493 for (signal, req, owner) in pending {
1494 if req.looping {
1495 let start = signal.get();
1496 self.animation_scheduler.animate_looping(
1497 &signal,
1498 owner,
1499 start,
1500 req.target,
1501 req.duration,
1502 req.easing,
1503 req.frame_interval,
1504 req.epsilon,
1505 req.max_duration,
1506 now,
1507 );
1508 } else {
1509 self.animation_scheduler.animate_with_options(
1510 &signal,
1511 owner,
1512 req.target,
1513 req.duration,
1514 req.easing,
1515 req.frame_interval,
1516 req.epsilon,
1517 req.max_duration,
1518 now,
1519 );
1520 }
1521 }
1522 }
1523
1524 /// Mark the owning window as active (focused AND not occluded) or
1525 /// inactive. Propagates to the animation scheduler AND the
1526 /// animated-quad registry so both pause-resume in lockstep — no
1527 /// ticks, no frame wakes, no GPU submits.
1528 ///
1529 /// On an actual state change it also fires `window_active_signal`
1530 /// (so build-time binders and `DimWhenInactive` react) and issues a
1531 /// global paint-only dirty mark, so every widget that reads
1532 /// `PaintContext::window_active` (caret gates, selection bands) repaints
1533 /// once. This is a repaint, not a relayout — geometry never changes when
1534 /// the window's active state flips (the caret keeps its space). Window
1535 /// focus changes are rare and user-driven, so the O(n) mark is cheap and
1536 /// Mark every node paint-dirty (no relayout, no rebuild) so the next
1537 /// render re-runs their `paint()`. This is the paint-cache invalidation an
1538 /// off-thread source needs after posting a [`RepaintWindowRequest`](crate::RepaintWindowRequest):
1539 /// a bare redraw request re-presents the cached frame, so a widget whose
1540 /// content changed off the UI thread (a terminal's PTY output) must be
1541 /// marked dirty for its `paint()` to run again.
1542 pub fn mark_all_needs_paint_only(&mut self) {
1543 self.arena.mark_all_needs_paint_only();
1544 }
1545
1546 /// strictly lighter than `set_theme`'s `mark_all_dirty` (layout + paint).
1547 pub fn set_window_active(&mut self, active: bool) {
1548 let now = std::time::Instant::now();
1549 self.animation_scheduler.set_window_active(active, now);
1550 self.animated_quads.set_window_active(active, now);
1551 if self.window_active_signal.get() != active {
1552 self.window_active_signal.set(active);
1553 self.arena.mark_all_needs_paint_only();
1554 if !active {
1555 // The pointer has left for another window; the OS sends no
1556 // leave event we can rely on, so a tooltip shown at the moment
1557 // of the switch would float over the newly-focused window's
1558 // chrome with nothing left to dismiss it. Retire tips and
1559 // cancel pending dwells — but leave *sticky* ones, which the
1560 // user pinned deliberately and expects to find on return.
1561 self.tooltip_window_deactivated();
1562 // Same reasoning for a held pointer: a widget that captured
1563 // the pointer for a drag (a column-resize grip, a splitter
1564 // divider, a scrollbar thumb, a slider) will never see the
1565 // matching PointerUp — the user releases the button over the
1566 // window that took focus, and this window is told nothing.
1567 // Capture is otherwise cleared only by that Up or by the
1568 // widget going inactive, so leaving it set strands the whole
1569 // window: every subsequent PointerMove is redelivered to the
1570 // abandoned widget instead of hit-testing (killing hover,
1571 // cursor shapes and tooltips everywhere else), and the next
1572 // click's Up is swallowed by it, so the first press on any
1573 // release-activated control silently does nothing.
1574 self.pointer_captured_by = None;
1575 }
1576 }
1577 }
1578
1579 /// Whether the owning window is currently active (`focused AND not
1580 /// occluded`). The reactive companion is [`Self::window_active_signal`].
1581 pub fn is_window_active(&self) -> bool {
1582 self.window_active_signal.get()
1583 }
1584
1585 /// Reactive handle on window-active state. Fires when the window gains or
1586 /// loses active status. Bind at [`BindingLevel::RepaintOnly`] — an
1587 /// active-state flip never affects geometry. Starts `true`.
1588 ///
1589 /// [`BindingLevel::RepaintOnly`]: crate::binding::BindingLevel::RepaintOnly
1590 pub fn window_active_signal(&self) -> crate::signal::Signal<bool> {
1591 self.window_active_signal.clone()
1592 }
1593
1594 /// Register a new animated quad for the currently-building widget.
1595 /// Called by [`crate::build_context::BuildContext::animated_quad`];
1596 /// returns an opaque handle the widget stashes for its `paint()`
1597 /// call.
1598 pub fn register_animated_quad(
1599 &mut self,
1600 owner: WidgetId,
1601 kind: crate::animated_quad::AnimatedQuadKind,
1602 ) -> crate::animated_quad::AnimatedQuadHandle {
1603 self.animated_quads
1604 .register(owner, kind, std::time::Instant::now())
1605 }
1606
1607 /// Active animated-quad slot count. Test / debug helper.
1608 pub fn animated_quad_count(&self) -> usize {
1609 self.animated_quads.active_count()
1610 }
1611
1612 /// Advance time-driven gesture recognizers (currently only
1613 /// [`crate::gesture::LongPressRecognizer`]) across every widget that
1614 /// has a gesture arena. Must be called by the event loop on each
1615 /// wake-up; otherwise long-press will never fire during an idle hold.
1616 ///
1617 /// When a recognizer transitions to `Recognized`, the corresponding
1618 /// handler on the owning widget is invoked with a fresh
1619 /// [`EventContext`], and any commands / overlay requests it emits are
1620 /// collected through the normal post-event path.
1621 pub fn tick_gestures(&mut self, now: std::time::Instant) {
1622 let mut noop = crate::window::NoopWindowOps;
1623 self.tick_gestures_with_ops(now, &mut noop);
1624 }
1625
1626 /// App-facing variant of [`tick_gestures`](Self::tick_gestures)
1627 /// that accepts a real [`WindowOps`](crate::window::WindowOps)
1628 /// sink so gesture-recognized handlers can call the multi-window
1629 /// API synchronously.
1630 pub fn tick_gestures_with_ops(
1631 &mut self,
1632 now: std::time::Instant,
1633 ops: &mut dyn crate::window::WindowOps,
1634 ) {
1635 // Snapshot the gesture-owners set into the reusable scratch.
1636 // Previously this iterated every active widget; in practice
1637 // only a tiny fraction carry a gesture arena, so visiting the
1638 // rest was pure overhead.
1639 // `mem::take` lets the loop borrow `&mut self` for
1640 // `make_event_context` etc. without conflicting with the
1641 // scratch buffer; we put the storage back at the end.
1642 let mut ids = std::mem::take(&mut self.active_ids_scratch);
1643 ids.clear();
1644 ids.extend(
1645 self.gesture_owners
1646 .iter()
1647 .copied()
1648 .filter(|id| self.arena.is_active(*id)),
1649 );
1650 for &id in &ids {
1651 let gesture = match self.arena.get_mut(id) {
1652 Some(node) => node
1653 .handlers
1654 .gesture_arena
1655 .as_mut()
1656 .and_then(|arena| arena.tick(now)),
1657 None => None,
1658 };
1659 let Some(gesture) = gesture else { continue };
1660
1661 let mut ctx = self.make_event_context(&mut *ops);
1662 if let Some(node) = self.arena.get_mut(id) {
1663 Self::dispatch_recognized_gesture(node, gesture, &mut ctx);
1664 }
1665 self.collect_from_ctx(ctx, id);
1666 self.arena.mark_needs_paint(id);
1667 }
1668 self.active_ids_scratch = ids;
1669 }
1670
1671 /// Earliest wall-clock deadline at which any active gesture arena
1672 /// needs [`WidgetTree::tick_gestures`] called — typically a pending
1673 /// long-press timeout. Returns `None` when no recognizer is waiting.
1674 pub fn next_gesture_deadline(&self) -> Option<std::time::Instant> {
1675 // Iterate just the widgets that actually carry a gesture arena.
1676 // `filter` for `is_active` skips dormant entries that may still
1677 // be in the set after a hide-without-detach.
1678 self.gesture_owners
1679 .iter()
1680 .copied()
1681 .filter(|id| self.arena.is_active(*id))
1682 .filter_map(|id| self.arena.get(id))
1683 .filter_map(|node| node.handlers.gesture_arena.as_ref())
1684 .filter_map(|arena| arena.next_deadline())
1685 .min()
1686 }
1687
1688 /// Advance animations by simulated time (for deterministic testing).
1689 /// Pending `animate_to` requests are started at the current sim_clock,
1690 /// then time advances by `duration`, and the scheduler ticks at the new time.
1691 pub fn tick_animations(&mut self, duration: std::time::Duration) {
1692 // From here on this tree is simulation-driven: `layout` must stamp the
1693 // animations it promotes with `sim_clock` too, or they are measured
1694 // against a clock that never reaches them. See `animation_clock`.
1695 self.sim_driven = true;
1696 self.process_pending_animations_at(self.sim_clock);
1697
1698 self.sim_clock += duration;
1699 // Mirror onto the overlay manager so any fade-out tween
1700 // started during this tick stamps its sim-time start in
1701 // lockstep with real time.
1702 self.overlay_manager.set_sim_clock(self.sim_clock);
1703
1704 if self.frame_tick_requested.get() {
1705 self.frame_tick_requested.set(false);
1706 let delta = duration.as_secs_f32().clamp(0.0, 0.1);
1707 self.frame_tick.set(delta);
1708 }
1709
1710 self.animation_scheduler
1711 .tick(self.sim_clock, &self.arena, self.paint_epoch);
1712
1713 // Simulated-time test helper — use NoopWindowOps; tests that
1714 // need a real sink call layout_with_ops / dispatch_event_with_ops
1715 // themselves.
1716 let mut noop = crate::window::NoopWindowOps;
1717 self.process_state_changes(&mut noop);
1718 }
1719
1720 /// Switch the tree-level theme at runtime.
1721 ///
1722 /// Updates `theme_signal` (a reactive `Signal<Theme>`) and marks all widgets
1723 /// dirty for relayout and repaint. Widgets are **not** rebuilt: the
1724 /// `LayoutContext` and `PaintContext` already resolve the current theme on
1725 /// every pass, and any widget that derives state from theme tokens should
1726 /// do so through a `theme_signal()` subscription rather than a build-time
1727 /// capture. Preserves focus, scroll offsets, and other interaction state.
1728 pub fn set_theme(&mut self, theme: Theme) {
1729 self.theme = theme.clone();
1730 self.theme_signal.set(theme);
1731 self.recompute_effective_theme();
1732 self.arena.mark_all_dirty();
1733 }
1734
1735 /// Recompute [`Self::effective_theme`] from the current `theme` and the
1736 /// combined text scale (`user_text_scale * text_scale_factor`). Callers
1737 /// that change either input are responsible for `mark_all_dirty()`.
1738 fn recompute_effective_theme(&mut self) {
1739 let combined = (self.user_text_scale as f64 * self.text_scale_factor) as f32;
1740 self.effective_theme = if (combined - 1.0).abs() < f32::EPSILON {
1741 self.theme.clone()
1742 } else {
1743 let mut t = self.theme.clone();
1744 t.typography = t.typography.scaled(combined);
1745 t
1746 };
1747 // Single source: every downstream consumer (the layout/paint context
1748 // `text_scale` field, the reactive `text_scale_signal`) reads from here.
1749 self.effective_text_scale = combined;
1750 self.text_scale_signal.set(combined);
1751 }
1752
1753 /// The combined effective text scale (`user_text_scale * OS text_scale_factor`).
1754 /// Read by the layout/paint walkers to populate `ctx.text_scale` for widgets
1755 /// that size from a source other than `Theme.typography`.
1756 pub fn effective_text_scale(&self) -> f32 {
1757 self.effective_text_scale
1758 }
1759
1760 /// Reactive handle on [`Self::effective_text_scale`]. Build-time binders that
1761 /// must react to a scale change without their own rebuild path bind this
1762 /// (e.g. `Calendar` binds it at `Rebuild` level so its fixed cell constants
1763 /// recompute). Fires on `set_user_text_scale` / theme / OS-pref change.
1764 pub fn text_scale_signal(&self) -> crate::signal::Signal<f32> {
1765 self.text_scale_signal.clone()
1766 }
1767
1768 /// Set the user-controlled global text-scale factor (`1.0` = 100 %).
1769 ///
1770 /// The factor multiplies with the OS accessibility text-scale preference to
1771 /// produce the rendered scale. Recomputes the effective theme and marks all
1772 /// widgets dirty so every text widget grows on the next pass; no rebuild,
1773 /// so focus/scroll/interaction state survive. Values outside `[0.25, 8.0]`
1774 /// are clamped. Persisted by the application via
1775 /// `teksilo_settings::TEXT_SCALE_KEY`.
1776 pub fn set_user_text_scale(&mut self, factor: f32) {
1777 let clamped = factor.clamp(0.25, 8.0);
1778 if (self.user_text_scale - clamped).abs() < f32::EPSILON {
1779 return;
1780 }
1781 self.user_text_scale = clamped;
1782 self.recompute_effective_theme();
1783 self.arena.mark_all_dirty();
1784 }
1785
1786 /// The current user-controlled text-scale factor (`1.0` = 100 %).
1787 pub fn user_text_scale(&self) -> f32 {
1788 self.user_text_scale
1789 }
1790
1791 /// After a rebuild that destroyed subtrees — or after a `visible_when` /
1792 /// `Switcher` pass parks the focused widget dormant — drop any interaction
1793 /// state (focus, hover) whose target `WidgetId` is no longer active.
1794 ///
1795 /// **FocusLost is load-bearing.** Clearing `self.focused` alone leaves the
1796 /// widget's own `on_focus` / `has_focus` / caret-blink state thinking it is
1797 /// still focused. A rich-text editor in that state keeps scheduling
1798 /// `wake_at` caret toggles and re-arming `frame_request` from its tick
1799 /// effect — and because `frame_tick` observers are **not** gated on
1800 /// dormancy, every open tab's editor (TabWidget mounts them all) still runs
1801 /// on those wakes. Rapid tab switches that park a focused editor without a
1802 /// real focus move (programmatic selection, race with pointer focus) used
1803 /// to accumulate stuck "focused" editors and unbounded frame work. Dispatch
1804 /// `FocusLost` first so widgets clear that state, then drop the tree's
1805 /// focus pointer.
1806 ///
1807 /// Preserves state when the target still exists *and* is active. Called from
1808 /// data-driven rebuild paths (`process_state_changes`) and after every
1809 /// rebuild drain; theme and locale switches no longer rebuild.
1810 pub(crate) fn revalidate_interaction_state(&mut self, ops: &mut dyn crate::window::WindowOps) {
1811 if let Some(id) = self.focused
1812 && !self.arena.is_active(id)
1813 {
1814 let old = self.focused;
1815 // Deliver FocusLost while the node still exists (dormant or about to
1816 // be torn down). Skip if the node is already gone — destroy paths
1817 // take care of bookkeeping without a deliverable target.
1818 if self.arena.get(id).is_some() {
1819 // Direct: no bubble through dormant ancestors, no overlay
1820 // dismiss side-effects — this is a teardown signal, not a
1821 // user-driven focus move.
1822 self.dispatch_to_widget_direct(
1823 id,
1824 &crate::event::WidgetEvent::FocusLost,
1825 &mut *ops,
1826 );
1827 }
1828 self.set_focused(None);
1829 self.focus_origin = None;
1830 self.update_focus_within_signals(old, None);
1831 self.update_view_focus_signals(old, None);
1832 self.a11y_dirty = true;
1833 }
1834 if self.focused.is_none() {
1835 self.focus_origin = None;
1836 }
1837 if let Some(id) = self.hovered
1838 && !self.arena.is_active(id)
1839 {
1840 let old = self.hovered;
1841 self.set_hovered(None);
1842 self.update_hover_within_signals(old, None);
1843 }
1844 // Pointer capture anchored at a destroyed widget would otherwise
1845 // swallow every subsequent Move/Up — dispatch_to_widget rejects
1846 // inactive targets. Drop the capture so events resume normal
1847 // hit-test dispatch. Same for any in-flight drag session whose
1848 // source was torn down: the user sees the drag "stick".
1849 if let Some(id) = self.pointer_captured_by
1850 && !self.arena.is_active(id)
1851 {
1852 self.pointer_captured_by = None;
1853 }
1854 // External (OS) drags have no in-app source widget, so they are never
1855 // torn down by source destruction — only internal drags are salvaged.
1856 let source_gone = self
1857 .active_drag
1858 .as_ref()
1859 .and_then(|s| s.source_widget)
1860 .is_some_and(|sw| !self.arena.is_active(sw));
1861 if source_gone {
1862 // `cancel_active_drag` fires on_drag_leave on the current
1863 // target before cleanup — the same contract as Escape.
1864 self.cancel_active_drag(&mut *ops);
1865 } else {
1866 // The drag *source* survived but its current hover **target** was
1867 // torn down by the rebuild (e.g. a side disabled / collapsed
1868 // mid-drag destroyed the panel under the pointer). Clear the stale
1869 // target id so a subsequent drop doesn't resolve to a destroyed
1870 // widget (and silently vanish); the next move — or the drop's own
1871 // re-hit-test — re-engages a live target.
1872 let stale_target = self
1873 .active_drag
1874 .as_ref()
1875 .and_then(|d| d.current_target)
1876 .is_some_and(|t| !self.arena.is_active(t));
1877 if stale_target && let Some(drag) = self.active_drag.as_mut() {
1878 drag.current_target = None;
1879 }
1880 }
1881 }
1882
1883 /// Rebuild a single composite widget: destroy old children, re-run `build()`,
1884 /// and wire up new children. Called from `process_state_changes()` when a
1885 /// binding at `BindingLevel::Rebuild` fires (data-driven rebuild). Theme
1886 /// and locale changes do **not** rebuild — they update reactive signals
1887 /// that widgets bind to via `theme_signal()` / `locale_signal()`.
1888 /// Test-only: force-mark a widget for rebuild on the next layout
1889 /// pass. Lets regression tests exercise the rebuild path without
1890 /// needing to trip a Signal binding. Exposed cross-crate (not
1891 /// `#[cfg(test)]`-gated) so widget-crate tests in `teksilo-widgets`
1892 /// and elsewhere can also drive rebuilds; the `_for_testing`
1893 /// suffix marks it as not intended for application code.
1894 pub fn arena_mark_needs_rebuild_for_testing(&mut self, id: WidgetId) {
1895 self.arena.mark_needs_rebuild(id);
1896 }
1897
1898 /// Force a [`DeferredSubtree`](crate::deferred_subtree::DeferredSubtree) at
1899 /// `id` to build its content now. A no-op for any other widget.
1900 ///
1901 /// The framework's own door into deferred content, for the case where the
1902 /// decision to show is the tree's rather than a widget's: a tooltip whose
1903 /// dwell has just matured has no open signal anyone could have handed over.
1904 pub(crate) fn materialize_deferred(&mut self, id: WidgetId) {
1905 let forced = self
1906 .arena
1907 .get_mut(id)
1908 .and_then(|n| n.widget.as_any_mut())
1909 .and_then(|any| any.downcast_mut::<crate::deferred_subtree::DeferredSubtree>())
1910 .map(|deferred| {
1911 let needed = !deferred.is_materialized();
1912 deferred.force();
1913 needed
1914 })
1915 .unwrap_or(false);
1916 if forced {
1917 self.rebuild_single_widget(id);
1918 }
1919 }
1920
1921 pub(crate) fn rebuild_single_widget(&mut self, widget_id: WidgetId) {
1922 // Per §9.4.5, drop the source handle first (stops further source-side
1923 // dispatch) and then remove the UI-side callback. Either order gives
1924 // the same user-visible outcome for events that get posted between
1925 // the two steps, but dropping the source handle first stops the
1926 // publisher thread's work sooner.
1927 //
1928 // ⚠ That reasoning is about the two steps below, and it used to be
1929 // read as covering the whole problem. It does not. The dangerous gap
1930 // is not the microseconds between these two lines, it is the whole
1931 // span from *publish* to *dispatch*: a backend event is posted with
1932 // the id its publisher captured and is handled by the UI thread
1933 // frames later, so any rebuild in between used to strand it. The ids
1934 // are therefore carried across into the new build (see
1935 // `BuildContext::reusable_sub_ids`) rather than being retired here.
1936 // Skribisto's Analysis pane hit this every time it was the restored
1937 // view at project open: it starts a long operation in `build()` and
1938 // sets its own `Rebuild`-bound state signal, the operation finished
1939 // inside its own rebuild, and the pane sat on "Reading the
1940 // manuscript…" for the rest of the session with nothing logged.
1941 // Cancel any looping/one-shot animations owned by this widget
1942 // before build() runs. Without this, a widget that creates a
1943 // fresh `animated_signal` in build() would leak the previous
1944 // instance's scheduler entry: the old Signal<f32> clone lives
1945 // in `animations` forever, ticking against an orphaned signal
1946 // (silent CPU waste) and, for looping animations, doubling up
1947 // when the new one registers.
1948 self.animation_scheduler.cancel_by_widget(widget_id);
1949 // Same pattern for shader-driven animated quads: free the
1950 // widget's slot(s) so `build()` can allocate fresh handles.
1951 // The old cached_paint (if any) carries stale slot indices —
1952 // clear it so paint() re-runs and re-emits DrawCommands with
1953 // the newly-allocated slot.
1954 self.animated_quads.cancel_by_widget(widget_id);
1955
1956 let drained_subs = if let Some(node) = self.arena.get_mut(widget_id) {
1957 node.effect_handles.clear();
1958 node.actions.clear();
1959 node.dirty.needs_rebuild = false;
1960 node.cached_paint = None;
1961 node.dirty.needs_paint = true;
1962 // Reset only the OWN handler bucket so `apply_self_handlers`
1963 // during this build's fresh build() starts from empty and
1964 // doesn't stack N-fold handler chains across rebuilds.
1965 // `external_handlers` — set by the `WidgetBuilder` chain at
1966 // creation time or by a composing parent's
1967 // `apply_handlers(child_id, ...)` — persists: those handlers
1968 // come from outside the widget and aren't re-emitted by its
1969 // own `build()`.
1970 //
1971 // `node_focusable` / `node_tab_index` / `node_cursor` /
1972 // `clips_children` / `context_menu_factory` are simple
1973 // values, not accumulating closures. Leave them alone —
1974 // apply_self_handlers rewrites them if the new build
1975 // specifies non-None values; otherwise values from the
1976 // creation site survive the rebuild.
1977 node.handlers = crate::event_handlers::EventHandlers::new();
1978 std::mem::take(&mut node.subscription_handles)
1979 } else {
1980 Vec::new()
1981 };
1982 // Rebuild wiped the OWN handler bucket above (the gesture arena
1983 // lived there), so the widget no longer owns any recognizers.
1984 // The next pointer hit re-runs `ensure_gesture_arena` and
1985 // re-inserts if the new build still wires gesture handlers.
1986 // External handlers (set via the builder chain at creation time)
1987 // persist, but `external_handlers` never carries a gesture arena
1988 // directly — it's always built by `ensure_gesture_arena` into
1989 // the OWN bucket.
1990 self.gesture_owners.remove(&widget_id);
1991 // Shortcuts the widget declared are torn down too — they will
1992 // be re-registered during the upcoming `build()` call. User
1993 // overrides live in a separate map keyed by id, so user
1994 // rebindings survive this round-trip (see ShortcutRegistry
1995 // graveyard semantics).
1996 self.shortcut_registry.unregister_all_for_owner(widget_id);
1997 self.global_actions.retain(|(owner, _)| *owner != widget_id);
1998 self.text_surfaces.remove(widget_id);
1999 // Re-apply `Widget::declare_shortcuts` so the static metadata
2000 // survives the rebuild (the unregister above wiped both
2001 // declared and build-registered entries; build() will refill
2002 // the handler-bearing ones, but it can't be relied on to
2003 // refill the metadata-only declarations).
2004 self.apply_declared_shortcuts(widget_id);
2005 // Drop any signal→widget bindings from the previous build
2006 // cycle so `build()` can re-register a fresh set without
2007 // accumulating duplicates across rebuilds.
2008 self.binding_registry.unregister_for_widget(widget_id);
2009 // Kept, in order, and handed to the upcoming `build()` so it re-subscribes under
2010 // the same ids. A subscription's id is what a publisher captured and posted with;
2011 // minting new ones here would leave every event already queued for this widget
2012 // naming an id nothing answers to. See `BuildContext::reusable_sub_ids`.
2013 let mut reusable_sub_ids = Vec::with_capacity(drained_subs.len());
2014 for (sub_id, handle) in drained_subs {
2015 drop(handle);
2016 self.app_context
2017 .subscription_callbacks
2018 .borrow_mut()
2019 .remove(&sub_id);
2020 self.app_context
2021 .subscription_ctx_callbacks
2022 .borrow_mut()
2023 .remove(&sub_id);
2024 reusable_sub_ids.push(sub_id);
2025 }
2026
2027 // Decide how to treat the existing children. Two modes:
2028 //
2029 // * Default (`preserves_children_on_rebuild() == false`): the widget
2030 // re-derives its whole subtree, so tear down every old child up
2031 // front and let `build()` produce a fresh set.
2032 //
2033 // * Reconcile (`preserves_children_on_rebuild() == true`): the widget
2034 // re-attaches the children it keeps (by id) and drops the rest. We
2035 // snapshot the old children, run `build()`, then destroy only the
2036 // old children the new build did NOT re-attach and did NOT re-parent
2037 // elsewhere. Re-attached children keep their state (focus, scroll,
2038 // text, subscriptions); dropped children are reaped rather than left
2039 // as stranded, still-active orphans.
2040 let preserve_children = self
2041 .arena
2042 .get(widget_id)
2043 .map(|n| n.widget.preserves_children_on_rebuild())
2044 .unwrap_or(false);
2045 let old_children: Vec<WidgetId> = self.arena.children(widget_id).to_vec();
2046 if !preserve_children {
2047 for child_id in &old_children {
2048 self.destroy_subtree(*child_id);
2049 }
2050 }
2051
2052 // The parentless nodes the *previous* build owned. Taken now so
2053 // `build()` records its new set into an empty list, and destroyed after
2054 // it returns — by then the widget's own fields point at the new nodes,
2055 // so tearing the old ones down cannot strand a live id in the widget.
2056 // Both the `preserve_children` reconcile and the plain path want this:
2057 // detached content is rebuilt wholesale either way (it is not addressed
2058 // by id from the outside, so there is nothing to preserve).
2059 let old_detached: Vec<WidgetId> = self
2060 .arena
2061 .get_mut(widget_id)
2062 .map(|node| std::mem::take(&mut node.detached))
2063 .unwrap_or_default();
2064
2065 let mut widget_box = match self.arena.take_widget(widget_id) {
2066 Some(widget) => widget,
2067 None => return,
2068 };
2069
2070 let mut build_ctx = crate::build_context::BuildContext {
2071 tree: self,
2072 composite_id: Some(widget_id),
2073 effect_handles: Vec::new(),
2074 subscription_handles: Vec::new(),
2075 reusable_sub_ids,
2076 };
2077 let new_children = widget_box.build(&mut build_ctx);
2078 let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
2079 let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);
2080
2081 self.arena.restore_widget(widget_id, widget_box);
2082
2083 for &child_id in &new_children {
2084 if let Some(child_node) = self.arena.get_mut(child_id) {
2085 child_node.parent = Some(widget_id);
2086 }
2087 }
2088
2089 // Reconcile the preserve path: reap any old child the new build
2090 // dropped (not in `new_children`) and did not re-parent elsewhere
2091 // (its `parent` still points here). Authoritative parent pointers mean
2092 // a kept subtree re-parented out of a dropped sibling survives. Runs
2093 // before `node.children` is overwritten so the destroy walk can't see
2094 // the new list. Re-parented survivors already have their new parent by
2095 // now (`ctx.add` builds nested widgets synchronously and re-homes their
2096 // children), so the `parent == widget_id` test correctly excludes them.
2097 if preserve_children {
2098 let new_set: std::collections::HashSet<WidgetId> =
2099 new_children.iter().copied().collect();
2100 for &old_c in &old_children {
2101 if !new_set.contains(&old_c) && self.arena.parent(old_c) == Some(widget_id) {
2102 self.destroy_subtree_inner(old_c, true);
2103 }
2104 }
2105 }
2106
2107 if let Some(node) = self.arena.get_mut(widget_id) {
2108 node.children = new_children;
2109 node.effect_handles = effect_handles;
2110 node.subscription_handles = subscription_handles;
2111 }
2112
2113 // Reap the previous build's parentless content, now that the fresh set
2114 // is recorded and the widget points at it.
2115 for id in old_detached {
2116 self.destroy_subtree_inner(id, false);
2117 }
2118 }
2119
2120 /// Record that `owner` created and owns the parentless node `detached` —
2121 /// pre-built overlay content that is deliberately not a child. See
2122 /// [`BuildContext::add_detached`](crate::build_context::BuildContext::add_detached)
2123 /// and [`WidgetNode::detached`](crate::arena::WidgetNode).
2124 pub(crate) fn record_detached(&mut self, owner: WidgetId, detached: WidgetId) {
2125 if let Some(node) = self.arena.get_mut(owner) {
2126 node.detached.push(detached);
2127 }
2128 }
2129
2130 /// Destroy every parentless node `owner` owns, and forget them.
2131 ///
2132 /// Taken out of the node first: the destroy walk below can re-enter this
2133 /// function (a detached node may own detached nodes of its own — a rich
2134 /// tooltip's cascade children each pre-build their own), and it must not
2135 /// see a list it is halfway through consuming.
2136 fn destroy_detached_of(&mut self, owner: WidgetId) {
2137 let detached = self
2138 .arena
2139 .get_mut(owner)
2140 .map(|node| std::mem::take(&mut node.detached))
2141 .unwrap_or_default();
2142 for id in detached {
2143 self.destroy_subtree_inner(id, false);
2144 }
2145 }
2146
2147 /// Recursively destroy a subtree, dropping per-widget subscription
2148 /// handles and removing their UI-side callbacks. Use this in place of
2149 /// `arena.destroy()` whenever a widget that may have subscribed to
2150 /// events is being torn down.
2151 pub(crate) fn destroy_subtree(&mut self, widget_id: WidgetId) {
2152 self.destroy_subtree_inner(widget_id, false);
2153 }
2154
2155 /// Shared teardown for [`destroy_subtree`](Self::destroy_subtree) and the
2156 /// reconciling rebuild path. When `reparent_aware` is `true`, recursion
2157 /// descends into a child only if that child's `parent` still points at
2158 /// `widget_id`.
2159 ///
2160 /// A reconciling rebuild (a [`preserves_children_on_rebuild`] widget) may
2161 /// re-parent a kept subtree *out* of a dropped sibling and *into* the new
2162 /// tree. The dropped sibling's `children` list still lists that subtree
2163 /// (stale), so following it would tear down a node that is actually alive
2164 /// elsewhere. Following the authoritative `parent` pointer instead stops
2165 /// at the boundary of what genuinely still belongs to the node being
2166 /// destroyed. The per-node teardown ends with `arena.remove_node` (a
2167 /// single-node removal), NOT `arena.destroy` (which would re-recurse the
2168 /// stale `children` list and undo the skip).
2169 ///
2170 /// [`preserves_children_on_rebuild`]: crate::widget::Widget::preserves_children_on_rebuild
2171 fn destroy_subtree_inner(&mut self, widget_id: WidgetId, reparent_aware: bool) {
2172 // See the matching cancel in `rebuild_single_widget` — the
2173 // scheduler holds strong Signal<f32> clones, so the animation
2174 // would outlive its widget without this explicit cancellation.
2175 self.animation_scheduler.cancel_by_widget(widget_id);
2176 // Release the animated-quad slot(s) too.
2177 self.animated_quads.cancel_by_widget(widget_id);
2178 // A tooltip's content widget is parentless (`ctx.add`), so the child
2179 // walk below never reaches it — reap it explicitly or the entry and
2180 // its node outlive the anchor for the lifetime of the tree.
2181 self.retire_tooltips_of_destroyed_anchor(widget_id);
2182 // Same reasoning, one level up: every *other* parentless node this
2183 // widget built (a dropdown menu, a calendar, a tooltip's nested
2184 // cascade children) is unreachable from the child walk and dies here
2185 // or never.
2186 self.destroy_detached_of(widget_id);
2187
2188 let children: Vec<WidgetId> = self.arena.children(widget_id).to_vec();
2189 for child in children {
2190 if reparent_aware && self.arena.parent(child) != Some(widget_id) {
2191 // Re-parented into the surviving tree by this rebuild — leave it.
2192 continue;
2193 }
2194 self.destroy_subtree_inner(child, reparent_aware);
2195 }
2196 let drained_subs = self
2197 .arena
2198 .get_mut(widget_id)
2199 .map(|node| std::mem::take(&mut node.subscription_handles))
2200 .unwrap_or_default();
2201 for (sub_id, handle) in drained_subs {
2202 drop(handle);
2203 self.app_context
2204 .subscription_callbacks
2205 .borrow_mut()
2206 .remove(&sub_id);
2207 self.app_context
2208 .subscription_ctx_callbacks
2209 .borrow_mut()
2210 .remove(&sub_id);
2211 }
2212 // Drop any shortcuts the destroyed widget owned. Unlike
2213 // `rebuild_single_widget`, destruction is permanent; if the
2214 // user had overrides, they stay in the graveyard.
2215 self.shortcut_registry.unregister_all_for_owner(widget_id);
2216 self.global_actions.retain(|(owner, _)| *owner != widget_id);
2217 self.text_surfaces.remove(widget_id);
2218 // Bindings from this widget stop being relevant; clean them
2219 // up so the registry doesn't leak dead entries for the
2220 // lifetime of the app.
2221 self.binding_registry.unregister_for_widget(widget_id);
2222 // Keep `gesture_owners` honest — destroying the widget tears
2223 // down its handlers, so the per-frame gesture pass must stop
2224 // visiting it.
2225 self.gesture_owners.remove(&widget_id);
2226 // If focus pointed at the widget about to disappear, drop it
2227 // so later dispatch doesn't anchor intent walks at a dead id
2228 // (which would silently swallow the intent).
2229 if self.focused == Some(widget_id) {
2230 let old = self.focused;
2231 self.set_focused(None);
2232 self.focus_origin = None;
2233 self.update_focus_within_signals(old, None);
2234 self.update_view_focus_signals(old, None);
2235 }
2236 if self.hovered == Some(widget_id) {
2237 let old = self.hovered;
2238 self.set_hovered(None);
2239 self.update_hover_within_signals(old, None);
2240 }
2241 // Symmetric with focus/hover above: a pointer capture anchored at the
2242 // widget about to disappear would otherwise swallow every subsequent
2243 // Move/Up (dispatch rejects inactive targets) until the next layout
2244 // pass runs `revalidate_interaction_state`. Drop it eagerly so capture
2245 // never outlives its owner, even when a destroy happens mid-gesture.
2246 if self.pointer_captured_by == Some(widget_id) {
2247 self.pointer_captured_by = None;
2248 }
2249 // Single-node removal: this function already recursed into the
2250 // children above (honouring re-parenting when `reparent_aware`).
2251 // `arena.destroy` would re-recurse the now-stale `children` list and
2252 // tear down a survivor re-homed out of this subtree.
2253 self.arena.remove_node(widget_id);
2254 }
2255
2256 /// Set the layout direction (LTR/RTL). Marks all widgets as needing layout.
2257 pub fn set_layout_direction(&mut self, direction: crate::environment::LayoutDirection) {
2258 self.layout_direction = direction;
2259 self.arena.mark_all_dirty();
2260 }
2261
2262 /// The current layout direction.
2263 pub fn layout_direction(&self) -> crate::environment::LayoutDirection {
2264 self.layout_direction
2265 }
2266
2267 /// Set OS-level accessibility preferences.
2268 ///
2269 /// Called by `teksilo-app` after querying the platform layer. Updates the
2270 /// values fed into `PaintContext` and `Environment` on subsequent frames.
2271 /// Marks all widgets dirty so the new preferences take effect immediately.
2272 pub fn set_accessibility_preferences(
2273 &mut self,
2274 high_contrast: bool,
2275 reduced_motion: bool,
2276 text_scale_factor: f64,
2277 ) {
2278 let changed = self.prefers_high_contrast != high_contrast
2279 || self.prefers_reduced_motion != reduced_motion
2280 || (self.text_scale_factor - text_scale_factor).abs() > f64::EPSILON;
2281
2282 if changed {
2283 self.prefers_high_contrast = high_contrast;
2284 self.prefers_reduced_motion = reduced_motion;
2285 self.text_scale_factor = text_scale_factor;
2286 // The OS factor feeds the effective text scale (multiplied with the
2287 // user factor), so refresh the cached scaled typography.
2288 self.recompute_effective_theme();
2289 self.arena.mark_all_dirty();
2290 }
2291 }
2292
2293 /// Whether the OS has requested high-contrast mode.
2294 pub fn prefers_high_contrast(&self) -> bool {
2295 self.prefers_high_contrast
2296 }
2297
2298 /// Whether the OS has requested reduced motion.
2299 pub fn prefers_reduced_motion(&self) -> bool {
2300 self.prefers_reduced_motion
2301 }
2302
2303 /// OS text scaling factor (1.0 = normal).
2304 pub fn text_scale_factor(&self) -> f64 {
2305 self.text_scale_factor
2306 }
2307
2308 /// Set the host window HiDPI device scale (physical px per logical px).
2309 /// Called by `teksilo-app` before each layout from
2310 /// `platform_window.scale_factor()`. Surfaced to widgets via
2311 /// `LayoutContext::scale_factor`. No dirty-marking: it rides the layout
2312 /// pass that follows, and a scale change already triggers a relayout.
2313 pub fn set_device_scale_factor(&mut self, scale_factor: f32) {
2314 self.device_scale_factor = scale_factor;
2315 }
2316
2317 /// The host window HiDPI device scale most recently set (1.0 by default).
2318 pub fn device_scale_factor(&self) -> f32 {
2319 self.device_scale_factor
2320 }
2321
2322 /// Mark a widget as clipping its children to its bounds (scroll areas).
2323 pub fn set_clips_children(&mut self, id: WidgetId, clips: bool) {
2324 self.arena.set_clips_children(id, clips);
2325 }
2326
2327 /// Apply a `HandlerSet` to an existing node in the arena, routed
2328 /// into the rebuild-cleared `handlers` slot (the widget's own
2329 /// self-applied handlers).
2330 /// Register any *bound* builder-level accessibility Props
2331 /// (`access_hidden` / `access_label` / `access_description` /
2332 /// `access_value`) at `BindingLevel::AccessibilityOnly`, so that a change
2333 /// to the underlying signal flips `a11y_dirty` and the AccessKit tree
2334 /// re-walks — re-resolving the announced hidden-state / name / description
2335 /// / value — without a visual relayout. Static Props are ignored by
2336 /// `register_if_bound`. Takes the registry explicitly (rather than `&self`)
2337 /// so insertion-path callers can keep a disjoint `&mut self.arena` borrow
2338 /// on the node alive.
2339 fn register_access_prop_bindings(
2340 access: &crate::widget_builder::AccessibilityOverrides,
2341 id: WidgetId,
2342 registry: &crate::binding::BindingRegistry,
2343 ) {
2344 use crate::binding::BindingLevel::AccessibilityOnly;
2345 if let Some(p) = access.hidden.as_ref() {
2346 p.register_if_bound(id, registry, AccessibilityOnly);
2347 }
2348 if let Some(p) = access.label.as_ref() {
2349 p.register_if_bound(id, registry, AccessibilityOnly);
2350 }
2351 if let Some(p) = access.description.as_ref() {
2352 p.register_if_bound(id, registry, AccessibilityOnly);
2353 }
2354 if let Some(p) = access.value.as_ref() {
2355 p.register_if_bound(id, registry, AccessibilityOnly);
2356 }
2357 }
2358
2359 pub(crate) fn apply_self_handler_set(
2360 &mut self,
2361 id: WidgetId,
2362 mut handler_set: crate::widget_builder::HandlerSet,
2363 ) {
2364 // `visible_when` needs the binding registry (which the arena lacks), so
2365 // pull it out here and apply it via `self.visible_when` after.
2366 let visible_when = handler_set.visible_when.take();
2367 // Same reason for the reactive access Props: the arena can't reach the
2368 // registry, so register them here before handing the set to the arena.
2369 if let Some(access) = handler_set.access.as_ref() {
2370 Self::register_access_prop_bindings(access, id, &self.binding_registry);
2371 }
2372 self.arena
2373 .apply_handler_set(id, handler_set, crate::arena::HandlerScope::Own);
2374 if let Some(prop) = visible_when {
2375 self.visible_when(id, prop);
2376 }
2377 }
2378
2379 /// Apply a `HandlerSet` to an existing node as *external* handlers —
2380 /// the kind attached by a composing parent via
2381 /// `BuildContext::apply_handlers(child_id, ...)` or by the
2382 /// `WidgetBuilder` chain at insertion time. These persist across
2383 /// the target widget's own rebuilds.
2384 pub(crate) fn apply_external_handler_set(
2385 &mut self,
2386 id: WidgetId,
2387 mut handler_set: crate::widget_builder::HandlerSet,
2388 ) {
2389 // See `apply_self_handler_set`: route `visible_when` and the reactive
2390 // access Props through the registry (the arena can't reach it).
2391 let visible_when = handler_set.visible_when.take();
2392 if let Some(access) = handler_set.access.as_ref() {
2393 Self::register_access_prop_bindings(access, id, &self.binding_registry);
2394 }
2395 self.arena
2396 .apply_handler_set(id, handler_set, crate::arena::HandlerScope::External);
2397 if let Some(prop) = visible_when {
2398 self.visible_when(id, prop);
2399 }
2400 }
2401
2402 /// Append an accessibility `labelled_by` relation onto an already-mounted
2403 /// node, *preserving* any overrides the widget already carries — unlike
2404 /// `apply_external_handler_set`, which replaces the whole override struct.
2405 /// Used by container widgets (e.g. `FormLayout`) to name a field after its
2406 /// label once both ids are known. Idempotent-ish: re-adding the same target
2407 /// pushes a duplicate, so call once per pairing.
2408 pub(crate) fn push_access_labelled_by(&mut self, id: WidgetId, label_id: WidgetId) {
2409 if let Some(node) = self.arena.get_mut(id) {
2410 node.access_overrides
2411 .get_or_insert_with(|| {
2412 Box::new(crate::widget_builder::AccessibilityOverrides::default())
2413 })
2414 .labelled_by
2415 .push(label_id);
2416 self.a11y_dirty = true;
2417 }
2418 }
2419
2420 /// Append an accessibility `described_by` relation onto an already-mounted
2421 /// node, preserving existing overrides (the `described_by` counterpart of
2422 /// [`push_access_labelled_by`](Self::push_access_labelled_by)).
2423 pub(crate) fn push_access_described_by(&mut self, id: WidgetId, target_id: WidgetId) {
2424 if let Some(node) = self.arena.get_mut(id) {
2425 node.access_overrides
2426 .get_or_insert_with(|| {
2427 Box::new(crate::widget_builder::AccessibilityOverrides::default())
2428 })
2429 .described_by
2430 .push(target_id);
2431 self.a11y_dirty = true;
2432 }
2433 }
2434
2435 /// Set a per-child alignment override on a widget.
2436 pub fn set_alignment(&mut self, id: WidgetId, alignment: teksilo_tokens::Alignment) {
2437 self.arena.set_alignment_override(id, alignment);
2438 }
2439
2440 /// Get the binding registry for registering State→Widget bindings.
2441 pub fn binding_registry(&self) -> &crate::binding::BindingRegistry {
2442 &self.binding_registry
2443 }
2444
2445 /// Shared access to the shortcut registry. Widgets register their
2446 /// default shortcuts through here during `build()` (via
2447 /// `BuildContext::register_shortcut`); settings UIs and
2448 /// persistence layers read and mutate overrides directly.
2449 pub fn shortcut_registry(&self) -> &crate::shortcut::ShortcutRegistry {
2450 &self.shortcut_registry
2451 }
2452
2453 pub fn shortcut_registry_mut(&mut self) -> &mut crate::shortcut::ShortcutRegistry {
2454 &mut self.shortcut_registry
2455 }
2456
2457 /// Install a one-shot key-capture callback, returning a
2458 /// [`CaptureHandle`](crate::shortcut::CaptureHandle) whose `Drop`
2459 /// cancels the capture if it hasn't already fired. The next
2460 /// `KeyDown` the tree receives bypasses shortcut-registry lookup
2461 /// and invokes the callback with:
2462 /// - the captured [`KeyStroke`](crate::shortcut::KeyStroke)
2463 /// - mutable access to the registry (rebind in-place)
2464 /// - a mutable [`EventContext`] (so the handler can also emit
2465 /// commands, send intents, dismiss overlays, …)
2466 ///
2467 /// Calling this while a previous capture is armed creates a
2468 /// **separate** slot; the prior handle, when eventually dropped,
2469 /// cancels only its own (now-orphaned) slot. The new capture
2470 /// wins.
2471 pub fn begin_key_capture(
2472 &mut self,
2473 callback: impl FnOnce(
2474 crate::shortcut::KeyStroke,
2475 &mut crate::shortcut::ShortcutRegistry,
2476 &mut EventContext,
2477 ) + 'static,
2478 ) -> crate::shortcut::CaptureHandle {
2479 let slot: crate::shortcut::KeyCaptureSlot =
2480 std::rc::Rc::new(std::cell::RefCell::new(Some(Box::new(callback))));
2481 self.key_capture = Some(slot.clone());
2482 crate::shortcut::CaptureHandle::new(slot)
2483 }
2484
2485 /// Cancel any currently-armed key capture without invoking it.
2486 /// Equivalent to dropping the [`CaptureHandle`](crate::shortcut::CaptureHandle),
2487 /// but exposed here so callers that lost the handle (or never
2488 /// kept one) can still bail out.
2489 pub fn cancel_key_capture(&mut self) {
2490 if let Some(slot) = self.key_capture.take() {
2491 slot.borrow_mut().take();
2492 }
2493 }
2494
2495 /// Whether a key-capture callback is currently armed.
2496 pub fn is_capturing_keys(&self) -> bool {
2497 self.key_capture
2498 .as_ref()
2499 .map(|slot| slot.borrow().is_some())
2500 .unwrap_or(false)
2501 }
2502
2503 /// Consume any pending key-capture callback. Used internally by
2504 /// the dispatch path — returns the boxed closure so the caller
2505 /// can invoke it once the KeyStroke has been constructed. Also
2506 /// drops the outer `Option<Rc<...>>` so `is_capturing_keys` goes
2507 /// back to `false`.
2508 pub(crate) fn take_key_capture(&mut self) -> Option<crate::shortcut::KeyCaptureCallback> {
2509 let slot = self.key_capture.take()?;
2510 slot.borrow_mut().take()
2511 }
2512
2513 /// Append an [`Action`](crate::action::Action) to a widget's arena
2514 /// node. Invoked by `BuildContext::register_action`; not meant
2515 /// to be called directly.
2516 /// Record that `widget_id` edits text. Replaces any previous registration
2517 /// from the same widget, so a rebuild re-points rather than accumulating.
2518 pub(crate) fn push_text_surface(
2519 &mut self,
2520 widget_id: WidgetId,
2521 surface: std::rc::Rc<dyn crate::text_surface::TextSurface>,
2522 ) {
2523 self.text_surfaces.insert(widget_id, surface);
2524 }
2525
2526 /// A cloneable view of this tree's text surfaces, for a caller that must ask
2527 /// the question later, without a `&WidgetTree` in hand.
2528 pub fn text_surfaces(&self) -> crate::text_surface::TextSurfaces {
2529 self.text_surfaces.clone()
2530 }
2531
2532 /// The text-editing widget that currently holds the keyboard focus.
2533 ///
2534 /// `None` when focus is elsewhere — or nowhere — which is exactly what a
2535 /// host needs in order to know that a text chord is safe to route itself.
2536 pub fn focused_text_surface(
2537 &self,
2538 ) -> Option<std::rc::Rc<dyn crate::text_surface::TextSurface>> {
2539 self.text_surfaces.focused()
2540 }
2541
2542 /// Is the keyboard focus inside a widget that edits text?
2543 ///
2544 /// The cheap half of [`focused_text_surface`](Self::focused_text_surface),
2545 /// for a host that only needs to decide whether to step aside.
2546 pub fn focused_is_text_surface(&self) -> bool {
2547 self.text_surfaces.focused_is_text_surface()
2548 }
2549
2550 pub(crate) fn push_action(&mut self, widget_id: WidgetId, action: crate::action::Action) {
2551 if let Some(node) = self.arena.get_mut(widget_id) {
2552 node.actions.push(action);
2553 }
2554 }
2555
2556 /// Telemetry dispatch tap. Looks up a registered
2557 /// [`crate::telemetry::TelemetryContext`] in `app_state` and emits
2558 /// an `intent.dispatched` event with the intent's name. No-op when
2559 /// no telemetry is configured. Errors and consent gating are
2560 /// handled inside the reporter — this site only needs to call
2561 /// `record`.
2562 fn tap_intent_dispatched(&self, intent: &crate::intent::Intent) {
2563 let Some(tcx) = self
2564 .app_context()
2565 .app_state::<crate::telemetry::TelemetryContext>()
2566 else {
2567 return;
2568 };
2569 let install_id = tcx.reporter.install_id();
2570 let props = [
2571 crate::telemetry::Prop {
2572 key: "name",
2573 value: crate::telemetry::PropValue::StaticStr(intent.name),
2574 },
2575 crate::telemetry::Prop {
2576 key: "source",
2577 value: crate::telemetry::PropValue::Enum {
2578 variant: intent.source.as_str(),
2579 },
2580 },
2581 ];
2582 let event = crate::telemetry::Event {
2583 name: "intent.dispatched",
2584 category: crate::telemetry::EventCategory::Intent,
2585 timestamp: std::time::SystemTime::now(),
2586 install_id,
2587 session_id: &tcx.session_id,
2588 schema_version: tcx.schema_version,
2589 props: &props,
2590 };
2591 tcx.reporter.record(&event);
2592 }
2593
2594 /// Histogram of widget concrete-type names across the active
2595 /// arena. Used by the `widget.census` telemetry emitter to surface
2596 /// "which widgets does this app actually use" data back to the
2597 /// framework. Keyed by
2598 /// `std::any::type_name::<T>()` of the concrete widget — a
2599 /// dotted, fully-qualified path like
2600 /// `teksilo_widgets::button::Button`.
2601 ///
2602 /// `&'static str` keys: `type_name_of_val` returns a
2603 /// compile-time string, so the histogram preserves the static
2604 /// lifetime all the way to the wire-format prop. This avoids
2605 /// any allocation for the type-name strings themselves.
2606 ///
2607 /// Cost: one `Box<dyn Widget>` indirection per active node plus
2608 /// a `HashMap` insert. Sub-millisecond on arenas with thousands
2609 /// of widgets. Safe to call every frame in tests; in production
2610 /// gate behind a periodic ticker (hourly or on-idle).
2611 pub fn widget_type_histogram(&self) -> std::collections::HashMap<&'static str, u32> {
2612 let mut out = std::collections::HashMap::<&'static str, u32>::new();
2613 for id in self.arena.active_ids_iter() {
2614 if let Some(node) = self.arena.get(id) {
2615 // `Widget::type_name` is monomorphized per impl, so
2616 // calling through the vtable correctly resolves to
2617 // the concrete type — `type_name_of_val(&*widget)`
2618 // alone would collapse to `"dyn teksilo_core::widget::Widget"`.
2619 let name: &'static str = node.widget.type_name();
2620 *out.entry(name).or_insert(0) += 1;
2621 }
2622 }
2623 out
2624 }
2625
2626 /// Number of active widgets in the arena. Cheap; matches the
2627 /// totals returned by `widget_type_histogram` when summed.
2628 pub fn active_widget_count(&self) -> usize {
2629 self.arena.active_ids_iter().count()
2630 }
2631
2632 /// Enqueue an intent for dispatch from `source`. Called from
2633 /// `collect_from_ctx` after a handler runs `ctx.send_intent(...)`
2634 /// and from the KeyDown shortcut-interception path.
2635 pub(crate) fn enqueue_intent(
2636 &mut self,
2637 source: WidgetId,
2638 intent: crate::intent::Intent,
2639 propagate_when_disabled: bool,
2640 ) {
2641 self.pending_intents
2642 .push((source, intent, propagate_when_disabled));
2643 }
2644
2645 /// Dispatch every queued intent. Handlers may call
2646 /// `ctx.send_intent(...)` to enqueue more; the loop consumes
2647 /// those too until the queue drains. No ordering guarantee
2648 /// beyond "first-enqueued is first-dispatched"; the `pop` path
2649 /// uses `remove(0)` to keep that FIFO behavior.
2650 pub(crate) fn drain_pending_intents(&mut self, ops: &mut dyn crate::window::WindowOps) {
2651 while !self.pending_intents.is_empty() {
2652 let (source, intent, propagate) = self.pending_intents.remove(0);
2653 self.dispatch_intent(source, intent, propagate, &mut *ops);
2654 }
2655 }
2656
2657 /// Walk `source → root` invoking any [`Action`](crate::action::Action)
2658 /// whose `intent` name matches. The first enabled, `Handled`
2659 /// response stops the walk. A `Propagated` or disabled action
2660 /// (when the shortcut's `propagate_when_disabled` is true) lets
2661 /// the walk continue. A disabled action with
2662 /// `propagate_when_disabled == false` consumes the intent at that
2663 /// level without invoking a handler.
2664 pub(crate) fn dispatch_intent(
2665 &mut self,
2666 source: WidgetId,
2667 intent: crate::intent::Intent,
2668 propagate_when_disabled: bool,
2669 ops: &mut dyn crate::window::WindowOps,
2670 ) {
2671 // Telemetry tap. Single insertion point catches every intent
2672 // — shortcut-driven, programmatic via `send_intent`, etc. —
2673 // because every dispatch funnels through here. A no-op when
2674 // no `TelemetryContext` is registered.
2675 self.tap_intent_dispatched(&intent);
2676
2677 // Pre-compute the source → root chain so the walk doesn't
2678 // need to hold any arena borrow while invoking handlers.
2679 let chain: Vec<WidgetId> = {
2680 let mut v = vec![source];
2681 let mut current = self.arena.parent(source);
2682 while let Some(id) = current {
2683 v.push(id);
2684 current = self.arena.parent(id);
2685 }
2686 v
2687 };
2688
2689 for id in chain {
2690 if !self.arena.is_active(id) || !self.arena.is_enabled(id) {
2691 continue;
2692 }
2693
2694 // Take out the first matching action by intent name so
2695 // we can invoke its FnMut handler without holding an
2696 // arena-wide borrow. The action is reinserted at its
2697 // original position so declaration order is preserved
2698 // for any follow-on dispatch.
2699 let Some((mut action, idx, enabled)) = self.arena.get_mut(id).and_then(|node| {
2700 let idx = node.actions.iter().position(|a| a.intent == intent.name)?;
2701 let enabled = node.actions[idx].is_enabled();
2702 Some((node.actions.remove(idx), idx, enabled))
2703 }) else {
2704 continue;
2705 };
2706
2707 if !enabled {
2708 // Return the action untouched.
2709 if let Some(node) = self.arena.get_mut(id) {
2710 node.actions.insert(idx, action);
2711 }
2712 if propagate_when_disabled {
2713 continue;
2714 }
2715 return;
2716 }
2717
2718 let mut ctx = self.make_event_context(&mut *ops);
2719 let response = (action.handler)(&intent, &mut ctx);
2720 if let Some(node) = self.arena.get_mut(id) {
2721 node.actions.insert(idx, action);
2722 }
2723 self.collect_from_ctx(ctx, id);
2724
2725 match response {
2726 crate::intent::IntentResponse::Handled => return,
2727 crate::intent::IntentResponse::Propagated => continue,
2728 }
2729 }
2730
2731 // Fallback: window-global actions (registered via
2732 // `register_action_global`). The source→root walk found no consuming
2733 // node action, so consult app-global commands — reachable regardless of
2734 // where the intent originated (menu-bar overlay, content, shortcut).
2735 let mut i = 0;
2736 while i < self.global_actions.len() {
2737 let matches = {
2738 let (_, action) = &self.global_actions[i];
2739 action.intent == intent.name && action.is_enabled()
2740 };
2741 if !matches {
2742 i += 1;
2743 continue;
2744 }
2745 // Take the action out so the FnMut handler can run without holding a
2746 // borrow on `self`; reinsert at its slot afterwards.
2747 let (owner, mut action) = self.global_actions.remove(i);
2748 let mut ctx = self.make_event_context(&mut *ops);
2749 let response = (action.handler)(&intent, &mut ctx);
2750 self.global_actions.insert(i, (owner, action));
2751 self.collect_from_ctx(ctx, owner);
2752 match response {
2753 crate::intent::IntentResponse::Handled => return,
2754 crate::intent::IntentResponse::Propagated => {
2755 i += 1;
2756 continue;
2757 }
2758 }
2759 }
2760 }
2761
2762 /// Register a window-global [`Action`](crate::action::Action) owned by
2763 /// `owner`. Consulted as a dispatch fallback (see [`Self::dispatch_intent`]);
2764 /// torn down when `owner` rebuilds or is destroyed. Backs
2765 /// [`BuildContext::register_action_global`](crate::BuildContext::register_action_global).
2766 pub(crate) fn push_global_action(&mut self, owner: WidgetId, action: crate::action::Action) {
2767 self.global_actions.push((owner, action));
2768 }
2769
2770 // --- Window-close request (drained by the app loop) ---
2771
2772 /// Drain the "close this window" flag set by
2773 /// [`EventContext::close_window`] during dispatch. A *guarded* close
2774 /// — the app routes it through the window's close guard.
2775 pub fn take_close_window_request(&mut self) -> bool {
2776 std::mem::replace(&mut self.close_window_requested, false)
2777 }
2778
2779 /// Drain the "close this window, no questions asked" flag set by
2780 /// [`EventContext::close_window_forced`] during dispatch. An
2781 /// *unconditional* close that bypasses the window's close guard.
2782 pub fn take_force_close_request(&mut self) -> bool {
2783 std::mem::replace(&mut self.force_close_requested, false)
2784 }
2785
2786 /// Drain the pending locale switch raised by
2787 /// [`EventContext::set_locale`] during dispatch. The app layer
2788 /// (`WindowManager::drain_pending_locale_requests`) parses the
2789 /// result and routes it through `WindowManager::set_locale` so the
2790 /// `I18nManager`'s active locale, version signal, and layout
2791 /// direction all stay in sync with the tree.
2792 pub fn take_pending_locale_request(&mut self) -> Option<String> {
2793 self.pending_locale_request.take()
2794 }
2795
2796 /// Drain the pending theme switch raised by
2797 /// [`EventContext::set_theme`] during dispatch. The app layer
2798 /// (`WindowManager::drain_pending_theme_requests`) routes it through
2799 /// `WindowManager::set_theme` so the new theme is applied to every
2800 /// window, not just the one whose handler requested it.
2801 pub fn take_pending_theme_request(&mut self) -> Option<crate::styles::Theme> {
2802 self.pending_theme_request.take()
2803 }
2804
2805 /// Drain the pending "follow OS theme" request raised by
2806 /// [`EventContext::follow_system_theme`] during dispatch. The app layer
2807 /// (`WindowManager::drain_pending_follow_system_requests`) switches to
2808 /// `ThemeMode::Native` and recomputes the theme from the OS for every
2809 /// window. Returns `true` if a request was pending.
2810 pub fn take_pending_follow_system_request(&mut self) -> bool {
2811 std::mem::take(&mut self.pending_follow_system_request)
2812 }
2813
2814 /// Drain the pending text-scale change raised by
2815 /// [`EventContext::set_text_scale`] during dispatch. The app layer
2816 /// (`WindowManager::drain_pending_text_scale_requests`) routes it through
2817 /// `WindowManager::set_text_scale` so the new factor is applied to every
2818 /// window, not just the one whose handler requested it.
2819 pub fn take_pending_text_scale_request(&mut self) -> Option<f32> {
2820 self.pending_text_scale_request.take()
2821 }
2822
2823 /// Drain all pending modal requests recorded during event handling.
2824 ///
2825 /// Each request includes the originating widget so higher layers can
2826 /// resolve routing and focus behavior relative to the source tree.
2827 pub fn drain_pending_modal_requests(&mut self) -> Vec<crate::modal::QueuedModalRequest> {
2828 std::mem::take(&mut self.pending_modal_requests)
2829 }
2830
2831 /// Drain whether the current native modal window should be dismissed.
2832 pub fn drain_pending_modal_dismissal(&mut self) -> bool {
2833 std::mem::replace(&mut self.pending_modal_dismissal, false)
2834 }
2835
2836 // --- Widget insertion ---
2837
2838 /// Walk `Widget::declare_shortcuts` for an already-inserted widget
2839 /// and register every returned shortcut with the registry, owned
2840 /// by `id`. Called at insertion AND at rebuild so the declared
2841 /// metadata survives across rebuilds (which `unregister_all_for_owner`
2842 /// would otherwise wipe). Build-time `ctx.register_shortcut` calls
2843 /// upsert handlers on top; the registry is idempotent on id.
2844 pub(crate) fn apply_declared_shortcuts(&mut self, id: WidgetId) {
2845 let declared = self
2846 .arena
2847 .get(id)
2848 .map(|n| n.widget.declare_shortcuts())
2849 .unwrap_or_default();
2850 for shortcut in declared {
2851 self.shortcut_registry.register_owned(shortcut, id);
2852 }
2853 }
2854
2855 /// Internal: insert a widget, call build(), wire children, register clips.
2856 fn insert_widget(&mut self, widget: Box<dyn Widget>) -> WidgetId {
2857 let id = self.arena.insert(widget);
2858
2859 {
2860 if let Some(mut widget_box) = self.arena.take_widget(id) {
2861 if let Some(handler_set) = widget_box.take_handler_set() {
2862 self.arena.restore_widget(id, widget_box);
2863 if let Some(node) = self.arena.get_mut(id) {
2864 // Handlers attached at the widget's creation site
2865 // are external from its own perspective — keep
2866 // them out of the rebuild-cleared `handlers`
2867 // slot so they survive data-driven rebuilds.
2868 node.external_handlers = handler_set.handlers;
2869 node.node_focusable = handler_set.focusable;
2870 node.node_tab_index = handler_set.tab_index;
2871 node.node_cursor = handler_set.cursor;
2872 // `clips_children` and `event_pass_through` are
2873 // node-level flags on `WidgetNode` — they must
2874 // be mirrored here too. Without this an
2875 // `Inner::new().event_pass_through(true)` chain
2876 // silently no-ops (the flag stays at default
2877 // `false`), and any widget wrapped with it
2878 // catches every pointer event in its bounds.
2879 if let Some(clips) = handler_set.clips_children {
2880 node.clips_children = clips;
2881 }
2882 if let Some(pass_through) = handler_set.event_pass_through {
2883 node.event_pass_through = pass_through;
2884 }
2885 if let Some(dead_zone) = handler_set.gesture_dead_zone {
2886 node.gesture_dead_zone = dead_zone;
2887 }
2888 if let Some(keyboard_capture) = handler_set.keyboard_capture {
2889 node.keyboard_capture = keyboard_capture;
2890 }
2891 if let Some(hit_transparent) = handler_set.hit_transparent {
2892 node.hit_transparent = hit_transparent;
2893 }
2894 if handler_set.context_menu_factory.is_some() {
2895 node.context_menu_factory = handler_set.context_menu_factory;
2896 }
2897 if let Some(sig) = handler_set.focus_within {
2898 node.focus_within_signal = Some(sig);
2899 }
2900 if let Some(sig) = handler_set.hover_within {
2901 node.hover_within_signal = Some(sig);
2902 }
2903 // Builder-chained `visible_when: prop`. Mirror of
2904 // `WidgetTree::visible_when`: register a bound prop at
2905 // Relayout, then store it on the node. (Disjoint field
2906 // borrow, like `access_hidden` below.)
2907 if let Some(prop) = handler_set.visible_when {
2908 prop.register_if_bound(
2909 id,
2910 &self.binding_registry,
2911 crate::binding::BindingLevel::Relayout,
2912 );
2913 node.visible_state = Some(prop);
2914 }
2915 // Builder-level accessibility overrides + subtree
2916 // mode. Mirrored here because this insertion path
2917 // bypasses `apply_handler_set`.
2918 if handler_set.access.is_some() {
2919 // Register any bound access_hidden/label/description/
2920 // value Props at AccessibilityOnly so the AT tree
2921 // re-walks when they flip. (Disjoint field borrow:
2922 // `node` borrows `self.arena`, this reads
2923 // `self.binding_registry`.)
2924 if let Some(access) = handler_set.access.as_ref() {
2925 Self::register_access_prop_bindings(
2926 access,
2927 id,
2928 &self.binding_registry,
2929 );
2930 }
2931 node.access_overrides = handler_set.access;
2932 }
2933 if let Some(mode) = handler_set.access_subtree {
2934 node.access_subtree = mode;
2935 }
2936 }
2937 } else {
2938 self.arena.restore_widget(id, widget_box);
2939 }
2940 }
2941 }
2942
2943 // Walk Widget::declare_shortcuts before build() so the
2944 // declared metadata lands in the registry first; if build()
2945 // also registers the same id with a real on_activate, the
2946 // registry upserts (preserving any user override).
2947 self.apply_declared_shortcuts(id);
2948
2949 {
2950 let mut widget_box = match self.arena.take_widget(id) {
2951 Some(widget) => widget,
2952 None => return id,
2953 };
2954 let mut build_ctx = crate::build_context::BuildContext {
2955 tree: self,
2956 composite_id: Some(id),
2957 effect_handles: Vec::new(),
2958 subscription_handles: Vec::new(),
2959 // A first mount has no previous build to inherit ids from.
2960 reusable_sub_ids: Vec::new(),
2961 };
2962 let built_children = widget_box.build(&mut build_ctx);
2963 let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
2964 let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);
2965
2966 self.arena.restore_widget(id, widget_box);
2967
2968 // Transfer per-widget handles to the node. Both lists are
2969 // stored unconditionally — a leaf widget that registers an
2970 // effect in its build() still needs its ObserverHandle to
2971 // persist (otherwise the effect unregisters the moment
2972 // BuildContext drops).
2973 if let Some(node) = self.arena.get_mut(id) {
2974 node.subscription_handles = subscription_handles;
2975 node.effect_handles = effect_handles;
2976 }
2977
2978 if !built_children.is_empty() {
2979 for &child_id in &built_children {
2980 if let Some(child_node) = self.arena.get_mut(child_id) {
2981 child_node.parent = Some(id);
2982 }
2983 }
2984 if let Some(node) = self.arena.get_mut(id) {
2985 node.children = built_children;
2986 }
2987 }
2988 }
2989
2990 let clips = self
2991 .arena
2992 .get(id)
2993 .is_some_and(|node| node.widget.clips_children());
2994 if clips {
2995 self.arena.set_clips_children(id, true);
2996 }
2997
2998 id
2999 }
3000
3001 /// Add a widget to the tree.
3002 pub fn add(&mut self, widget: impl Widget + 'static) -> WidgetId {
3003 self.insert_widget(Box::new(widget))
3004 }
3005
3006 /// Add a pre-boxed widget to the tree.
3007 pub fn add_boxed(&mut self, widget: Box<dyn Widget>) -> WidgetId {
3008 self.insert_widget(widget)
3009 }
3010
3011 /// Add a widget as a child of another widget.
3012 pub fn add_child(&mut self, parent: WidgetId, widget: impl Widget + 'static) -> WidgetId {
3013 let boxed: Box<dyn Widget> = Box::new(widget);
3014
3015 let id = self.arena.insert_child(parent, boxed);
3016
3017 {
3018 if let Some(mut widget_box) = self.arena.take_widget(id) {
3019 if let Some(handler_set) = widget_box.take_handler_set() {
3020 self.arena.restore_widget(id, widget_box);
3021 if let Some(node) = self.arena.get_mut(id) {
3022 // Creation-site handlers are external (persist
3023 // across the widget's own rebuilds) — see the
3024 // matching block in `insert_widget`.
3025 node.external_handlers = handler_set.handlers;
3026 node.node_focusable = handler_set.focusable;
3027 node.node_tab_index = handler_set.tab_index;
3028 node.node_cursor = handler_set.cursor;
3029 if let Some(clips) = handler_set.clips_children {
3030 node.clips_children = clips;
3031 }
3032 if let Some(pass_through) = handler_set.event_pass_through {
3033 node.event_pass_through = pass_through;
3034 }
3035 if let Some(dead_zone) = handler_set.gesture_dead_zone {
3036 node.gesture_dead_zone = dead_zone;
3037 }
3038 if let Some(keyboard_capture) = handler_set.keyboard_capture {
3039 node.keyboard_capture = keyboard_capture;
3040 }
3041 if let Some(hit_transparent) = handler_set.hit_transparent {
3042 node.hit_transparent = hit_transparent;
3043 }
3044 if handler_set.context_menu_factory.is_some() {
3045 node.context_menu_factory = handler_set.context_menu_factory;
3046 }
3047 if let Some(sig) = handler_set.focus_within {
3048 node.focus_within_signal = Some(sig);
3049 }
3050 if let Some(sig) = handler_set.hover_within {
3051 node.hover_within_signal = Some(sig);
3052 }
3053 // Builder-chained `visible_when: prop`. Same as in
3054 // `insert_widget`.
3055 if let Some(prop) = handler_set.visible_when {
3056 prop.register_if_bound(
3057 id,
3058 &self.binding_registry,
3059 crate::binding::BindingLevel::Relayout,
3060 );
3061 node.visible_state = Some(prop);
3062 }
3063 // Builder-level accessibility overrides + subtree
3064 // mode. Same rationale as in `insert_widget`.
3065 if handler_set.access.is_some() {
3066 // Register any bound access_hidden/label/description/
3067 // value Props at AccessibilityOnly so the AT tree
3068 // re-walks when they flip. (Disjoint field borrow:
3069 // `node` borrows `self.arena`, this reads
3070 // `self.binding_registry`.)
3071 if let Some(access) = handler_set.access.as_ref() {
3072 Self::register_access_prop_bindings(
3073 access,
3074 id,
3075 &self.binding_registry,
3076 );
3077 }
3078 node.access_overrides = handler_set.access;
3079 }
3080 if let Some(mode) = handler_set.access_subtree {
3081 node.access_subtree = mode;
3082 }
3083 }
3084 } else {
3085 self.arena.restore_widget(id, widget_box);
3086 }
3087 }
3088 }
3089
3090 // Same shortcut-declaration walk as `insert_widget` — keeps
3091 // metadata visible from the moment the child mounts, before
3092 // build() runs.
3093 self.apply_declared_shortcuts(id);
3094
3095 {
3096 if let Some(mut widget_box) = self.arena.take_widget(id) {
3097 let mut build_ctx = crate::build_context::BuildContext {
3098 tree: self,
3099 composite_id: Some(id),
3100 effect_handles: Vec::new(),
3101 subscription_handles: Vec::new(),
3102 // A first mount has no previous build to inherit ids from.
3103 reusable_sub_ids: Vec::new(),
3104 };
3105 let built_children = widget_box.build(&mut build_ctx);
3106 let effect_handles = std::mem::take(&mut build_ctx.effect_handles);
3107 let subscription_handles = std::mem::take(&mut build_ctx.subscription_handles);
3108
3109 self.arena.restore_widget(id, widget_box);
3110
3111 // Transfer per-widget handles to the node. See the
3112 // matching block in `insert_widget` — effect and
3113 // subscription handles must persist for leaf widgets
3114 // too, not only composite ones.
3115 if let Some(node) = self.arena.get_mut(id) {
3116 node.subscription_handles = subscription_handles;
3117 node.effect_handles = effect_handles;
3118 }
3119
3120 if !built_children.is_empty() {
3121 for &child_id in &built_children {
3122 if let Some(child_node) = self.arena.get_mut(child_id) {
3123 child_node.parent = Some(id);
3124 }
3125 }
3126 if let Some(node) = self.arena.get_mut(id) {
3127 node.children = built_children;
3128 }
3129 }
3130 }
3131 }
3132
3133 let clips = self
3134 .arena
3135 .get(id)
3136 .is_some_and(|node| node.widget.clips_children());
3137 if clips {
3138 self.arena.set_clips_children(id, true);
3139 }
3140
3141 id
3142 }
3143
3144 // --- Property bindings ---
3145
3146 /// Bind a widget's visibility to a boolean prop or compatibility state binding.
3147 /// When false, the widget is set dormant; when true, it is activated.
3148 /// Accepts `Signal<bool>`, `Prop<bool>`, compatibility state bindings, or plain `bool`.
3149 pub fn visible_when(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
3150 let prop = state.into();
3151 prop.register_if_bound(
3152 id,
3153 &self.binding_registry,
3154 crate::binding::BindingLevel::Relayout,
3155 );
3156 if let Some(node) = self.arena.get_mut(id) {
3157 node.visible_state = Some(prop);
3158 }
3159 }
3160
3161 /// Install (or reuse) the activation signal on a node and return a
3162 /// handle to it. The framework sets it to `false` when the node is
3163 /// parked dormant (`Switcher` / `visible_when`) and `true` when it is
3164 /// re-activated — see [`crate::arena::WidgetArena::set_dormant`] /
3165 /// [`activate`](crate::arena::WidgetArena::activate). The returned
3166 /// signal is initialised to the node's current active state. Used by
3167 /// widgets owning a resource outside the paint pass (a native subview)
3168 /// that must hide/show it in lockstep with framework activation.
3169 pub fn activation_signal(&mut self, id: WidgetId) -> crate::signal::Signal<bool> {
3170 if let Some(node) = self.arena.get_mut(id) {
3171 if let Some(existing) = node.activation_signal.clone() {
3172 return existing;
3173 }
3174 let active = node.activation == crate::arena::ActivationState::Active;
3175 let sig = crate::signal::Signal::new(active);
3176 node.activation_signal = Some(sig.clone());
3177 sig
3178 } else {
3179 // Node missing (shouldn't happen in build) — hand back a
3180 // detached signal so the caller still gets a valid handle.
3181 crate::signal::Signal::new(true)
3182 }
3183 }
3184
3185 /// Fire the `activation_signal` of every node that transitioned
3186 /// Active↔Dormant since the last flush. Called at a well-defined tree-level
3187 /// point (the end of `process_state_changes`), never from inside the arena
3188 /// recursion — so an observer (e.g. a `WebView` calling the engine's
3189 /// `set_visible`) runs after the visibility pass has fully committed,
3190 /// matching the `focus_within` / `hover_within` update discipline.
3191 pub(crate) fn flush_activation_signals(&mut self) {
3192 let changes = self.arena.take_activation_changes();
3193 for (id, _recorded) in changes {
3194 // Re-read the node at flush time; it still exists (the transition
3195 // was recorded in the same synchronous operation).
3196 let Some(node) = self.arena.get(id) else {
3197 continue;
3198 };
3199 let Some(sig) = node.activation_signal.clone() else {
3200 continue;
3201 };
3202 // Fire the node's **current** state, not the value recorded at the
3203 // transition, and only when it differs from what observers last
3204 // saw. `pending_activation_changes` is an append-only queue: a
3205 // node parked and re-activated inside one batch records both
3206 // edges, and replaying them in order hands observers a `false`
3207 // that was never observable — the node is already Active by the
3208 // time anyone is told anything.
3209 //
3210 // The in-tree modal path does exactly that on every open: build
3211 // the content, `set_dormant` it, mount the scrim, `activate` it,
3212 // then move focus in. Both edges land in one batch and flush
3213 // *after* the focus dispatch, so the stale `false` arrives last
3214 // and observers act on a state that has already been superseded.
3215 // For a text editor that meant its dormancy handler wiped the
3216 // `has_focus` it had just been granted, and the dialog opened with
3217 // no caret; a `WebView` would have taken a real `set_visible(false)`
3218 // OS call for a subview that never left the screen.
3219 //
3220 // Collapsing to the final state also makes the flush idempotent
3221 // over duplicate ids: the first iteration syncs the signal, the
3222 // rest find it already equal and skip. `Signal::set` notifies
3223 // unconditionally, so the equality guard is what stops the
3224 // redundant fanout.
3225 let active = node.activation == crate::arena::ActivationState::Active;
3226 if sig.get() != active {
3227 sig.set(active);
3228 }
3229 }
3230 }
3231
3232 /// Bind an opacity multiplier (0..1) to a widget. The render walker
3233 /// emits `SetOpacity(value)` before painting the widget's subtree
3234 /// and `RestoreOpacity` afterwards, so the multiplier composes
3235 /// correctly with ancestor opacity scopes via the canvas's stacked
3236 /// opacity model. Bound at `Repaint` level: opacity changes never
3237 /// trigger relayout. Pass any `Prop<f32>` or `Signal<f32>` source
3238 /// (typically an animated signal driven by a `Fade` wrapper).
3239 pub fn set_opacity(&mut self, id: WidgetId, opacity: impl Into<crate::signal::Prop<f32>>) {
3240 let prop = opacity.into();
3241 prop.register_if_bound(
3242 id,
3243 &self.binding_registry,
3244 crate::binding::BindingLevel::RepaintOnly,
3245 );
3246 if let Some(node) = self.arena.get_mut(id) {
3247 node.opacity_prop = Some(prop);
3248 }
3249 }
3250
3251 /// Bind a 2D affine transform to a widget. The render walker emits
3252 /// `PushTransform(value)` before painting the widget's subtree and
3253 /// `PopTransform` afterwards; the renderer composes the transform
3254 /// onto its stack so nested wrappers and widget-internal canvas
3255 /// transforms compose correctly. Bound at `Repaint` level: visual-
3256 /// only transforms never trigger relayout. Wrappers that want the
3257 /// transform's *value change* to also drive layout (e.g.
3258 /// `Scale::reflow(true)`) must additionally bind the *driver*
3259 /// signal to themselves at `Relayout` level — the transform prop
3260 /// itself stays at Repaint. Pass a `Transform2D`, `Signal<Transform2D>`,
3261 /// or `Prop<Transform2D>`.
3262 pub fn set_transform(
3263 &mut self,
3264 id: WidgetId,
3265 transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
3266 ) {
3267 let prop = transform.into();
3268 prop.register_if_bound(
3269 id,
3270 &self.binding_registry,
3271 crate::binding::BindingLevel::RepaintOnly,
3272 );
3273 if let Some(node) = self.arena.get_mut(id) {
3274 node.transform_prop = Some(prop);
3275 // A plain transform is a *self* transform — clear any prior
3276 // content-transform marker so the flag can never go stale if a
3277 // node switches from `set_content_transform` to `set_transform`.
3278 node.content_transform = false;
3279 }
3280 }
3281
3282 /// Like [`set_transform`](Self::set_transform), but marks the transform as
3283 /// a **content** transform: it positions the node's content within a fixed
3284 /// parent-space viewport (the node's bounds) rather than transforming the
3285 /// node itself. Hit-testing then keeps the whole viewport interactive at
3286 /// any pan / zoom. Used by `SceneView`; see
3287 /// `WidgetNode::content_transform`.
3288 pub fn set_content_transform(
3289 &mut self,
3290 id: WidgetId,
3291 transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
3292 ) {
3293 let prop = transform.into();
3294 prop.register_if_bound(
3295 id,
3296 &self.binding_registry,
3297 crate::binding::BindingLevel::RepaintOnly,
3298 );
3299 if let Some(node) = self.arena.get_mut(id) {
3300 node.transform_prop = Some(prop);
3301 node.content_transform = true;
3302 }
3303 }
3304
3305 /// Bind a Gaussian-equivalent blur radius to a widget. The render
3306 /// walker emits `BeginBlurredSubtree { bounds, radius }` before
3307 /// painting the widget's subtree and `EndBlurredSubtree` afterwards;
3308 /// the renderer redirects drawing into an intermediate texture, runs
3309 /// a dual-Kawase blur chain at the requested radius, and composites
3310 /// the blurred result back into the parent pass. Bound at `Repaint`
3311 /// level: blur radius changes never trigger relayout. Sub-perceptual
3312 /// radii (< 0.5) skip the Begin/End pair entirely so animated
3313 /// enable/disable patterns have zero per-frame cost when fully off.
3314 /// Pass any `Prop<f32>` or `Signal<f32>` source.
3315 pub fn set_blur(&mut self, id: WidgetId, radius: impl Into<crate::signal::Prop<f32>>) {
3316 let prop = radius.into();
3317 prop.register_if_bound(
3318 id,
3319 &self.binding_registry,
3320 crate::binding::BindingLevel::RepaintOnly,
3321 );
3322 if let Some(node) = self.arena.get_mut(id) {
3323 node.blur_prop = Some(prop);
3324 }
3325 }
3326
3327 /// Bind a widget's enabled state to a boolean prop or compatibility state binding.
3328 /// When false, the widget and its entire subtree ignore all events but remain
3329 /// visible. Focus traversal skips disabled subtrees and AccessKit marks their
3330 /// nodes as disabled. Accepts `Signal<bool>`, `Prop<bool>`, compatibility state
3331 /// bindings, or plain `bool`.
3332 ///
3333 /// The bound signal registers at `BindingLevel::SubtreeRepaint`: when
3334 /// it flips, the entire subtree rooted at `id` is marked for repaint
3335 /// (not relayout — geometry doesn't change). Leaves like
3336 /// `IconWidget` then re-resolve their role color
3337 /// using the new `PaintContext::effective_enabled` value, so a
3338 /// disabled subtree's icons and text dim automatically.
3339 pub fn enabled_when(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
3340 let prop = state.into();
3341 // SubtreeRepaint propagates the visual dirty mark through the
3342 // disabled subtree so leaves re-resolve their role colors.
3343 prop.register_if_bound(
3344 id,
3345 &self.binding_registry,
3346 crate::binding::BindingLevel::SubtreeRepaint,
3347 );
3348 // AccessibilityOnly is orthogonal — it flips `a11y_dirty` so
3349 // AccessKit's `disabled` flag refreshes on the next a11y sync
3350 // (the accessibility walker reads `arena.is_enabled(id)`,
3351 // which is already correct via the prop, but the tree needs
3352 // to be told to rebuild).
3353 prop.register_if_bound(
3354 id,
3355 &self.binding_registry,
3356 crate::binding::BindingLevel::AccessibilityOnly,
3357 );
3358 if let Some(node) = self.arena.get_mut(id) {
3359 node.enabled_state = Some(prop);
3360 }
3361 }
3362
3363 /// Reactive view of "is this widget effectively enabled?" — the AND
3364 /// of the widget's own `enabled_state` and every ancestor's.
3365 /// [`Self::is_enabled`] is the non-reactive equivalent; this method
3366 /// gives composite widgets a `Signal<bool>` for derived state.
3367 ///
3368 /// Leaves (`IconWidget`, `TextWidget`, `RectWidget`) do NOT need this
3369 /// — they receive the resolved bool via
3370 /// [`crate::widget::PaintContext::effective_enabled`] at paint time.
3371 /// This method is for composites that want to derive cursor / custom
3372 /// paint roles / etc. reactively.
3373 ///
3374 /// Install-or-reuse, exactly like [`Self::activation_signal`]: the signal
3375 /// lives on the node and the framework refreshes it from the live arena
3376 /// once per state-change pass (`flush_effective_enabled_signals`).
3377 ///
3378 /// It is deliberately NOT a signal derived by walking the ancestor chain
3379 /// here. A widget's `parent` is still `None` while its own `build()` runs
3380 /// — `insert_widget` inserts the node parentless and wires the parent link
3381 /// only after `build()` returns — so an ancestor walk performed from
3382 /// inside `build()` (which is how every caller uses this) sees an empty
3383 /// chain and would capture the widget's OWN `enabled` prop as the whole
3384 /// answer, permanently. That was a real bug: a Button inside a disabled
3385 /// form stayed painted as if enabled.
3386 ///
3387 /// The value is seeded from the live arena and corrected on the next
3388 /// flush, so a first-`build()` caller (parent not yet wired) and a
3389 /// rebuild caller (parent wired) both converge before anything paints.
3390 pub fn effective_enabled_signal(&mut self, id: WidgetId) -> crate::signal::Signal<bool> {
3391 if let Some(existing) = self
3392 .arena
3393 .get(id)
3394 .and_then(|n| n.effective_enabled_signal.clone())
3395 {
3396 return existing;
3397 }
3398 // Seed from the live tree. Mid-`build()` the parent is not wired yet,
3399 // so this is the widget's own state only; `flush_effective_enabled_signals`
3400 // corrects it against the fully-wired tree before the first paint.
3401 let seed = self.arena.is_enabled(id);
3402 let sig = crate::signal::Signal::new(seed);
3403 let Some(node) = self.arena.get_mut(id) else {
3404 // Node missing (shouldn't happen in build) — hand back a detached
3405 // handle so the caller still gets a valid signal.
3406 return crate::signal::Signal::new(true);
3407 };
3408 node.effective_enabled_signal = Some(sig.clone());
3409 self.arena.watch_effective_enabled(id);
3410 sig
3411 }
3412
3413 /// Refresh every node-resident `effective_enabled_signal` against the live
3414 /// arena, firing observers only where the value actually changed.
3415 ///
3416 /// Unlike [`Self::flush_activation_signals`] this cannot be driven off a
3417 /// change queue: a node's `enabled_state` is a `Prop<bool>` that may be
3418 /// bound to an app `Signal` which flips without the arena being notified,
3419 /// so there is no mutation site at which to record a transition. Instead
3420 /// this recomputes the (cheap, `O(depth)`) ancestor AND for each opted-in
3421 /// node and diffs. Only nodes that called
3422 /// [`Self::effective_enabled_signal`] are visited, so a tree with no
3423 /// interactive widgets pays nothing.
3424 ///
3425 /// Values are collected first and set afterwards: a `Signal::set` observer
3426 /// may mutate the tree, and must not run while the arena is being walked —
3427 /// the same discipline as `flush_activation_signals` and the
3428 /// `focus_within` / `hover_within` updates.
3429 pub(crate) fn flush_effective_enabled_signals(&mut self) {
3430 self.arena.prune_effective_enabled_watchers();
3431 let mut updates: Vec<(crate::signal::Signal<bool>, bool)> = Vec::new();
3432 for id in self.arena.effective_enabled_watchers() {
3433 let Some(sig) = self
3434 .arena
3435 .get(id)
3436 .and_then(|n| n.effective_enabled_signal.clone())
3437 else {
3438 continue;
3439 };
3440 let now = self.arena.is_enabled(id);
3441 if sig.get() != now {
3442 updates.push((sig, now));
3443 }
3444 }
3445 for (sig, value) in updates {
3446 sig.set(value);
3447 }
3448 }
3449
3450 /// Whether a widget is effectively enabled. Returns `false` if the widget
3451 /// itself or any ancestor has `enabled_state` bound to `false`.
3452 pub fn is_enabled(&self, id: WidgetId) -> bool {
3453 self.arena.is_enabled(id)
3454 }
3455
3456 /// Bind a widget's Tab-key participation to a boolean prop or
3457 /// compatibility state binding. When false, the widget is removed
3458 /// from Tab / Shift+Tab traversal (`cycle_focus`) but remains
3459 /// reachable via `request_focus` and arrow-key navigation that
3460 /// calls `request_focus`. Implements the ARIA roving-tabindex
3461 /// pattern (HTML `tabindex="-1"` semantics). Accepts
3462 /// `Signal<bool>`, `Prop<bool>`, or plain `bool`.
3463 pub fn set_tab_stop(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
3464 let prop = state.into();
3465 // Bind at the lightest level — tab-stop changes never affect
3466 // layout or paint; cycle_focus reads the current value on
3467 // each Tab keypress.
3468 prop.register_if_bound(
3469 id,
3470 &self.binding_registry,
3471 crate::binding::BindingLevel::RepaintOnly,
3472 );
3473 if let Some(node) = self.arena.get_mut(id) {
3474 node.tab_stop = Some(prop);
3475 }
3476 }
3477
3478 /// Current Tab-key participation for a widget. Returns the value
3479 /// of the `tab_stop` prop if bound, or `true` (the default) when
3480 /// no binding is present. Mirrors the filter used by
3481 /// `cycle_focus` — primarily for tests asserting the
3482 /// roving-tabindex contract.
3483 pub fn tab_stop(&self, id: WidgetId) -> bool {
3484 self.arena
3485 .get(id)
3486 .and_then(|node| node.tab_stop.as_ref())
3487 .map(|prop| prop.get())
3488 .unwrap_or(true)
3489 }
3490
3491 /// Declare `id` as a **traversal-scope boundary** with the given policy.
3492 /// `cycle_focus` then treats the node's subtree as an independent Tab
3493 /// group: `tab_index` values inside it are scoped (they never collide
3494 /// with sibling scopes) and `policy` governs Tab at the scope's ends.
3495 ///
3496 /// The scope node is forced non-focusable — it is a transparent boundary,
3497 /// never itself a Tab stop. Called from `BuildContext::set_traversal_scope`
3498 /// (which the `FocusScope` wrapper widget invokes during `build`), and
3499 /// directly usable from tests with no dependency on the widgets crate.
3500 pub fn set_traversal_scope(
3501 &mut self,
3502 id: WidgetId,
3503 policy: crate::focus::TraversalScopePolicy,
3504 ) {
3505 if let Some(node) = self.arena.get_mut(id) {
3506 node.node_traversal_scope = Some(policy);
3507 node.node_focusable = Some(false);
3508 }
3509 }
3510
3511 /// Remove a previously set traversal-scope marker from `id` (rebuild
3512 /// paths where a `FocusScope` is replaced by a non-scope widget). Leaves
3513 /// `node_focusable` untouched — a later handler-set application resets it.
3514 pub fn clear_traversal_scope(&mut self, id: WidgetId) {
3515 if let Some(node) = self.arena.get_mut(id) {
3516 node.node_traversal_scope = None;
3517 }
3518 }
3519
3520 /// Current traversal-scope policy on `id`, if any. For tests asserting
3521 /// the scope marker contract.
3522 pub fn traversal_scope(&self, id: WidgetId) -> Option<crate::focus::TraversalScopePolicy> {
3523 self.arena
3524 .get(id)
3525 .and_then(|node| node.node_traversal_scope)
3526 }
3527
3528 // --- Theme override ---
3529
3530 /// Set a theme override on a widget. All descendants of this widget
3531 /// will see the modified theme during layout and paint.
3532 /// The override function receives a mutable `Theme` to modify.
3533 ///
3534 /// ```
3535 /// # use teksilo_core::{Widget, LayoutResponse, LayoutContext, widget_tree::WidgetTree};
3536 /// # use teksilo_canvas::{Size, SizeProposal};
3537 /// # use teksilo_tokens::ColorTokens;
3538 /// # #[derive(Debug)] struct MinWidget;
3539 /// # impl Widget for MinWidget {
3540 /// # fn layout_response(&self, _: SizeProposal, _: &LayoutContext) -> LayoutResponse {
3541 /// # Size::new(0.0, 0.0).into()
3542 /// # }
3543 /// # }
3544 /// # let mut tree = WidgetTree::new();
3545 /// # let panel_id = tree.add(MinWidget);
3546 /// tree.set_theme_override(panel_id, |theme| {
3547 /// theme.colors = ColorTokens::dark_default();
3548 /// });
3549 /// ```
3550 pub fn set_theme_override(
3551 &mut self,
3552 id: WidgetId,
3553 f: impl Fn(&mut crate::styles::Theme) + 'static,
3554 ) {
3555 let had_override = self
3556 .arena
3557 .get(id)
3558 .is_some_and(|n| n.theme_override.is_some());
3559 if let Some(node) = self.arena.get_mut(id) {
3560 node.theme_override = Some(crate::environment::ThemeOverride { func: Box::new(f) });
3561 node.dirty.needs_layout = true;
3562 node.dirty.needs_paint = true;
3563 }
3564 if !had_override {
3565 self.arena.theme_override_count += 1;
3566 }
3567 }
3568
3569 /// Get the resolved theme for a specific widget (applying ancestor overrides).
3570 pub fn resolved_theme(&self, id: WidgetId) -> crate::styles::Theme {
3571 self.arena.resolve_theme(id, &self.theme).into_owned()
3572 }
3573}
3574
3575impl Default for WidgetTree {
3576 fn default() -> Self {
3577 Self::new()
3578 }
3579}
3580
3581#[cfg(test)]
3582mod activation_signal_tests {
3583 use super::*;
3584 use crate::build_context::BuildContext;
3585 use crate::signal::Signal;
3586 use crate::widget::{LayoutContext, LayoutResponse, Widget};
3587 use teksilo_canvas::SizeProposal;
3588
3589 /// A leaf that, on build, opts into its activation signal and mirrors it
3590 /// into an out-of-band signal the test can read.
3591 #[derive(Debug)]
3592 struct ActivationProbe {
3593 log: Signal<Vec<bool>>,
3594 }
3595
3596 impl Widget for ActivationProbe {
3597 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
3598 let id = ctx.self_id();
3599 let vis = ctx.activation_signal(id);
3600 let log = self.log.clone();
3601 ctx.effect(&vis, move |active| {
3602 let mut v = log.get();
3603 v.push(*active);
3604 log.set(v);
3605 });
3606 Vec::new()
3607 }
3608
3609 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
3610 proposal.resolve(10.0, 10.0).into()
3611 }
3612 }
3613
3614 /// A widget that enqueues a post-mount action via `run_after_mount` has it
3615 /// run exactly once, with a real `EventContext`, when the tree drains
3616 /// (`run_mount_actions`) — and `has_pending_mount_actions` reflects the
3617 /// queue state.
3618 #[derive(Debug)]
3619 struct MountActionProbe {
3620 ran: Signal<u32>,
3621 }
3622
3623 impl Widget for MountActionProbe {
3624 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
3625 let ran = self.ran.clone();
3626 ctx.run_after_mount(move |_ectx| ran.set(ran.get() + 1));
3627 Vec::new()
3628 }
3629
3630 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
3631 proposal.resolve(10.0, 10.0).into()
3632 }
3633 }
3634
3635 #[test]
3636 fn run_after_mount_runs_once_on_drain() {
3637 let mut tree = WidgetTree::new();
3638 let ran = Signal::new(0_u32);
3639 tree.add(MountActionProbe { ran: ran.clone() });
3640 tree.layout(SizeProposal::exact(100.0, 100.0));
3641
3642 // Queued during build, not yet run.
3643 assert!(tree.has_pending_mount_actions());
3644 assert_eq!(ran.get(), 0);
3645
3646 // Drain with a Noop sink (as headless callers do).
3647 tree.run_mount_actions(&mut crate::window::NoopWindowOps);
3648 assert_eq!(ran.get(), 1);
3649 assert!(!tree.has_pending_mount_actions());
3650
3651 // Draining again is a no-op (the queue is empty).
3652 tree.run_mount_actions(&mut crate::window::NoopWindowOps);
3653 assert_eq!(ran.get(), 1);
3654 }
3655
3656 #[test]
3657 fn activation_signal_fires_on_dormant_and_reactivate() {
3658 let mut tree = WidgetTree::new();
3659 let log = Signal::new(Vec::<bool>::new());
3660 let probe = tree.add(ActivationProbe { log: log.clone() });
3661
3662 // Gate the probe's visibility on a signal.
3663 let visible = Signal::new(true);
3664 tree.visible_when(probe, visible.clone());
3665 tree.layout(SizeProposal::exact(100.0, 100.0));
3666
3667 // Initially active: effect registration alone fires nothing.
3668 assert_eq!(log.get(), Vec::<bool>::new());
3669
3670 // Hide → dormant → activation signal false.
3671 visible.set(false);
3672 tree.layout(SizeProposal::exact(100.0, 100.0));
3673 assert_eq!(log.get(), vec![false]);
3674
3675 // Show → active → activation signal true.
3676 visible.set(true);
3677 tree.layout(SizeProposal::exact(100.0, 100.0));
3678 assert_eq!(log.get(), vec![false, true]);
3679
3680 // Redundant relayout while active fires nothing new.
3681 tree.layout(SizeProposal::exact(100.0, 100.0));
3682 assert_eq!(log.get(), vec![false, true]);
3683 }
3684
3685 /// **A node parked and re-woken before the flush never looks dormant.**
3686 ///
3687 /// `pending_activation_changes` is an append-only queue, so this records
3688 /// two edges; replaying them in order would hand observers a `false` that
3689 /// was already superseded — for a state signal that is a lie, not a
3690 /// history. `present_in_tree_modal_request` takes exactly this route on
3691 /// every dialog (build the content, park it, mount the scrim, wake it,
3692 /// *then* move focus in), and the stale `false` landed after the focus
3693 /// dispatch: a text editor's dormancy handler wiped the focus it had just
3694 /// been granted and the dialog opened with no caret.
3695 #[test]
3696 fn a_park_and_wake_inside_one_batch_fires_nothing() {
3697 let mut tree = WidgetTree::new();
3698 let log = Signal::new(Vec::<bool>::new());
3699 let probe = tree.add(ActivationProbe { log: log.clone() });
3700 tree.layout(SizeProposal::exact(100.0, 100.0));
3701 assert_eq!(log.get(), Vec::<bool>::new());
3702
3703 tree.set_dormant(probe);
3704 tree.activate(probe);
3705 tree.layout(SizeProposal::exact(100.0, 100.0));
3706 assert_eq!(
3707 log.get(),
3708 Vec::<bool>::new(),
3709 "a node that ends the batch where it started never observably \
3710 changed — firing the intermediate `false` makes observers act on \
3711 a state that was never visible",
3712 );
3713
3714 // The converse still reports: a batch with a *net* transition fires
3715 // once, with the state the node actually ended in.
3716 tree.activate(probe);
3717 tree.set_dormant(probe);
3718 tree.layout(SizeProposal::exact(100.0, 100.0));
3719 assert_eq!(
3720 log.get(),
3721 vec![false],
3722 "a net Active→Dormant batch must still report, exactly once",
3723 );
3724
3725 tree.activate(probe);
3726 tree.layout(SizeProposal::exact(100.0, 100.0));
3727 assert_eq!(log.get(), vec![false, true]);
3728 }
3729}
3730
3731#[cfg(test)]
3732mod visible_when_builder_tests {
3733 use super::*;
3734 use crate::signal::{Prop, Signal};
3735 use crate::widget_builder::WidgetBuilder;
3736
3737 /// `.visible_when(signal)` on any widget builder threads a *bound*
3738 /// visibility prop onto the inserted node — so `teksu!`'s property form
3739 /// (`Widget { visible_when: sig }`) reaches the same `node.visible_state`
3740 /// slot as the imperative `ctx.visible_when(id, sig)`.
3741 #[test]
3742 fn visible_when_builder_binds_node_visibility() {
3743 let mut tree = WidgetTree::new();
3744 let shown = Signal::new(false);
3745 let id = tree.add(crate::test_widgets::FillWidget::new().visible_when(shown.clone()));
3746
3747 let node = tree.arena.get(id).expect("node exists");
3748 assert!(
3749 matches!(node.visible_state, Some(Prop::Bound(_))),
3750 "`.visible_when(Signal)` must store a bound visibility prop"
3751 );
3752 }
3753
3754 /// A static `bool` is accepted too (`Prop::Static`), matching
3755 /// `ctx.visible_when` / `access_hidden` semantics.
3756 #[test]
3757 fn visible_when_builder_accepts_static_bool() {
3758 let mut tree = WidgetTree::new();
3759 let id = tree.add(crate::test_widgets::FillWidget::new().visible_when(false));
3760
3761 let node = tree.arena.get(id).expect("node exists");
3762 assert!(matches!(node.visible_state, Some(Prop::Static(false))));
3763 }
3764}
3765
3766#[cfg(test)]
3767mod text_scale_tests {
3768 use super::*;
3769
3770 #[test]
3771 fn effective_theme_starts_equal_to_theme() {
3772 let tree = WidgetTree::new();
3773 assert_eq!(
3774 tree.effective_theme.typography.body.size,
3775 tree.theme.typography.body.size
3776 );
3777 assert_eq!(tree.user_text_scale(), 1.0);
3778 }
3779
3780 #[test]
3781 fn effective_text_scale_and_signal_track_the_combined_factor() {
3782 let mut tree = WidgetTree::new();
3783 assert_eq!(tree.effective_text_scale(), 1.0);
3784 assert_eq!(tree.text_scale_signal().get(), 1.0);
3785
3786 tree.set_user_text_scale(1.5);
3787 assert!((tree.effective_text_scale() - 1.5).abs() < 0.001);
3788 assert!((tree.text_scale_signal().get() - 1.5).abs() < 0.001);
3789
3790 // OS preference multiplies in.
3791 tree.set_accessibility_preferences(false, false, 2.0);
3792 assert!((tree.effective_text_scale() - 3.0).abs() < 0.01);
3793 assert!((tree.text_scale_signal().get() - 3.0).abs() < 0.01);
3794 }
3795
3796 #[test]
3797 fn set_user_text_scale_scales_effective_typography() {
3798 let mut tree = WidgetTree::new();
3799 let base = tree.theme.typography.body.size;
3800 tree.set_user_text_scale(1.5);
3801 assert!((tree.effective_theme.typography.body.size - base * 1.5).abs() < 0.001);
3802 // The unscaled base theme is untouched.
3803 assert_eq!(tree.theme.typography.body.size, base);
3804 }
3805
3806 #[test]
3807 fn set_theme_preserves_existing_user_scale() {
3808 let mut tree = WidgetTree::new();
3809 tree.set_user_text_scale(2.0);
3810 let dark = crate::presets::intui::dark();
3811 let dark_base = dark.typography.body.size;
3812 tree.set_theme(dark);
3813 assert!((tree.effective_theme.typography.body.size - dark_base * 2.0).abs() < 0.001);
3814 }
3815
3816 #[test]
3817 fn os_text_scale_multiplies_with_user_scale() {
3818 let mut tree = WidgetTree::new();
3819 let base = tree.theme.typography.body.size;
3820 tree.set_user_text_scale(1.5);
3821 // OS preference reports 1.2 → combined 1.8.
3822 tree.set_accessibility_preferences(false, false, 1.2);
3823 assert!((tree.effective_theme.typography.body.size - base * 1.8).abs() < 0.01);
3824 }
3825
3826 #[test]
3827 fn same_scale_is_a_noop_and_factor_is_clamped() {
3828 let mut tree = WidgetTree::new();
3829 tree.set_user_text_scale(1.5);
3830 // Re-setting the same value should not panic / change anything.
3831 tree.set_user_text_scale(1.5);
3832 assert_eq!(tree.user_text_scale(), 1.5);
3833 // Out-of-range clamps into [0.25, 8.0].
3834 tree.set_user_text_scale(100.0);
3835 assert_eq!(tree.user_text_scale(), 8.0);
3836 }
3837}
3838
3839/// Covers the `WidgetTree`-level facts
3840/// `teksilo_app::WindowManager::request_redraw_needing_render` (the
3841/// targeted cross-window redraw added for shared-`Signal` dispatch
3842/// fan-out) relies on. Neither test touches windows at all — they exist
3843/// to pin down `needs_render()`'s contract in isolation, since
3844/// teksilo-app cannot stand up a real `PlatformWindow` in a unit test.
3845#[cfg(test)]
3846mod cross_window_redraw_signal_tests {
3847 use super::*;
3848 use crate::signal::Signal;
3849 use crate::test_widgets::{FillWidget, StackWidget};
3850 use teksilo_canvas::SizeProposal;
3851
3852 /// The premise the fix acts on, AND the trap a naive fix would fall
3853 /// into. A `Signal` shared by two independent trees (standing in for
3854 /// two windows) is supposed to dirty both when mutated, even though
3855 /// only one of them is the tree whose dispatch made the mutation —
3856 /// but `Signal::set` only flips a dirty flag on the signal itself and
3857 /// in the `BindingRegistry`; nothing walks that into a tree's
3858 /// `needs_layout` / `needs_paint` bits (what `needs_render()` reads)
3859 /// except that tree's OWN `process_state_changes`, run at the top of
3860 /// its OWN `layout()`. So immediately after the mutation, with
3861 /// neither tree having re-run `layout()`, BOTH read clean — a naive
3862 /// "just check `needs_render()`" cross-window redraw would see
3863 /// nothing to do and stay a permanent no-op. Once tree B's `layout()`
3864 /// runs (what `request_redraw_needing_render` does for every window
3865 /// before checking it), the same mutation is finally visible there.
3866 ///
3867 /// Each tree wraps its gated leaf in a `StackWidget` parent (rather
3868 /// than gating a bare root leaf) so the fact under test — an ACTIVE
3869 /// widget ending up dirty — is unambiguous: `any_needs_layout()` /
3870 /// `any_needs_paint()` only ever look at `Active` nodes, and a leaf
3871 /// that itself goes dormant is deliberately excluded from both (a
3872 /// hidden widget has nothing to paint). What must go dirty here is
3873 /// the STILL-ACTIVE stack, via `mark_ancestors_need_layout` — the
3874 /// same mechanism that makes a real window's content re-flow around
3875 /// a child that just appeared or disappeared.
3876 #[test]
3877 fn a_shared_signal_mutation_only_shows_up_after_that_trees_own_layout_reconciles_it() {
3878 let shared = Signal::new(true);
3879
3880 let mut tree_a = WidgetTree::new();
3881 let stack_a = tree_a.add(StackWidget::new());
3882 let id_a = tree_a.add_child(stack_a, FillWidget::new());
3883 tree_a.visible_when(id_a, shared.clone());
3884
3885 let mut tree_b = WidgetTree::new();
3886 let stack_b = tree_b.add(StackWidget::new());
3887 let id_b = tree_b.add_child(stack_b, FillWidget::new());
3888 tree_b.visible_when(id_b, shared.clone());
3889
3890 let proposal = SizeProposal::exact(100.0, 100.0);
3891
3892 // Bring both to the same clean baseline a real event loop reaches
3893 // after its initial layout + paint.
3894 tree_a.layout(proposal);
3895 tree_a.render();
3896 tree_b.layout(proposal);
3897 tree_b.render();
3898 assert!(!tree_a.needs_render(), "precondition: tree A starts clean");
3899 assert!(!tree_b.needs_render(), "precondition: tree B starts clean");
3900
3901 // Simulate a handler mutating the shared Signal during tree A's
3902 // dispatch. Neither tree re-runs layout() here yet.
3903 shared.set(false);
3904
3905 assert!(
3906 !tree_a.needs_render(),
3907 "the mutation alone does not retroactively dirty tree A either — \
3908 a Signal write cannot poke an arena directly, only the next \
3909 process_state_changes (inside layout()) can"
3910 );
3911 assert!(
3912 !tree_b.needs_render(),
3913 "and tree B reads exactly as clean as tree A does at this point — \
3914 checking needs_render() without reconciling first cannot tell them apart"
3915 );
3916
3917 // ...but they are NOT indistinguishable to `needs_reconcile()`,
3918 // which is what `request_redraw_needing_render` actually gates
3919 // its reconcile on. Both trees observe the shared Signal, so
3920 // both report pending reactive work here — and asking is
3921 // read-only, so asking tree A first does not answer for tree B.
3922 assert!(
3923 tree_a.needs_reconcile() && tree_b.needs_reconcile(),
3924 "both trees must report pending reactive work from the shared write"
3925 );
3926
3927 // This is what `request_redraw_needing_render` does for a window
3928 // whose gate is open — reconcile at the window's OWN current
3929 // size, which is what walks a pending Signal-driven change into
3930 // the arena.
3931 tree_b.layout(proposal);
3932
3933 assert!(
3934 tree_b.needs_render(),
3935 "tree B, which merely OBSERVES the shared Signal, is now dirty — \
3936 this is the cross-tree fan-out request_redraw_needing_render \
3937 exists to notice (via its own reconcile-then-check) and repaint"
3938 );
3939 assert!(
3940 !tree_b.needs_reconcile(),
3941 "and having reconciled, tree B's gate closes again"
3942 );
3943 assert!(
3944 tree_a.needs_reconcile(),
3945 "while tree A — which has NOT reconciled — is still waiting; one \
3946 window's reconcile must never close another's gate"
3947 );
3948 }
3949
3950 /// The gate `request_redraw_needing_render` gained must stay SHUT for
3951 /// a window with nothing reactive pending, or it saves nothing: that
3952 /// method runs after every dispatched event, and an open gate costs
3953 /// a full `layout_with_ops` — a dozen per-frame passes (pending
3954 /// animations, frame tick, scheduler tick, drag tick,
3955 /// `process_state_changes`, tooltips, four overlay passes) before it
3956 /// reaches the geometry short-circuit, for every open window, on
3957 /// every event of a fast mouse-move stream.
3958 #[test]
3959 fn needs_reconcile_is_false_for_an_idle_tree() {
3960 let shared = Signal::new(true);
3961 let mut idle = WidgetTree::new();
3962 let stack = idle.add(StackWidget::new());
3963 let leaf = idle.add_child(stack, FillWidget::new());
3964 idle.visible_when(leaf, shared.clone());
3965 idle.layout(SizeProposal::exact(100.0, 100.0));
3966 idle.render();
3967
3968 assert!(!idle.needs_reconcile(), "nothing written — gate shut");
3969 assert!(!idle.needs_reconcile(), "and asking does not open it");
3970
3971 shared.set(false);
3972 assert!(idle.needs_reconcile(), "a write opens it");
3973 idle.layout(SizeProposal::exact(100.0, 100.0));
3974 assert!(!idle.needs_reconcile(), "reconciling closes it again");
3975 }
3976
3977 /// A *pending* `animate_to` contributes no scheduler deadline until
3978 /// `process_pending_animations` promotes it, so `next_timer_deadline`
3979 /// (and therefore `request_redraw_due`) cannot see it. The gate must,
3980 /// or arming an animation from another window's handler would leave
3981 /// it parked until something unrelated woke the window.
3982 #[test]
3983 fn needs_reconcile_sees_an_animation_armed_but_not_yet_started() {
3984 let mut tree = WidgetTree::new();
3985 let id = tree.add(FillWidget::new());
3986 tree.layout(SizeProposal::exact(50.0, 50.0));
3987 tree.render();
3988
3989 // Registered but deliberately NOT bound to any widget, so the
3990 // binding-registry term cannot be what notices it.
3991 let anim = Signal::new_animated(0.0_f32);
3992 tree.register_animated_signal(&anim, id);
3993 assert!(!tree.needs_reconcile(), "precondition: nothing armed yet");
3994
3995 anim.animate_to(
3996 1.0,
3997 std::time::Duration::from_millis(200),
3998 teksilo_tokens::Easing::Linear,
3999 );
4000 assert!(
4001 tree.needs_reconcile(),
4002 "an armed-but-unstarted animation is reactive work only layout() can pick up"
4003 );
4004 assert!(
4005 anim.has_pending_animation(),
4006 "and asking must not have consumed the request"
4007 );
4008
4009 tree.layout(SizeProposal::exact(50.0, 50.0));
4010 assert!(
4011 !anim.has_pending_animation(),
4012 "the reconcile promoted it into the scheduler"
4013 );
4014 }
4015
4016 /// An animation armed *after* the wall clock has overtaken the simulated one
4017 /// must still run.
4018 ///
4019 /// `layout` promotes a pending `animate_to` into the scheduler; once
4020 /// `tick_animations` is driving the tree, the scheduler is only ever ticked at
4021 /// `sim_clock`. Stamping the promotion with `Instant::now()` therefore put the
4022 /// start in the scheduler's *future* the moment a test's real time outran the
4023 /// simulated time it had asked for — and a start in the future does not run
4024 /// slow, it does not run at all. That made animated headless tests a function
4025 /// of machine load: green run alone, frozen once a full suite filled the cores
4026 /// and stretched each test's wall-clock time past its simulated budget.
4027 ///
4028 /// The two clocks below are the shape of that: 90 ms simulated against at
4029 /// least 100 ms real, so simulated time never catches up. Under the old
4030 /// behaviour the animation stays pinned at its start value forever.
4031 #[test]
4032 fn an_animation_armed_after_real_time_outran_the_sim_clock_still_runs() {
4033 let mut tree = WidgetTree::new();
4034 let id = tree.add(FillWidget::new());
4035 tree.layout(SizeProposal::exact(50.0, 50.0));
4036
4037 // Put the tree in simulated time, then let the wall clock get ahead of it.
4038 tree.tick_animations(std::time::Duration::from_millis(10));
4039 std::thread::sleep(std::time::Duration::from_millis(100));
4040
4041 let anim = Signal::new_animated(0.0_f32);
4042 tree.register_animated_signal(&anim, id);
4043 anim.animate_to(
4044 1.0,
4045 std::time::Duration::from_millis(50),
4046 teksilo_tokens::Easing::Linear,
4047 );
4048 tree.layout(SizeProposal::exact(50.0, 50.0));
4049
4050 // 80 ms of simulated time against a 50 ms animation: comfortably finished
4051 // on the only clock the scheduler is ever ticked with, and still short of
4052 // the ~100 ms of real time that has passed.
4053 tree.tick_animations(std::time::Duration::from_millis(80));
4054
4055 assert_eq!(
4056 anim.get(),
4057 1.0,
4058 "the animation must be measured against the clock it is ticked with, \
4059 not the wall clock that has already run past it"
4060 );
4061 assert!(
4062 !tree.has_active_animations(),
4063 "and having reached its target it must be off the scheduler"
4064 );
4065 }
4066
4067 /// `needs_render()` (paint/layout dirt only) must stay `false` while a
4068 /// per-frame `Signal<f32>` animation is merely *running*, with nothing
4069 /// new to paint. `request_redraw_needing_render` filters on
4070 /// `needs_render()`, not the broader `needs_redraw()`, precisely so a
4071 /// window with a live animation isn't forced into an extra immediate
4072 /// redraw on every sibling-window event — that would defeat the 60 Hz
4073 /// `WaitUntil` pacing those animations already get elsewhere and
4074 /// reintroduce the uncapped free-running redraw bug that pacing was
4075 /// written to remove.
4076 #[test]
4077 fn needs_render_excludes_a_running_animation_with_no_dirty_paint() {
4078 let mut tree = WidgetTree::new();
4079 let id = tree.add(FillWidget::new());
4080 tree.layout(SizeProposal::exact(50.0, 50.0));
4081 tree.render();
4082 assert!(!tree.needs_render(), "precondition: tree starts clean");
4083 assert!(!tree.needs_redraw(), "precondition: nothing running yet");
4084
4085 let anim = Signal::new_animated(0.0_f32);
4086 tree.register_animated_signal(&anim, id);
4087 anim.animate_to(
4088 1.0,
4089 std::time::Duration::from_millis(200),
4090 teksilo_tokens::Easing::Linear,
4091 );
4092 // `process_pending_animations` (which picks up the pending
4093 // `animate_to` and starts it on the scheduler) runs inside `layout`.
4094 tree.layout(SizeProposal::exact(50.0, 50.0));
4095
4096 assert!(
4097 tree.needs_redraw(),
4098 "an animation just started, so needs_redraw() (has_running()) must be true"
4099 );
4100 assert!(
4101 !tree.needs_render(),
4102 "but nothing is actually dirty for paint/layout — needs_render() must stay false, \
4103 which is the whole point of using it (not needs_redraw()) as the cross-window filter"
4104 );
4105 }
4106}
4107
4108#[cfg(test)]
4109mod effective_enabled_signal_tests {
4110 use super::*;
4111 use crate::build_context::BuildContext;
4112 use crate::signal::Signal;
4113 use crate::widget::{LayoutContext, LayoutResponse, Widget};
4114 use std::cell::RefCell;
4115 use std::rc::Rc;
4116 use teksilo_canvas::SizeProposal;
4117
4118 type SignalSlot = Rc<RefCell<Option<Signal<bool>>>>;
4119
4120 /// A leaf that opts into `effective_enabled_signal` from inside its own
4121 /// `build()` — the only way real widgets use it, and the case that was
4122 /// broken. It publishes the handle so the test can read the live value.
4123 #[derive(Debug)]
4124 struct EnabledProbe {
4125 out: SignalSlot,
4126 }
4127
4128 impl Widget for EnabledProbe {
4129 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
4130 let id = ctx.self_id();
4131 let sig = ctx.effective_enabled_signal(id);
4132 // Also pins that the signal is *mutable*: the previous derived
4133 // implementation panicked here with "observe() is only supported
4134 // on mutable signals", which is why widgets could not use
4135 // `ctx.effect` to react to being disabled.
4136 ctx.effect(&sig, |_| {});
4137 *self.out.borrow_mut() = Some(sig);
4138 Vec::new()
4139 }
4140
4141 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
4142 proposal.resolve(10.0, 10.0).into()
4143 }
4144 }
4145
4146 /// A composite that adds the probe through the ordinary `ctx.add` idiom, so
4147 /// the child is inserted PARENTLESS and builds before its parent link is
4148 /// wired — the exact situation that defeated the old
4149 /// walk-the-ancestors-at-call-time implementation.
4150 #[derive(Debug)]
4151 struct Form {
4152 enabled: Signal<bool>,
4153 out: SignalSlot,
4154 }
4155
4156 impl Widget for Form {
4157 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
4158 let id = ctx.self_id();
4159 ctx.enabled_when(id, self.enabled.clone());
4160 vec![ctx.add(EnabledProbe {
4161 out: self.out.clone(),
4162 })]
4163 }
4164
4165 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
4166 proposal.resolve(10.0, 10.0).into()
4167 }
4168 }
4169
4170 fn mount_form(enabled: Signal<bool>) -> (WidgetTree, WidgetId, Signal<bool>) {
4171 let out: SignalSlot = Rc::new(RefCell::new(None));
4172 let mut tree = WidgetTree::new();
4173 let form = tree.add(Form {
4174 enabled,
4175 out: out.clone(),
4176 });
4177 tree.layout(SizeProposal::exact(100.0, 100.0));
4178 let sig = out.borrow().clone().expect("probe published its signal");
4179 (tree, form, sig)
4180 }
4181
4182 /// THE REGRESSION. A widget whose own `enabled` is untouched must still
4183 /// report disabled when an ANCESTOR is disabled. This failed before the
4184 /// signal became node-resident: `insert_widget` inserts the node with
4185 /// `parent: None` and wires the parent only after `build()` returns, so an
4186 /// ancestor walk done during `build()` saw an empty chain and captured
4187 /// "enabled" for the widget's whole life.
4188 #[test]
4189 fn tracks_an_ancestor_disabled_before_mount() {
4190 let (_tree, _form, sig) = mount_form(Signal::new(false));
4191 assert!(
4192 !sig.get(),
4193 "a child of a disabled ancestor must report effectively-disabled"
4194 );
4195 }
4196
4197 /// The live case: the ancestor's bound signal flips after mount. The child
4198 /// must follow, in both directions, with no rebuild.
4199 #[test]
4200 fn follows_an_ancestor_flipping_after_mount() {
4201 let enabled = Signal::new(true);
4202 let (mut tree, _form, sig) = mount_form(enabled.clone());
4203 assert!(sig.get(), "starts enabled");
4204
4205 enabled.set(false);
4206 tree.layout(SizeProposal::exact(100.0, 100.0));
4207 assert!(!sig.get(), "child follows the ancestor going disabled");
4208
4209 enabled.set(true);
4210 tree.layout(SizeProposal::exact(100.0, 100.0));
4211 assert!(sig.get(), "and follows it coming back");
4212 }
4213
4214 /// A widget's own `enabled_state` still works on its own.
4215 #[test]
4216 fn honours_the_widgets_own_state() {
4217 let out: SignalSlot = Rc::new(RefCell::new(None));
4218 let mut tree = WidgetTree::new();
4219 let probe = tree.add(EnabledProbe { out: out.clone() });
4220 tree.enabled_when(probe, false);
4221 tree.layout(SizeProposal::exact(100.0, 100.0));
4222 let sig = out.borrow().clone().unwrap();
4223 assert!(!sig.get(), "own enabled_state alone disables");
4224 }
4225
4226 /// The signal must agree with the paint-time bool the render walker
4227 /// computes. If they disagreed, role-driven chrome (which dims from
4228 /// `PaintContext::effective_enabled`) and signal-driven chrome (which dims
4229 /// from this signal) would grey out at different moments.
4230 #[test]
4231 fn agrees_with_the_paint_time_effective_enabled() {
4232 let enabled = Signal::new(true);
4233 let (mut tree, form, sig) = mount_form(enabled.clone());
4234 let probe = tree.children(form)[0];
4235
4236 for value in [false, true, false] {
4237 enabled.set(value);
4238 tree.layout(SizeProposal::exact(100.0, 100.0));
4239 assert_eq!(
4240 sig.get(),
4241 tree.is_enabled(probe),
4242 "signal and the arena's live is_enabled must agree (enabled={value})"
4243 );
4244 }
4245 }
4246
4247 /// Install-or-reuse: asking twice hands back the same signal.
4248 #[test]
4249 fn is_install_or_reuse() {
4250 let out: SignalSlot = Rc::new(RefCell::new(None));
4251 let mut tree = WidgetTree::new();
4252 let id = tree.add(EnabledProbe { out });
4253 let a = tree.effective_enabled_signal(id);
4254 let b = tree.effective_enabled_signal(id);
4255 assert!(
4256 Signal::same(&a, &b),
4257 "must hand back the same signal handle"
4258 );
4259 }
4260}