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