Skip to main content

teksilo_core/
build_context.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! BuildContext — context available during Widget::build().
5//!
6//! Provides Signal-based APIs for creating reactive state, registering
7//! effects, and adding child widgets during the build lifecycle.
8
9use crate::binding::BindingRegistry;
10use crate::event_source::{SubscriptionHandle, SubscriptionId};
11use crate::signal::{ObserverHandle, Signal};
12use crate::widget_id::WidgetId;
13
14/// Context available during Widget::build().
15pub struct BuildContext<'a> {
16    pub(crate) tree: &'a mut crate::widget_tree::WidgetTree,
17    pub(crate) composite_id: Option<WidgetId>,
18    /// RAII handles for effects registered during this build cycle.
19    /// Transferred to the arena node's `effect_handles` after build returns.
20    pub(crate) effect_handles: Vec<ObserverHandle>,
21    /// Backend-event subscription handles registered during this build
22    /// cycle via `subscribe_event`. Transferred to the arena node's
23    /// `subscription_handles` after build returns.
24    pub(crate) subscription_handles: Vec<(SubscriptionId, SubscriptionHandle)>,
25    /// The `SubscriptionId`s the **previous** build of this same widget used, in the
26    /// order it created them. Empty on a first mount.
27    ///
28    /// A subscription's id is what crosses the thread boundary: the publisher-side
29    /// wrapper captures it by value and posts it, and the UI thread looks it up some
30    /// frames later. Minting a fresh id on every rebuild therefore silently destroys
31    /// every event already in flight, because `rebuild_single_widget` removes the
32    /// previous build's callbacks before `build()` runs and the queued event then names
33    /// an id nothing answers to. Re-using the ids here makes a subscription's identity
34    /// span the rebuilds of one widget, so an event posted before a rebuild is delivered
35    /// to the closure the *new* build installed.
36    ///
37    /// Matched **by position**, which is what makes it cheap and predictable: the Nth
38    /// `subscribe_event`/`subscribe_event_with_ctx` call of this build re-uses the id of
39    /// the Nth call of the last one. A build that subscribes fewer times simply leaves
40    /// the surplus ids unclaimed and they stay torn down; one that subscribes more
41    /// allocates fresh ids for the extras.
42    pub(crate) reusable_sub_ids: Vec<SubscriptionId>,
43}
44
45impl<'a> BuildContext<'a> {
46    /// The WidgetId of the widget being built.
47    pub fn self_id(&self) -> WidgetId {
48        self.composite_id
49            .expect("self_id() called outside of build()")
50    }
51
52    /// Add a widget to the tree.
53    pub fn add(&mut self, widget: impl crate::widget::Widget + 'static) -> WidgetId {
54        self.tree.add(widget)
55    }
56
57    /// Add a pre-boxed widget to the tree.
58    pub fn add_boxed(&mut self, widget: Box<dyn crate::widget::Widget>) -> WidgetId {
59        self.tree.add_boxed(widget)
60    }
61
62    /// Add a **parentless** widget this one owns: pre-built overlay content
63    /// (a dropdown menu, a date picker's calendar, a tooltip's nested cascade
64    /// children) that must not be reached by the child walk.
65    ///
66    /// Use this — never a bare [`add`](Self::add) — for anything built ahead of
67    /// time and parked with [`set_dormant`](Self::set_dormant) to be shown later
68    /// through an overlay. The two differ only in bookkeeping: `add` hands back
69    /// a node nothing owns, so the builder's own teardown cannot reach it and
70    /// every rebuild strands another copy in the arena; this records the
71    /// ownership edge, so the node dies with its owner and the previous
72    /// generation dies with each rebuild.
73    ///
74    /// Content that *can* be a child should be returned from `build()` as one
75    /// instead. This exists for content that cannot: activation and the paint
76    /// walk both descend through `children`, so a dormant popup parked there
77    /// wakes with its host and paints inline at zero size.
78    pub fn add_detached(&mut self, widget: impl crate::widget::Widget + 'static) -> WidgetId {
79        self.add_detached_boxed(Box::new(widget))
80    }
81
82    /// Insert a child whose subtree is **not built until `reveal` first turns
83    /// true**, and is retained from then on. Returns the host's id immediately.
84    ///
85    /// The shape this replaces is `ctx.add(panel)` followed by
86    /// `ctx.set_dormant(id)` — correct, but it builds content the user may never
87    /// open, on every rebuild of the owner. In a virtualized collection the
88    /// owner is a per-row delegate, so that cost is multiplied by the row count:
89    /// on a 40-row table whose cells each carried a four-item menu, the eager
90    /// form cost 325–552 ms per rebuild against 42–46 ms without the column at
91    /// all, and ~85% of it was the `add` rather than constructing the widget
92    /// value. See [`DeferredSubtree`](crate::deferred_subtree::DeferredSubtree)
93    /// for the full contract.
94    ///
95    /// Pass the same signal the content's `visible_when` gate uses. Everything
96    /// downstream of the returned id — `set_dormant` / `activate`,
97    /// `visible_when`, `OverlayRequest::content_id`, descendant checks,
98    /// dismissal — is unchanged; only when the subtree below it exists moves.
99    pub fn add_deferred(
100        &mut self,
101        reveal: crate::signal::Signal<bool>,
102        widget: impl crate::widget::Widget + 'static,
103    ) -> WidgetId {
104        self.add_deferred_boxed(reveal, Box::new(widget))
105    }
106
107    /// [`add_deferred`](Self::add_deferred) for an already-boxed widget.
108    pub fn add_deferred_boxed(
109        &mut self,
110        reveal: crate::signal::Signal<bool>,
111        widget: Box<dyn crate::widget::Widget>,
112    ) -> WidgetId {
113        self.add(crate::deferred_subtree::DeferredSubtree::new(
114            Some(reveal),
115            widget,
116        ))
117    }
118
119    /// [`add_deferred`](Self::add_deferred) for content the **framework**
120    /// materializes, kept as a child of the builder.
121    ///
122    /// The parented twin of
123    /// [`add_detached_deferred_on_demand`](Self::add_detached_deferred_on_demand),
124    /// for the two rich-tooltip attach paths: they have always parented their
125    /// body on the anchor's owner, and reparenting them to `detached` would move
126    /// which teardown reaps them. Only *when* the body is built changes.
127    ///
128    /// Worth the separate entry point because a rich tooltip is not one widget:
129    /// `RichTooltipWidget::build` eagerly pre-creates a nested tooltip for every
130    /// `:key` link in its body, recursively, so one attached tip expands into a
131    /// cascade. Built eagerly on a data view's row delegate, 29 rows of
132    /// Skribisto's Overview carried 1,305 tooltip widgets inside a 22,737-node
133    /// subtree, and tearing that down cost 5.3 s per arrow-key press — the
134    /// destroy, not the build.
135    pub fn add_deferred_on_demand(
136        &mut self,
137        widget: impl crate::widget::Widget + 'static,
138    ) -> WidgetId {
139        self.add(crate::deferred_subtree::DeferredSubtree::new(
140            None,
141            Box::new(widget),
142        ))
143    }
144
145    /// [`add_deferred`](Self::add_deferred) for content the **framework**
146    /// materializes rather than a widget's own open signal.
147    ///
148    /// The tooltip case: a tooltip body has no open signal a widget could hand
149    /// over — the tree decides, when a dwell matures. `WidgetTree` forces such
150    /// a host just before it consults `Widget::tooltip_has_content`, so the
151    /// body exists by the time anything asks it a question.
152    pub fn add_detached_deferred_on_demand(
153        &mut self,
154        widget: impl crate::widget::Widget + 'static,
155    ) -> WidgetId {
156        self.add_detached(crate::deferred_subtree::DeferredSubtree::new(
157            None,
158            Box::new(widget),
159        ))
160    }
161
162    /// [`add_deferred`](Self::add_deferred), inserted detached — the shape
163    /// overlay content wants, so it is owned by the builder and dies with it
164    /// rather than outliving every menu the user ever opened.
165    pub fn add_detached_deferred_boxed(
166        &mut self,
167        reveal: crate::signal::Signal<bool>,
168        widget: Box<dyn crate::widget::Widget>,
169    ) -> WidgetId {
170        self.add_detached(crate::deferred_subtree::DeferredSubtree::new(
171            Some(reveal),
172            widget,
173        ))
174    }
175
176    /// [`add_detached_deferred_boxed`](Self::add_detached_deferred_boxed) for an
177    /// unboxed widget.
178    pub fn add_detached_deferred(
179        &mut self,
180        reveal: crate::signal::Signal<bool>,
181        widget: impl crate::widget::Widget + 'static,
182    ) -> WidgetId {
183        self.add_detached_deferred_boxed(reveal, Box::new(widget))
184    }
185
186    /// [`add_detached`](Self::add_detached) for an already-boxed widget.
187    pub fn add_detached_boxed(&mut self, widget: Box<dyn crate::widget::Widget>) -> WidgetId {
188        let id = self.tree.add_boxed(widget);
189        let owner = self.self_id();
190        self.tree.record_detached(owner, id);
191        id
192    }
193
194    /// Add a Level 2 widget as a child of another widget.
195    pub fn add_child(
196        &mut self,
197        parent: WidgetId,
198        widget: impl crate::widget::Widget + 'static,
199    ) -> WidgetId {
200        self.tree.add_child(parent, widget)
201    }
202
203    // --- Signal APIs ---
204
205    /// Create a new mutable signal.
206    pub fn signal<T: 'static>(&mut self, value: T) -> Signal<T> {
207        Signal::new(value)
208    }
209
210    /// Create a new `Signal<f32>` that supports `animate_to()`.
211    /// Registered with the animation scheduler automatically. The owning
212    /// widget (`self_id()`) is recorded so that the scheduler can pause
213    /// the animation when the widget is offscreen, dormant, or rebuilt.
214    pub fn animated_signal(&mut self, value: f32) -> Signal<f32> {
215        let signal = Signal::new_animated(value);
216        let owner = self.self_id();
217        self.tree.register_animated_signal(&signal, owner);
218        signal
219    }
220
221    /// Register a pre-existing `Signal<f32>` for animation support.
222    /// Use this when the signal was created outside of `build()` (e.g. in the
223    /// widget constructor) and needs to be registered with the animation scheduler.
224    pub fn register_animated_signal(&mut self, signal: &Signal<f32>) {
225        let owner = self.self_id();
226        self.tree.register_animated_signal(signal, owner);
227    }
228
229    /// Read the OS-level `prefers-reduced-motion` preference. Widgets
230    /// that use looping or decorative animations (spinners, sprite
231    /// icons, marquee text, etc.) should skip starting them when this
232    /// returns `true` so the UI respects accessibility settings and —
233    /// as a bonus — draws no CPU/GPU.
234    pub fn prefers_reduced_motion(&self) -> bool {
235        self.tree.prefers_reduced_motion()
236    }
237
238    /// Build an [`AnimationSpec`](crate::animation_builder::AnimationSpec)
239    /// — the fluent ergonomic façade over `Signal<f32>::animate_to`.
240    /// Captures the theme's `MotionTokens` and the platform
241    /// reduced-motion preference at build time, returns a clonable
242    /// spec that event-handler closures can drive without
243    /// re-threading durations and easing.
244    ///
245    /// ```ignore
246    /// let knob_anim = ctx.animate().fast().standard();
247    /// handlers = handlers.on_tap(move |_, _| {
248    ///     knob_anim.to_or_snap(&knob_position, target);
249    /// });
250    /// ```
251    pub fn animate(&self) -> crate::animation_builder::AnimationSpec {
252        crate::animation_builder::AnimationSpec::from_motion(
253            self.theme().motion.clone(),
254            self.prefers_reduced_motion(),
255        )
256    }
257
258    /// Opt into the shader-driven animated-quad pipeline. The widget
259    /// paint() emits ONE `canvas.draw_animated_quad(bounds, handle.slot(),
260    /// class)` call; the renderer samples per-slot state from its
261    /// uniform buffer each frame and the widget's paint() does not
262    /// re-run for animation ticks — only on layout changes. The
263    /// returned handle is stable for the widget-mount lifetime and
264    /// should be stashed on `self` to thread to `paint()`.
265    ///
266    /// For decorative motion that isn't a quad (scroll-offset tweens,
267    /// sidebar slide, toggle knob), keep using `ctx.animated_signal` +
268    /// `signal.animate_looping` — both paths coexist.
269    pub fn animated_quad(
270        &mut self,
271        kind: crate::animated_quad::AnimatedQuadKind,
272    ) -> crate::animated_quad::AnimatedQuadHandle {
273        let owner = self.self_id();
274        self.tree.register_animated_quad(owner, kind)
275    }
276
277    /// The per-frame delta-seconds signal. Observe it via
278    /// `ctx.effect(&ctx.frame_tick(), |delta| ...)` to run code once per
279    /// frame **the tree was explicitly asked to pump**. Merely observing
280    /// this signal does not keep the event loop awake — widgets must
281    /// call [`request_frame`](Self::request_frame) (typically from an
282    /// event handler or from inside the tick closure itself) to schedule
283    /// the next wake-up. This preserves Teksilo's draw-when-needed model.
284    pub fn frame_tick(&self) -> Signal<f32> {
285        self.tree.frame_tick()
286    }
287
288    /// Ask the tree to pump exactly one more frame. See
289    /// [`frame_tick`](Self::frame_tick) for the observer side.
290    pub fn request_frame(&self) {
291        self.tree.request_frame();
292    }
293
294    /// Request that the AccessKit tree be re-walked after this build pass.
295    /// Use when `build()` restructured its subtree in a way that changes the
296    /// accessibility tree (relayout alone no longer re-walks AT). `SceneView`
297    /// calls this each build, since it may have materialised or destroyed
298    /// scene widgets or applied a11y-only scene mutations.
299    pub fn request_accessibility_update(&self) {
300        self.tree.request_accessibility_update();
301    }
302
303    /// Speak `message` to the screen reader, politely.
304    ///
305    /// The build-time companion to
306    /// [`EventContext::announce`](crate::widget::EventContext::announce), for a
307    /// widget that discovers during `build()` that something needs saying — an
308    /// error surface appearing, a result count changing. Announcing from
309    /// `build()` announces once per *rebuild*, so guard it on a real change
310    /// rather than on the build itself.
311    pub fn announce(&mut self, message: impl Into<String>) {
312        self.tree.announce(message);
313    }
314
315    /// Speak `message` to the screen reader at the given urgency. See
316    /// [`EventContext::announce_with`](crate::widget::EventContext::announce_with).
317    pub fn announce_with(
318        &mut self,
319        message: impl Into<String>,
320        politeness: crate::announcer::Politeness,
321    ) {
322        self.tree.announce_with(message, politeness);
323    }
324
325    /// Clone the shared "frame requested" flag. Stash it on widget
326    /// state and call `.set(true)` from inside a frame-tick effect
327    /// closure to chain-request another frame without needing
328    /// mutable access to the tree. Used by widgets with continuous
329    /// frame needs (caret blink, drag auto-scroll, smooth
330    /// animations driven from a tick closure).
331    ///
332    /// **Prefer [`subscribe_frame_tick`](Self::subscribe_frame_tick)**
333    /// for visual-only continuous animations (Pulse, Cycle, …): the
334    /// scheduler-backed path automatically pauses the chain when the
335    /// owner widget is hidden, while this raw handle keeps the event
336    /// loop pumping at full frame rate regardless of visibility.
337    pub fn frame_request_handle(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
338        self.tree.frame_request_handle()
339    }
340
341    /// Subscribe the widget being built to the per-frame-effect
342    /// scheduler. The returned RAII guard removes the subscription on
343    /// drop — store it on `self` so its lifetime tracks the widget's.
344    ///
345    /// While at least one subscriber's owner is visible, the framework
346    /// auto-arms `frame_tick_requested` after every render. When all
347    /// subscribers are hidden (e.g. parked inside a non-selected
348    /// `Switcher` branch), no re-arm happens and the chain dies, so
349    /// the event loop sleeps. On a hidden→visible transition the
350    /// `visible_when` binding's relayout dirty triggers a repaint that
351    /// paints the subscriber, which the post-render arm then detects
352    /// and resumes the chain.
353    ///
354    /// Replaces the widget-managed `frame_request.set(true)` re-arm
355    /// pattern for visual-only continuous animations. The widget's
356    /// `frame_tick` effect closure no longer needs to call
357    /// `frame_request.set(true)` itself — the scheduler handles it.
358    pub fn subscribe_frame_tick(&self) -> crate::frame_tick_scheduler::FrameTickSubscription {
359        let sub = self.tree.subscribe_frame_tick(self.self_id());
360        // Bootstrap: ensure at least one frame runs after registration
361        // so the first paint happens. The post-render re-arm takes over
362        // from there. This is also the resume nudge for the case where
363        // a widget rebuilds (e.g. due to a state change) while still
364        // hidden — the parent's relayout dirty will trigger paint, and
365        // post-render arm will pick up the chain.
366        self.tree.request_frame();
367        sub
368    }
369
370    /// Like [`subscribe_frame_tick`](Self::subscribe_frame_tick), but the
371    /// widget only needs to wake **at most once per `interval`** while
372    /// visible. Same visibility gate and RAII guard; between wakes the
373    /// event loop sleeps to the interval deadline rather than rendering
374    /// identical 60 fps frames. Use when the widget's visible output
375    /// changes far less often than 60 Hz — e.g. `Cycle`'s once-per-period
376    /// index advance, or a seconds-granular clock.
377    pub fn subscribe_frame_tick_throttled(
378        &self,
379        interval: std::time::Duration,
380    ) -> crate::frame_tick_scheduler::FrameTickSubscription {
381        let sub = self
382            .tree
383            .subscribe_frame_tick_throttled(self.self_id(), interval);
384        // Bootstrap the first frame after registration (see
385        // `subscribe_frame_tick`).
386        self.tree.request_frame();
387        sub
388    }
389
390    /// Clone the shared wake-at deadline cell. Stash it on widget
391    /// state and set `Some(instant)` from a frame-tick effect to
392    /// schedule a one-shot deadline wake-up without keeping the event
393    /// loop in `Poll` mode. See `WidgetTree::wake_at_handle` for
394    /// the underlying mechanism.
395    pub fn wake_at_handle(&self) -> std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>> {
396        self.tree.wake_at_handle()
397    }
398
399    /// Register a scoped effect tied to this build cycle.
400    /// The effect fires whenever the signal changes. It is automatically
401    /// cleaned up on rebuild or widget destruction.
402    pub fn effect<T: Clone + 'static>(&mut self, signal: &Signal<T>, f: impl Fn(&T) + 'static) {
403        let handle = signal.observe(f);
404        self.effect_handles.push(handle);
405    }
406
407    /// Register a pre-existing observer handle for lifecycle management.
408    /// The handle will be dropped (and the observer removed) on rebuild
409    /// or widget destruction.
410    pub fn own_handle(&mut self, handle: ObserverHandle) {
411        self.effect_handles.push(handle);
412    }
413
414    /// Get the binding registry.
415    pub fn binding_registry(&self) -> &BindingRegistry {
416        self.tree.binding_registry()
417    }
418
419    /// Get the current theme.
420    pub fn theme(&self) -> &crate::styles::Theme {
421        self.tree.theme()
422    }
423
424    /// The active [`TargetDensity`] — the ladder `ctx.theme().input` was
425    /// projected onto.
426    ///
427    /// Read it in `build()` when a dimension must be chosen once per build
428    /// (a `MinSize` wrapper, a recipe's metrics). For the values themselves
429    /// prefer `ctx.theme().input` plus the
430    /// [`density`](crate::styles::density) helpers, which encode the floors.
431    ///
432    /// [`TargetDensity`]: teksilo_tokens::TargetDensity
433    pub fn density(&self) -> teksilo_tokens::TargetDensity {
434        self.tree.input_density()
435    }
436
437    /// Reactive handle on the current theme. Fires observers when
438    /// `tree.set_theme(...)` is called. Build implementations that want
439    /// theme-driven values to update without a rebuild should use this
440    /// instead of cloning tokens from `self.theme()` — for example,
441    /// `ctx.theme_signal().map(|t| t.colors.primary)` or combining with
442    /// interaction state via `zip(...)`.
443    pub fn theme_signal(&self) -> crate::signal::Signal<crate::styles::Theme> {
444        self.tree.theme_signal().clone()
445    }
446
447    /// Current combined text-scale factor (`user × OS`, `1.0` = 100 %). One-shot
448    /// read for build-time sizing; for a value that updates without a rebuild,
449    /// bind [`text_scale_signal`](Self::text_scale_signal) instead.
450    pub fn text_scale(&self) -> f32 {
451        self.tree.effective_text_scale()
452    }
453
454    /// Reactive handle on the combined text-scale factor. Fires when the user
455    /// scale, theme, or OS text-scale preference changes. Build implementations
456    /// that derive a build-time dimension from the scale (e.g. `Calendar`'s
457    /// fixed cell sizes) bind this — typically at `Rebuild` level so the change
458    /// recomputes the constants — since a scale change relayouts but does not
459    /// rebuild on its own.
460    pub fn text_scale_signal(&self) -> crate::signal::Signal<f32> {
461        self.tree.text_scale_signal()
462    }
463
464    /// Whether the host window is currently active (`focused AND not
465    /// occluded`). One-shot read for build-time use; for a value that reacts
466    /// to focus changes, bind [`window_active_signal`](Self::window_active_signal).
467    pub fn window_active(&self) -> bool {
468        self.tree.is_window_active()
469    }
470
471    /// Reactive handle on window-active state. Fires when the host window gains
472    /// or loses active status (`focused AND not occluded`). Build
473    /// implementations that show/hide appearance with window focus — caret
474    /// effects, the selection-colour swap in text fields, `DimWhenInactive` —
475    /// bind this, typically at `RepaintOnly` level (an active-state flip never
476    /// affects geometry). Starts `true`.
477    pub fn window_active_signal(&self) -> crate::signal::Signal<bool> {
478        self.tree.window_active_signal()
479    }
480
481    /// Reactive handle on the current locale. Fires observers when
482    /// `tree.set_locale(...)` is called.
483    pub fn locale_signal(&self) -> crate::signal::Signal<Option<String>> {
484        self.tree.locale_signal().clone()
485    }
486
487    /// The [`WindowState`](crate::window::WindowState) for the window
488    /// hosting this tree. `None` only for trees built outside of an
489    /// app (tests, headless scenarios). Use this to bind widgets to
490    /// window-level signals like `placement`, `size`, `focused`.
491    pub fn window(&self) -> Option<&crate::window::WindowState> {
492        self.tree.window_state()
493    }
494
495    /// Retrieve an application-scoped value of type `T` registered via
496    /// `TeksiloAppBuilder::app_state`. Returns `None` if no value of
497    /// that type was registered. The returned reference borrows from
498    /// the framework for the duration of the build pass.
499    pub fn app_state<T: 'static>(&self) -> Option<&T> {
500        self.tree.app_context().app_state::<T>()
501    }
502
503    /// Borrow the [`AppEventPoster`](crate::AppEventPoster) installed by the
504    /// framework, if any. Mirrors [`EventContext::poster`](crate::widget::EventContext::poster).
505    /// Used by integrations that wire a platform callback (e.g. a native menu
506    /// item) to post a typed payload back to the UI loop. Returns `None` for
507    /// trees built outside an app (tests / headless).
508    pub fn poster(&self) -> Option<&std::sync::Arc<dyn crate::AppEventPoster>> {
509        self.tree.app_context().poster()
510    }
511
512    /// Bind a widget's visibility to a boolean prop or compatibility state binding.
513    pub fn visible_when(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
514        self.tree.visible_when(id, state);
515    }
516
517    /// Enqueue a one-shot action to run shortly after this build, with a real
518    /// [`EventContext`](crate::widget::EventContext) — the only place a widget
519    /// can read the OS parent window handle (`ctx.parent_window_handle()`),
520    /// `app_state`, and `poster` *together*, after it is mounted under its
521    /// window. The action runs at most once per enqueue (the app loop drains
522    /// the queue each iteration); a widget that rebuilds must guard against
523    /// enqueuing twice. Built for widgets owning a native OS resource that
524    /// needs a window handle to initialise (a `WebView`'s engine subview);
525    /// ordinary widgets never need it.
526    pub fn run_after_mount(&mut self, f: impl FnOnce(&mut crate::widget::EventContext) + 'static) {
527        self.tree.queue_mount_action(Box::new(f));
528    }
529
530    /// Observe a node's framework activation as a `Signal<bool>` — `true`
531    /// while active, `false` while parked dormant by a `Switcher` /
532    /// `visible_when` gate. Initialised to the node's current state and
533    /// updated only on an actual Active↔Dormant transition.
534    ///
535    /// Ordinary widgets never need this: dormant subtrees are simply not
536    /// painted, so they vanish for free. It exists for the one case where
537    /// "not painted" ≠ "hidden" — a widget owning a native OS resource
538    /// that renders *outside* the wgpu pass (a `WebView`'s engine subview).
539    /// Such a widget does `ctx.effect(&ctx.activation_signal(id), move |a|
540    /// handle.set_visible(*a))` to hide/show the native surface in lockstep.
541    pub fn activation_signal(&mut self, id: WidgetId) -> Signal<bool> {
542        self.tree.activation_signal(id)
543    }
544
545    /// The framework press signal for the widget being built — `true` while it
546    /// holds a pointer press whose visual should show.
547    ///
548    /// The router owns the state, so this is correct for the four cases a
549    /// widget's own `PointerDown`/`PointerUp` bookkeeping gets wrong: a press
550    /// that slides off its target goes `false` and comes back `true` on
551    /// re-entry (WCAG 2.2 SC 2.5.2), a press a pan claimant or an ancestor drag
552    /// wins goes `false` with no release to hang it on, a cancel clears it, and
553    /// a press inside a scrollable withholds the visual for the profile's
554    /// `press_feedback_delay` so a finger that turns out to be scrolling never
555    /// flashes a highlight. The rules are written out in `docs/touch-and-pen.md`
556    /// §7.
557    ///
558    /// Bind it at [`BindingLevel::RepaintOnly`](crate::binding::BindingLevel) —
559    /// a press changes colour, never size.
560    pub fn pressed_signal(&mut self) -> Signal<bool> {
561        let id = self.self_id();
562        self.tree.pressed_signal(id)
563    }
564
565    /// Reactive `Signal<bool>` that is `true` while the *focus scope* containing
566    /// the widget being built — its nearest focusable ancestor, e.g. the
567    /// enclosing `ListView` / `TreeView` — holds keyboard focus. Items outside
568    /// any focusable scope read a constant `true`.
569    ///
570    /// Drives **focus-aware selection**: a selected row renders with the active
571    /// `Selected` chrome while its view has focus and the muted
572    /// `SelectedInactive` chrome when focus moves elsewhere — the standard
573    /// desktop affordance (Qt `SH_ItemView_...`, macOS inactive selection) that
574    /// shows where the keyboard is. The scope is resolved at build time but the
575    /// signal stays live across focus changes.
576    pub fn view_focus_active(&mut self) -> Signal<bool> {
577        // Prefer the scope a containing data view explicitly established for its
578        // rows (deterministic, parenting-independent); else resolve by walking
579        // to the nearest focusable ancestor.
580        if let Some(scope) = self.tree.current_view_focus() {
581            return scope;
582        }
583        let id = self.self_id();
584        self.tree.view_focus_active_for(id)
585    }
586
587    /// Mark the widget being built as a **focus scope** for the rows/items it
588    /// builds next: any descendant's [`view_focus_active`](Self::view_focus_active)
589    /// (and `StandardItem`'s focus-aware selection / focus ring) reads *this*
590    /// widget's keyboard focus. A data view calls this around its row loop, then
591    /// [`end_view_focus`](Self::end_view_focus). Deterministic — unaffected by
592    /// arena parenting, which may not be wired while docked/virtualized rows build.
593    pub fn begin_view_focus(&mut self) -> Signal<bool> {
594        let id = self.self_id();
595        self.tree.begin_view_focus(id)
596    }
597
598    /// Like [`begin_view_focus`](Self::begin_view_focus) but keys the scope on
599    /// an explicit `node_id` rather than the widget being built. A view whose
600    /// rows are built by a **separate body-pane widget** (TableView /
601    /// TreeTableView / GridView) passes its own focusable root id so descendant
602    /// items resolve the *root's* keyboard focus — not the pane's, which is a
603    /// child of the root and so never holds focus itself.
604    pub fn begin_view_focus_for(&mut self, node_id: WidgetId) -> Signal<bool> {
605        self.tree.begin_view_focus(node_id)
606    }
607
608    /// End the focus scope opened by [`begin_view_focus`](Self::begin_view_focus).
609    pub fn end_view_focus(&mut self) {
610        self.tree.end_view_focus();
611    }
612
613    /// Input-modality "focus-visible" signal — `true` after keyboard input,
614    /// `false` after pointer input (the standard `:focus-visible` rule). Pair
615    /// with [`view_focus_active`](Self::view_focus_active) to draw a focus
616    /// ring only during keyboard navigation, not on mouse clicks.
617    pub fn focus_visible(&self) -> Signal<bool> {
618        self.tree.focus_visible_signal()
619    }
620
621    /// Bind an opacity multiplier (0..1) to a widget. The render walker
622    /// emits `SetOpacity(value)` before painting the widget's subtree
623    /// and `RestoreOpacity` afterwards, so the multiplier composes
624    /// correctly with ancestor opacity scopes. Bound at `RepaintOnly`:
625    /// opacity changes never trigger relayout. Used by the `Fade`
626    /// wrapper to animate a child between hidden and fully visible.
627    pub fn set_opacity(&mut self, id: WidgetId, opacity: impl Into<crate::signal::Prop<f32>>) {
628        self.tree.set_opacity(id, opacity);
629    }
630
631    /// Bind a 2D affine transform to a widget. The render walker emits
632    /// `PushTransform(value)` before painting the widget's subtree and
633    /// `PopTransform` afterwards, so the transform composes onto the
634    /// renderer's stack with any ancestor transform scopes and with
635    /// the widget's own canvas-level transforms. Bound at `RepaintOnly`:
636    /// visual-only transforms never trigger relayout. Used by `Scale`
637    /// and `Rotate`; reflow-driving wrappers (e.g. `Scale::reflow(true)`)
638    /// must additionally bind their driver signal to themselves at
639    /// `Relayout` to make layout track the value.
640    pub fn set_transform(
641        &mut self,
642        id: WidgetId,
643        transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
644    ) {
645        self.tree.set_transform(id, transform);
646    }
647
648    /// Bind a 2D affine **content** transform to a widget — the transform
649    /// positions the widget's content within its fixed parent-space viewport
650    /// (its bounds) rather than transforming the widget itself. Renders the
651    /// same `PushTransform` / `PopTransform` scope as
652    /// [`set_transform`](Self::set_transform), but hit-testing treats the
653    /// bounds as a fixed viewport so the whole visible area stays interactive
654    /// at any pan / zoom. Used by `SceneView` for its pan/zoom view transform.
655    pub fn set_content_transform(
656        &mut self,
657        id: WidgetId,
658        transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
659    ) {
660        self.tree.set_content_transform(id, transform);
661    }
662
663    /// Bind a Gaussian-equivalent blur radius to a widget. The render
664    /// walker emits `BeginBlurredSubtree { bounds, radius }` before
665    /// painting the widget's subtree and `EndBlurredSubtree` afterwards;
666    /// the renderer redirects drawing into an intermediate texture, runs
667    /// a dual-Kawase blur chain at the requested radius, and composites
668    /// the blurred result back into the parent pass at the widget's
669    /// bounds. Bound at `RepaintOnly`: blur radius changes never trigger
670    /// relayout. Sub-perceptual radii (< 0.5) skip the Begin/End pair
671    /// entirely so animated enable/disable patterns have zero per-frame
672    /// cost when fully off. Used by the `Blur` wrapper.
673    pub fn set_blur(&mut self, id: WidgetId, radius: impl Into<crate::signal::Prop<f32>>) {
674        self.tree.set_blur(id, radius);
675    }
676
677    /// Bind a widget's enabled state to a boolean prop or compatibility state binding.
678    pub fn enabled_when(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
679        self.tree.enabled_when(id, state);
680    }
681
682    /// Reactive view of "is this widget effectively enabled?" — the AND
683    /// of the widget's own `enabled_state` and every ancestor's. The
684    /// arena's [`crate::arena::WidgetArena::is_enabled`] is the
685    /// non-reactive equivalent; this method gives composite widgets a
686    /// `Signal<bool>` they can `.map(...)` / `.zip(...)` against to
687    /// derive other reactive UI state (cursor, custom paint, helper
688    /// signals).
689    ///
690    /// Leaves like `IconWidget` / `TextWidget` / `RectWidget` do NOT
691    /// need this — they get the bool directly via
692    /// [`crate::widget::PaintContext::effective_enabled`] at paint time.
693    /// This method is for composites that need the value at build time
694    /// or want to chain signals.
695    ///
696    /// The signal is node-resident and framework-refreshed (install-or-reuse,
697    /// like [`Self::activation_signal`]), so it tracks ancestors correctly even
698    /// though a widget's parent is not yet wired while its own `build()` runs.
699    /// It is a *mutable* signal, so — unlike the old derived implementation —
700    /// it can be passed to [`Self::effect`].
701    ///
702    /// Returns a signal reading `true` for any node whose entire ancestor
703    /// chain (including itself) has no `enabled_state` bound.
704    pub fn effective_enabled_signal(&mut self, id: WidgetId) -> Signal<bool> {
705        self.tree.effective_enabled_signal(id)
706    }
707
708    /// Bind a widget's Tab-key participation to a boolean prop or
709    /// compatibility state binding. When false, the widget is removed
710    /// from Tab / Shift+Tab traversal but remains reachable via
711    /// `request_focus` and arrow-key navigation. Implements the ARIA
712    /// roving-tabindex pattern (HTML `tabindex="-1"` semantics).
713    pub fn set_tab_stop(&mut self, id: WidgetId, state: impl Into<crate::signal::Prop<bool>>) {
714        self.tree.set_tab_stop(id, state);
715    }
716
717    /// Publish what a data view's `Space` should do when the row containing
718    /// `id` holds the keyboard cursor.
719    ///
720    /// A `ListView` / `TreeView` row is not itself focusable and the view keeps
721    /// the row subtree out of the Tab order — a listbox is one Tab stop — so a
722    /// checkbox inside a row has no keyboard route of its own.
723    /// `StandardListItem` / `StandardTreeItem` publish one for the checkbox
724    /// they embed; a hand-written row delegate calls this to get the same
725    /// behaviour. Without it `Space` keeps meaning "toggle the selection".
726    pub fn set_keyboard_toggle(&mut self, id: WidgetId, f: std::rc::Rc<dyn Fn()>) {
727        self.tree.set_keyboard_toggle(id, f);
728    }
729
730    /// Declare the widget being built as a **traversal-scope boundary** for
731    /// Tab / Shift+Tab navigation. Descendants' `tab_index` values become
732    /// scoped to this node — they never collide with sibling scopes — and the
733    /// `policy` controls what happens at the scope's ends:
734    ///
735    /// - [`TraversalScopePolicy::Continue`](crate::focus::TraversalScopePolicy::Continue)
736    ///   — Tab flows out into the enclosing scope's next member (groups
737    ///   numbering only).
738    /// - [`TraversalScopePolicy::Cycle`](crate::focus::TraversalScopePolicy::Cycle)
739    ///   — Tab wraps within the scope, never exits. For **modal dialogs only**:
740    ///   a popover or menu is non-modal, and the framework closes one the
741    ///   keyboard walks out of rather than containing focus in it. Trapping such
742    ///   an overlay stops that dismissal from ever firing.
743    ///
744    /// This node is automatically excluded from being a Tab stop itself.
745    /// Prefer the `FocusScope` wrapper widget in `teksilo-widgets` over
746    /// calling this directly.
747    pub fn set_traversal_scope(&mut self, policy: crate::focus::TraversalScopePolicy) {
748        let id = self.self_id();
749        self.tree.set_traversal_scope(id, policy);
750    }
751
752    /// Attach a tooltip to a widget.
753    pub fn attach_tooltip(
754        &mut self,
755        anchor_id: WidgetId,
756        content_id: WidgetId,
757        delay: std::time::Duration,
758    ) {
759        self.tree.attach_tooltip(anchor_id, content_id, delay);
760        self.claim_tooltip_description(anchor_id);
761    }
762
763    /// Attach a tooltip with an explicit
764    /// [`TooltipPlacement`](crate::overlay::TooltipPlacement) — use `Side`
765    /// for anchors stacked vertically (menu items, a vertical tab strip,
766    /// list/tree rows) so the tooltip opens beside the anchor instead of
767    /// covering the next sibling.
768    pub fn attach_tooltip_with_placement(
769        &mut self,
770        anchor_id: WidgetId,
771        content_id: WidgetId,
772        delay: std::time::Duration,
773        placement: crate::overlay::TooltipPlacement,
774    ) {
775        self.tree
776            .attach_tooltip_with_placement(anchor_id, content_id, delay, placement);
777        self.claim_tooltip_description(anchor_id);
778    }
779
780    /// Attach a tooltip that auto-promotes to sticky after a dwell
781    /// timer. Non-None `sticky_after` enables the sticky-on-dwell UX:
782    /// once the tooltip has been shown for `sticky_after`, the tree
783    /// flags the entry sticky and swaps the overlay's dismiss
784    /// behavior to `EscapeOrClickOutside`.
785    pub fn attach_tooltip_with_sticky(
786        &mut self,
787        anchor_id: WidgetId,
788        content_id: WidgetId,
789        delay: std::time::Duration,
790        sticky_after: Option<std::time::Duration>,
791    ) {
792        self.tree
793            .attach_tooltip_with_sticky(anchor_id, content_id, delay, sticky_after);
794        self.claim_tooltip_description(anchor_id);
795    }
796
797    /// Variant of [`attach_tooltip_with_sticky`](Self::attach_tooltip_with_sticky)
798    /// that takes a shared `Rc<Cell<Option<Instant>>>` "sink" the
799    /// tree updates whenever the tooltip is shown / dismissed. The
800    /// tooltip widget reads from this sink to compute its own dwell
801    /// progress reliably, without needing a paint-gap heuristic.
802    pub fn attach_tooltip_with_sticky_sink(
803        &mut self,
804        anchor_id: WidgetId,
805        content_id: WidgetId,
806        delay: std::time::Duration,
807        sticky_after: Option<std::time::Duration>,
808        shown_at_sink: std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>,
809    ) {
810        self.tree.attach_tooltip_with_sticky_sink(
811            anchor_id,
812            content_id,
813            delay,
814            sticky_after,
815            shown_at_sink,
816        );
817        self.claim_tooltip_description(anchor_id);
818    }
819
820    /// Variant of [`attach_tooltip_with_sticky_sink`](Self::attach_tooltip_with_sticky_sink)
821    /// that also carries a [`TooltipPlacement`](crate::overlay::TooltipPlacement).
822    /// The full-featured path used by rich + composite tooltips that want
823    /// `Side` placement in a vertical context (menu items, list/tree rows).
824    pub fn attach_tooltip_with_sticky_sink_placement(
825        &mut self,
826        anchor_id: WidgetId,
827        content_id: WidgetId,
828        delay: std::time::Duration,
829        sticky_after: Option<std::time::Duration>,
830        shown_at_sink: std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>,
831        placement: crate::overlay::TooltipPlacement,
832    ) {
833        self.tree.attach_tooltip_with_sticky_sink_placement(
834            anchor_id,
835            content_id,
836            delay,
837            sticky_after,
838            shown_at_sink,
839            placement,
840        );
841        self.claim_tooltip_description(anchor_id);
842    }
843
844    /// Name this widget as the one the tooltip just attached describes.
845    ///
846    /// Every `attach_tooltip*` wrapper ends with this, so a composing control
847    /// gets it for free: `Button`, `Toggle` and the two dozen widgets shaped
848    /// like them hang the overlay off an inner chrome node -- the thing with
849    /// the right bounds to open against -- while their role, their name and
850    /// their focusability sit on their own outer node, which is the node an
851    /// assistive technology lands on and therefore the node a description has
852    /// to be on.
853    ///
854    /// A widget anchoring its tooltip on itself claims itself, which is what
855    /// it already had. A widget attaching *many* tooltips in one build -- a
856    /// list body pane, one per visible row -- claims itself for every one of
857    /// them, which is a claim that cannot be granted; the accessibility walk
858    /// is where that is noticed, because it is the only place the whole set
859    /// is visible at once.
860    fn claim_tooltip_description(&mut self, anchor_id: WidgetId) {
861        let owner = self.self_id();
862        self.tree.set_tooltip_description_owner(anchor_id, owner);
863    }
864
865    /// Promote a shown tooltip to "sticky": removes its auto-dismiss
866    /// on pointer-leave and swaps the overlay's dismiss behavior to
867    /// `EscapeOrClickOutside`. Used by rich tooltips that implement a
868    /// dwell timer.
869    pub fn promote_tooltip_to_sticky(&mut self, content_id: WidgetId) {
870        self.tree.promote_tooltip_to_sticky(content_id);
871    }
872
873    /// Set a widget as dormant (inactive). Used to pre-create overlay content
874    /// that will be activated later via `EventContext::activate()`.
875    pub fn set_dormant(&mut self, id: WidgetId) {
876        self.tree.set_dormant(id);
877    }
878
879    /// Destroy a widget and its entire subtree, removing them from the
880    /// arena and dropping any per-widget subscription / effect handles.
881    ///
882    /// Use this to clean up dormant subtrees that the current widget
883    /// created during a prior build and that live outside its regular
884    /// arena children — e.g., a pre-built popup panel inserted via
885    /// `ctx.add(..)` + `ctx.set_dormant(..)` that becomes stale after a
886    /// rebuild. Regular arena children of the composite (i.e. widgets
887    /// whose ids are returned from `build`) are destroyed automatically
888    /// by the framework's rebuild path and do not need this call.
889    ///
890    /// If an overlay currently references `id` as its content, the
891    /// overlay is dismissed first so the manager does not retain a
892    /// stale content reference.
893    pub fn destroy_subtree(&mut self, id: WidgetId) {
894        let overlay_id = self.tree.overlay_manager().find_by_content(id);
895        if let Some(overlay_id) = overlay_id {
896            self.tree.dismiss_overlay(overlay_id);
897        }
898        self.tree.destroy_subtree(id);
899    }
900
901    /// Apply a `HandlerSet` to the composite widget being built (self).
902    /// This transfers attached event handlers, focusable flag, cursor, etc.
903    /// to the widget's arena node, replacing `event()` and `is_focusable()` overrides.
904    pub fn apply_self_handlers(&mut self, handler_set: crate::widget_builder::HandlerSet) {
905        let id = self.self_id();
906        self.tree.apply_self_handler_set(id, handler_set);
907    }
908
909    /// Move keyboard focus to `id`. Mirrors
910    /// `EventContext::request_focus` for use during `build()` — e.g.
911    /// when a composing widget pre-builds an editor and needs focus to
912    /// land on it as soon as the subtree is wired in.
913    pub fn focus(&mut self, id: WidgetId) {
914        self.tree.focus(id);
915    }
916
917    /// Find the first focusable widget within the subtree rooted at
918    /// `root` in depth-first order. Returns `None` when the subtree has
919    /// no focusable descendant or `root` is not in the arena.
920    pub fn first_focusable_descendant(&self, root: WidgetId) -> Option<WidgetId> {
921        self.tree.first_focusable_descendant(root)
922    }
923
924    /// Move keyboard focus **into** the subtree rooted at `id`: its first
925    /// focusable descendant in tab order, or `id` itself when it is the only
926    /// focusable thing there. Returns whether focus ended up inside `id`.
927    ///
928    /// The build-time twin of
929    /// [`EventContext::request_focus_into`](crate::widget::EventContext::request_focus_into),
930    /// and safe here for the same reason [`focus`](Self::focus) is: `add` builds
931    /// a child's whole subtree synchronously, so by the time a composing widget
932    /// holds a child's id the focusable descendants of that child already exist.
933    ///
934    /// **Idempotent, and that is the point.** `build` runs again on every
935    /// rebuild, so a bare `focus` here would drag focus back into this subtree
936    /// every time the owner rebuilt for an unrelated reason — a table body pane
937    /// rebuilds on selection, on filtering and on scroll. This is a no-op while
938    /// focus already sits inside `id`, so it expresses "focus belongs in here"
939    /// rather than "focus here now".
940    ///
941    /// A subtree with nothing focusable leaves focus exactly where it was: an
942    /// empty region never traps it.
943    ///
944    /// ⚠ **Ancestor-chain side effects do not run**, and that is a property of
945    /// focusing from `build` at all, not of this method — [`focus`](Self::focus)
946    /// has it too. A node added during `build` is not parented until the build
947    /// that produced it *returns*, so at this moment `id`'s chain stops at
948    /// whatever the caller has already inserted: `focus_within` signals on
949    /// enclosing nodes never flip, and `scroll_focused_into_view` finds no
950    /// scroll container to reveal the target in. Everything **below** `id` is
951    /// linked (children are parented as each is inserted), so the walk that
952    /// picks the focusable descendant, and every later key dispatch — which
953    /// happens after the pass, on a whole tree — are unaffected.
954    ///
955    /// Reach for [`EventContext::request_focus_into`](crate::widget::EventContext::request_focus_into)
956    /// where the difference matters: it is queued and drained after dispatch,
957    /// against a complete tree.
958    pub fn focus_into(&mut self, id: WidgetId) -> bool {
959        if let Some(focused) = self.tree.focused()
960            && (focused == id || self.tree.is_descendant_of(focused, id))
961        {
962            return true;
963        }
964        match self.tree.first_focusable_descendant(id) {
965            Some(target) => {
966                self.tree.focus(target);
967                true
968            }
969            None => false,
970        }
971    }
972
973    // --- Actions & shortcuts ---
974
975    /// Attach an [`Action`](crate::action::Action) to the widget being
976    /// built. Actions are consulted during intent dispatch as the
977    /// framework walks source-widget → root; the first matching,
978    /// enabled action wins (subject to the `IntentResponse` returned
979    /// by its handler).
980    ///
981    /// Actions are cleared on rebuild, mirroring event handlers.
982    pub fn register_action(&mut self, action: crate::action::Action) {
983        let id = self.self_id();
984        self.tree.push_action(id, action);
985    }
986
987    /// Declare that the widget being built **edits text**.
988    ///
989    /// Every text widget should call this. It is what lets an application take
990    /// a text chord — `Ctrl+Z`, `Ctrl+C` — for itself without silently breaking
991    /// the widget it took it from: the host asks
992    /// [`focused_text_surface`](crate::widget_tree::WidgetTree::focused_text_surface)
993    /// and either drives this surface or steps aside so the widget keeps its own
994    /// keys. See [`crate::text_surface`] for the whole argument.
995    ///
996    /// Owned by the registering widget and torn down on its rebuild or destroy,
997    /// like [`register_action_global`](Self::register_action_global). Calling it
998    /// twice from one widget re-points rather than duplicating, so a rebuild
999    /// that hands over a fresh handle is correct.
1000    pub fn register_text_surface(
1001        &mut self,
1002        surface: std::rc::Rc<dyn crate::text_surface::TextSurface>,
1003    ) {
1004        let id = self.self_id();
1005        self.tree.push_text_surface(id, surface);
1006    }
1007
1008    /// A cloneable view of this tree's registered text surfaces.
1009    ///
1010    /// Take it once, during `build`, and hold it: a view-model refreshed from a
1011    /// frame tick has no `&WidgetTree` to consult, and that is exactly when it
1012    /// needs to know whether the caret is in a text widget.
1013    pub fn text_surfaces(&self) -> crate::text_surface::TextSurfaces {
1014        self.tree.text_surfaces()
1015    }
1016
1017    /// Register a **window-global** [`Action`](crate::action::Action), owned by
1018    /// the widget being built. Unlike [`register_action`](Self::register_action)
1019    /// — which only fires when this widget is on the intent's source→root walk —
1020    /// a global action is consulted as a dispatch *fallback*, so it is reachable
1021    /// no matter where the intent originated: a menu-bar dropdown (which renders
1022    /// in an overlay, not under the registering widget), deep content, or a
1023    /// global shortcut anchored at the root when nothing is focused.
1024    ///
1025    /// This is the action-side counterpart to
1026    /// [`register_shortcut_global`](Self::register_shortcut_global): use it for
1027    /// app-wide commands (`app.save`, `view.toggle_sidebar`) whose handler lives
1028    /// at the app root but whose triggers (menu, toolbar, shortcut) are scattered
1029    /// across the tree and chrome. Ownership applies: the action is torn down
1030    /// when this widget rebuilds or is destroyed.
1031    pub fn register_action_global(&mut self, action: crate::action::Action) {
1032        let id = self.self_id();
1033        self.tree.push_global_action(id, action);
1034    }
1035
1036    /// Register a [`Shortcut`](crate::shortcut::Shortcut) in the
1037    /// tree's registry, owned by the widget being built.
1038    ///
1039    /// If the shortcut builder left `scope` at the default
1040    /// ([`ShortcutScope::Global`](crate::shortcut::ShortcutScope::Global)),
1041    /// this method rewrites it to `Scoped(self_id)` so the shortcut
1042    /// only fires when focus is inside the registering widget's
1043    /// subtree — the ergonomic default for widget-declared shortcuts.
1044    /// Callers that want an explicit global shortcut should use
1045    /// [`BuildContext::register_shortcut_global`] instead; callers
1046    /// that want to scope to a specific child should set
1047    /// `.scope_to(child_id)` on the builder themselves.
1048    ///
1049    /// Ownership: the shortcut is removed from the registry when the
1050    /// widget is destroyed or rebuilt. User overrides survive across
1051    /// rebuilds (graveyard semantics).
1052    pub fn register_shortcut(&mut self, mut shortcut: crate::shortcut::Shortcut) {
1053        let id = self.self_id();
1054        if shortcut.scope == crate::shortcut::ShortcutScope::Global {
1055            shortcut.scope = crate::shortcut::ShortcutScope::Scoped(id);
1056        }
1057        self.tree
1058            .shortcut_registry_mut()
1059            .register_owned(shortcut, id);
1060    }
1061
1062    /// Register a [`Shortcut`](crate::shortcut::Shortcut) with
1063    /// explicit global scope, owned by the widget being built. Unlike
1064    /// [`BuildContext::register_shortcut`], this does not rewrite the
1065    /// scope — the shortcut fires regardless of focus position.
1066    ///
1067    /// Ownership still applies: the shortcut is torn down when this
1068    /// widget goes away.
1069    pub fn register_shortcut_global(&mut self, mut shortcut: crate::shortcut::Shortcut) {
1070        let id = self.self_id();
1071        shortcut.scope = crate::shortcut::ShortcutScope::Global;
1072        self.tree
1073            .shortcut_registry_mut()
1074            .register_owned(shortcut, id);
1075    }
1076
1077    /// Pre-declare shortcuts on behalf of a not-yet-mounted child
1078    /// (e.g. a `Switcher` walking its `Pending` slots' static
1079    /// declarations before they're inserted). Each shortcut is owned
1080    /// by the *calling* widget and its declared scope is preserved
1081    /// as-is — unlike [`register_shortcut`](Self::register_shortcut),
1082    /// no rewrite from `Global` to `Scoped(self)` happens, because
1083    /// the child intended its own scope.
1084    ///
1085    /// When the child is eventually mounted, the framework's
1086    /// insert-time walk of `Widget::declare_shortcuts` re-registers
1087    /// the same ids owned by the *child*; the registry's idempotent
1088    /// upsert moves ownership cleanly. If the child never mounts, the
1089    /// pre-declared entries stay alive (owned by the parent) so
1090    /// settings UIs still see them, and they get torn down when the
1091    /// parent goes away.
1092    pub fn register_pending_shortcuts(
1093        &mut self,
1094        shortcuts: impl IntoIterator<Item = crate::shortcut::Shortcut>,
1095    ) {
1096        let id = self.self_id();
1097        let registry = self.tree.shortcut_registry_mut();
1098        for shortcut in shortcuts {
1099            registry.register_owned(shortcut, id);
1100        }
1101    }
1102
1103    /// Read-through access to the tree's shortcut registry. Consumers
1104    /// (menus, tooltips) look up the effective keystroke for a given
1105    /// id here, and observe
1106    /// [`ShortcutRegistry::version`](crate::shortcut::ShortcutRegistry::version)
1107    /// to refresh when the user rebinds.
1108    pub fn shortcut_registry(&self) -> &crate::shortcut::ShortcutRegistry {
1109        self.tree.shortcut_registry()
1110    }
1111
1112    /// Effective view of a shortcut by id, merged with any user
1113    /// override. Returns `None` when no default has been registered
1114    /// for `id`. Typical caller pattern: call from `paint()` so
1115    /// late-registered shortcuts are still picked up without a
1116    /// dedicated build-phase query.
1117    pub fn effective_shortcut<'b>(
1118        &'b self,
1119        id: &str,
1120    ) -> Option<crate::shortcut::EffectiveShortcut<'b>> {
1121        self.shortcut_registry().effective(id)
1122    }
1123
1124    /// Convenience accessor for the reactive version signal. Widgets
1125    /// that render shortcut-derived state (menu labels, tooltips)
1126    /// observe this so the UI refreshes when the user rebinds or a
1127    /// new shortcut is registered.
1128    pub fn shortcut_version(&self) -> &Signal<u64> {
1129        self.shortcut_registry().version()
1130    }
1131
1132    /// A reactive, **per-id** handle to a shortcut's effective primary
1133    /// keystroke — the granular alternative to [`Self::shortcut_version`].
1134    /// Bind this to render one shortcut's accelerator as a *leaf* value
1135    /// (a menu item's trailing label, a tooltip) that refreshes in place
1136    /// when the user rebinds *that* id, without observing — and rebuilding
1137    /// on — every unrelated registry mutation. The signal is created on
1138    /// first request, seeded with the current value, and kept live by the
1139    /// registry across register / unregister / rebind of that id.
1140    pub fn effective_shortcut_signal(
1141        &mut self,
1142        id: &'static str,
1143    ) -> Signal<Option<crate::shortcut::KeyStroke>> {
1144        self.tree
1145            .shortcut_registry_mut()
1146            .effective_primary_signal(id)
1147    }
1148
1149    /// Apply a `HandlerSet` to a child widget created during this build.
1150    /// Use this to attach event handlers to children without wrapping them
1151    /// in `WidgetWithHandlers`.
1152    pub fn apply_handlers(
1153        &mut self,
1154        id: crate::widget_id::WidgetId,
1155        handler_set: crate::widget_builder::HandlerSet,
1156    ) {
1157        // A composing parent attaches handlers to a child — from the
1158        // child's perspective these are external and must survive the
1159        // child's own rebuilds.
1160        self.tree.apply_external_handler_set(id, handler_set);
1161    }
1162
1163    /// Wire an accessibility `labelled_by` relation from an already-mounted
1164    /// child (`id`) to its label (`label_id`), so assistive tech announces the
1165    /// field by its visible label (WCAG 3.3.2 / EN 301 549 11.5.2.7). Unlike
1166    /// the `.access_labelled_by(..)` builder method, this operates *after* the
1167    /// child is mounted (so a container like `FormLayout` can pair a label and
1168    /// a boxed field once both ids are resolved) and preserves any
1169    /// accessibility overrides the child already carries.
1170    pub fn access_labelled_by(
1171        &mut self,
1172        id: crate::widget_id::WidgetId,
1173        label_id: crate::widget_id::WidgetId,
1174    ) {
1175        self.tree.push_access_labelled_by(id, label_id);
1176    }
1177
1178    /// The widget that paints `id`'s title, when it has one.
1179    ///
1180    /// A container names itself from its visible title by pointing at that
1181    /// node — see [`Widget::accessible_title_node`](crate::widget::Widget::accessible_title_node).
1182    /// `build()` runs eagerly on insertion, so the answer is already there
1183    /// the moment the content is mounted.
1184    pub fn accessible_title_node(
1185        &self,
1186        id: crate::widget_id::WidgetId,
1187    ) -> Option<crate::widget_id::WidgetId> {
1188        self.tree.widget_accessible_title_node(id)
1189    }
1190
1191    /// Wire an accessibility `described_by` relation from an already-mounted
1192    /// child (`id`) to a description/error node (`target_id`) — the
1193    /// post-mount, override-preserving counterpart of the
1194    /// `.access_described_by(..)` builder method (WCAG 3.3.1).
1195    pub fn access_described_by(
1196        &mut self,
1197        id: crate::widget_id::WidgetId,
1198        target_id: crate::widget_id::WidgetId,
1199    ) {
1200        self.tree.push_access_described_by(id, target_id);
1201    }
1202
1203    /// The id this subscription should carry: the one the previous build used at this
1204    /// same position, or a fresh one.
1205    ///
1206    /// ⚠ **Position is the whole matching rule**, and it is deliberate. The alternative
1207    /// — matching on the origin — cannot be written here: `origin` reaches the adapter
1208    /// as `Box<dyn Any>`, with no `Eq` and no `Hash` to compare it by, and requiring
1209    /// either would change every `EventSource` in existence. Position is stable for the
1210    /// shape widgets actually have, where `build()` runs the same subscribe calls in the
1211    /// same order every time.
1212    ///
1213    /// What a widget that subscribes *conditionally* gets: if the origin at position N
1214    /// differs between two builds, an event still in flight from the old origin is
1215    /// delivered to the new build's callback rather than being dropped. That is safe by
1216    /// construction rather than by luck — an app registers exactly one `EventSource`, so
1217    /// every subscription in the tree shares one origin type and one event type, and the
1218    /// payload downcast cannot mismatch. The callback receives the whole event and can
1219    /// read its origin, which is what `Origin::LongOperation(..)` handlers already do.
1220    fn next_subscription_id(
1221        &self,
1222        app_context: &crate::event_source::TreeAppContext,
1223    ) -> SubscriptionId {
1224        // `subscription_handles` is pushed to once per subscribe call and starts empty
1225        // for each build, so its length *is* this call's position within the build.
1226        self.reusable_sub_ids
1227            .get(self.subscription_handles.len())
1228            .copied()
1229            .unwrap_or_else(|| app_context.allocate_subscription_id())
1230    }
1231
1232    /// Subscribe to events from the registered application event source.
1233    /// The callback runs on the UI thread when the source publishes an
1234    /// event with a matching origin.
1235    ///
1236    /// The subscription is scoped to the current widget's lifetime: when
1237    /// the widget is rebuilt or destroyed, the framework drops the source
1238    /// handle (unregistering from the source) and removes the UI-side
1239    /// callback.
1240    ///
1241    /// It is scoped to the window it was registered from as well. A closing window's
1242    /// tree is dropped wholesale, with no per-widget destroy pass, so teksilo-app calls
1243    /// [`TreeAppContext::purge_subscriptions_for_window`](crate::event_source::TreeAppContext::purge_subscriptions_for_window)
1244    /// to drop the callbacks that window installed. A registration from a windowless
1245    /// tree (headless / tests) records no window, and only the per-widget path above
1246    /// removes such a callback.
1247    ///
1248    /// # Panics
1249    ///
1250    /// Panics if no event source has been registered on the
1251    /// `TeksiloAppBuilder`. In debug builds, also asserts that the `Origin`
1252    /// and `Event` types match the registered source.
1253    pub fn subscribe_event<O, E, F>(&mut self, origin: O, callback: F)
1254    where
1255        O: 'static,
1256        E: 'static,
1257        F: Fn(&E) + 'static,
1258    {
1259        use std::any::{Any, TypeId};
1260        use std::rc::Rc;
1261        use std::sync::Arc;
1262
1263        // Recorded so `TreeAppContext::purge_subscriptions_for_window` can drop this
1264        // entry when the window closes. A closing window's tree is dropped wholesale,
1265        // with no per-widget destroy pass, so nothing else ever reaches the entry and
1266        // the callback (plus everything it captured) would stay live for the rest of
1267        // the process. `None` from a windowless tree (headless / tests), which no
1268        // window purge touches. Mirrors `subscribe_event_with_ctx` below.
1269        let window_id = self.window().map(|w| w.id());
1270
1271        let app_context = self.tree.app_context.clone();
1272
1273        let adapter = app_context.event_source.as_ref().expect(
1274            "BuildContext::subscribe_event called but no event source was registered \
1275             on TeksiloAppBuilder. Call .event_source(source) on the builder first.",
1276        );
1277
1278        debug_assert_eq!(
1279            adapter.origin_type,
1280            TypeId::of::<O>(),
1281            "subscribe_event origin type mismatch: source uses {}, subscribe call used {}",
1282            adapter.origin_type_name,
1283            std::any::type_name::<O>(),
1284        );
1285        debug_assert_eq!(
1286            adapter.event_type,
1287            TypeId::of::<E>(),
1288            "subscribe_event event type mismatch: source uses {}, subscribe call used {}",
1289            adapter.event_type_name,
1290            std::any::type_name::<E>(),
1291        );
1292
1293        let sub_id = self.next_subscription_id(&app_context);
1294
1295        // The UI-side callback that runs after an event posted from the
1296        // source thread is delivered back to the UI thread. It downcasts
1297        // the type-erased payload back to `&E` and invokes the user's `F`.
1298        let stored_callback: Rc<dyn Fn(&dyn Any)> = Rc::new(move |event_any| {
1299            let event = event_any
1300                .downcast_ref::<E>()
1301                .expect("subscription event downcast failed — framework bug");
1302            callback(event);
1303        });
1304        app_context
1305            .subscription_callbacks
1306            .borrow_mut()
1307            .insert(sub_id, (window_id, stored_callback));
1308
1309        // Build the wrapper that the source will invoke from its
1310        // publisher thread. It carries only the sub_id (Copy) and an
1311        // Arc-clone of the poster (Send + Sync), boxes the typed event
1312        // as Any+Send, and posts an AppEvent::SubscriptionEvent through
1313        // the proxy. Tests that run without a registered poster post
1314        // events into a test queue and dispatch them back into the tree
1315        // via `tree.app_context().dispatch_subscription_event`.
1316        let poster = app_context
1317            .poster
1318            .as_ref()
1319            .expect(
1320                "BuildContext::subscribe_event called but no AppEventPoster \
1321                 is installed on the tree. teksilo-app installs one when an \
1322                 event source is registered on the builder; tests must \
1323                 supply a TestPoster via TreeAppContext::with_source_and_poster.",
1324            )
1325            .clone();
1326        let wrapper: Arc<dyn Fn(Box<dyn Any + Send>) + Send + Sync> =
1327            Arc::new(move |erased_event| {
1328                poster.post_subscription_event(sub_id, erased_event);
1329            });
1330
1331        let handle = (adapter.subscribe_fn)(Box::new(origin), wrapper);
1332        self.subscription_handles.push((sub_id, handle));
1333    }
1334
1335    /// Like [`subscribe_event`](Self::subscribe_event), but the UI-side
1336    /// callback additionally receives a fresh
1337    /// [`EventContext`](crate::widget::EventContext) bound to this widget's
1338    /// window. That lets it react to a backend event *imperatively* — update /
1339    /// replace / dismiss a toast, present a modal, `send_intent`, navigate —
1340    /// none of which a plain (context-free) `subscribe_event` callback can do
1341    /// (it can only poke `Signal`s).
1342    ///
1343    /// This is the supported bridge for **long-operation progress**: a Qleany
1344    /// `Origin::LongOperation(Progress | Completed | Cancelled | Failed)` event
1345    /// crosses from the operation's background thread to the UI thread and the
1346    /// callback drives an evolving progress toast (percentage in the body, a
1347    /// Cancel action, a success/error replacement on completion) — see the
1348    /// `toast_demo` example.
1349    ///
1350    /// The event is delivered on the UI thread through the same
1351    /// `AppEvent::SubscriptionEvent` path as `subscribe_event`; teksilo-app
1352    /// mints the `EventContext` from this widget's window tree just before the
1353    /// call (mirroring `teksilo-async`'s `spawn_local_with` completion path).
1354    /// The subscription is torn down with the widget, exactly like
1355    /// `subscribe_event`.
1356    ///
1357    /// The `<O, E>` type match against the registered event source is a
1358    /// `debug_assert` (as in [`subscribe_event`](Self::subscribe_event)); a
1359    /// mismatched call site in a release build is not caught here but panics
1360    /// later at the payload downcast.
1361    ///
1362    /// Registering from a windowless tree (headless / tests) is allowed but
1363    /// records `None` for the window — the app-side router then has no tree to
1364    /// mint an `EventContext` from and cannot deliver it, so such a subscription
1365    /// never fires in a running app. Ordinary application widgets always have a
1366    /// window; headless code that wants to observe events should use
1367    /// [`subscribe_event`](Self::subscribe_event) and drive `Signal`s instead.
1368    pub fn subscribe_event_with_ctx<O, E, F>(&mut self, origin: O, callback: F)
1369    where
1370        O: 'static,
1371        E: 'static,
1372        F: Fn(&E, &mut crate::widget::EventContext) + 'static,
1373    {
1374        use std::any::{Any, TypeId};
1375        use std::rc::Rc;
1376        use std::sync::Arc;
1377
1378        let window_id = self.window().map(|w| w.id());
1379
1380        let app_context = self.tree.app_context.clone();
1381
1382        let adapter = app_context.event_source.as_ref().expect(
1383            "BuildContext::subscribe_event_with_ctx called but no event source was registered \
1384             on TeksiloAppBuilder. Call .event_source(source) on the builder first.",
1385        );
1386
1387        debug_assert_eq!(
1388            adapter.origin_type,
1389            TypeId::of::<O>(),
1390            "subscribe_event_with_ctx origin type mismatch: source uses {}, subscribe call used {}",
1391            adapter.origin_type_name,
1392            std::any::type_name::<O>(),
1393        );
1394        debug_assert_eq!(
1395            adapter.event_type,
1396            TypeId::of::<E>(),
1397            "subscribe_event_with_ctx event type mismatch: source uses {}, subscribe call used {}",
1398            adapter.event_type_name,
1399            std::any::type_name::<E>(),
1400        );
1401
1402        // Re-used across this widget's rebuilds exactly as in `subscribe_event` — the
1403        // context-bearing path keeps its callbacks in a second map but crosses the very
1404        // same queue, so it loses in-flight events the very same way. See
1405        // [`Self::next_subscription_id`].
1406        let sub_id = self.next_subscription_id(&app_context);
1407
1408        // The UI-side callback, invoked after an event posted from the source
1409        // thread is delivered back to the UI thread and a fresh `EventContext`
1410        // has been minted. Downcasts the type-erased payload back to `&E` and
1411        // forwards it plus the context to the user's `F`. Stored behind `Rc` so
1412        // dispatch can drop the map borrow before invoking it (re-entrancy).
1413        let stored_callback: Rc<dyn Fn(&dyn Any, &mut crate::widget::EventContext)> =
1414            Rc::new(move |event_any, ctx| {
1415                let event = event_any
1416                    .downcast_ref::<E>()
1417                    .expect("subscription event downcast failed — framework bug");
1418                callback(event, ctx);
1419            });
1420        app_context
1421            .subscription_ctx_callbacks
1422            .borrow_mut()
1423            .insert(sub_id, (window_id, stored_callback));
1424
1425        // Same publisher-thread wrapper as `subscribe_event`: carry only the
1426        // sub_id (Copy) + an Arc-clone of the poster, box the typed event, and
1427        // post an `AppEvent::SubscriptionEvent`. The dispatch side (teksilo-app)
1428        // routes context-bearing sub_ids through the fresh-`EventContext` path.
1429        let poster = app_context
1430            .poster
1431            .as_ref()
1432            .expect(
1433                "BuildContext::subscribe_event_with_ctx called but no AppEventPoster \
1434                 is installed on the tree. teksilo-app installs one when an \
1435                 event source is registered on the builder.",
1436            )
1437            .clone();
1438        let wrapper: Arc<dyn Fn(Box<dyn Any + Send>) + Send + Sync> =
1439            Arc::new(move |erased_event| {
1440                poster.post_subscription_event(sub_id, erased_event);
1441            });
1442
1443        let handle = (adapter.subscribe_fn)(Box::new(origin), wrapper);
1444        self.subscription_handles.push((sub_id, handle));
1445    }
1446}
1447
1448#[cfg(test)]
1449mod effect_tests {
1450    use super::*;
1451    use crate::widget::{LayoutContext, Widget};
1452    use crate::widget_id::WidgetId;
1453    use crate::widget_tree::WidgetTree;
1454    use teksilo_canvas::SizeProposal;
1455
1456    /// A leaf widget that registers an effect on one signal to mirror its
1457    /// value into another. Produces no children.
1458    #[derive(Debug)]
1459    struct LeafWithEffect {
1460        source: Signal<i32>,
1461        mirror: Signal<i32>,
1462    }
1463
1464    impl Widget for LeafWithEffect {
1465        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1466            let mirror = self.mirror.clone();
1467            ctx.effect(&self.source, move |v| mirror.set(*v));
1468            Vec::new()
1469        }
1470
1471        fn layout_response(
1472            &self,
1473            proposal: SizeProposal,
1474            _ctx: &LayoutContext,
1475        ) -> crate::widget::LayoutResponse {
1476            proposal.resolve(0.0, 0.0).into()
1477        }
1478    }
1479
1480    /// A widget that observes the per-frame tick signal and accumulates the
1481    /// deltas it receives into a shared counter, so a test can verify both
1482    /// that the tick fires at all and that the delta value is non-zero.
1483    #[derive(Debug)]
1484    struct FrameTickListener {
1485        ticks: Signal<u32>,
1486        last_delta: Signal<f32>,
1487    }
1488
1489    impl Widget for FrameTickListener {
1490        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1491            let ticks = self.ticks.clone();
1492            let last_delta = self.last_delta.clone();
1493            let tick = ctx.frame_tick();
1494            ctx.effect(&tick, move |delta| {
1495                ticks.set(ticks.get() + 1);
1496                last_delta.set(*delta);
1497            });
1498            Vec::new()
1499        }
1500
1501        fn layout_response(
1502            &self,
1503            proposal: SizeProposal,
1504            _ctx: &LayoutContext,
1505        ) -> crate::widget::LayoutResponse {
1506            proposal.resolve(0.0, 0.0).into()
1507        }
1508    }
1509
1510    #[test]
1511    fn frame_tick_stays_silent_until_explicit_request() {
1512        // The draw-when-needed contract: a widget that merely observes
1513        // frame_tick must NOT keep the tree awake. Only an explicit
1514        // `request_frame()` call pumps a tick.
1515        let mut tree = WidgetTree::new();
1516        let ticks = Signal::new(0_u32);
1517        let last_delta = Signal::new(-1.0_f32);
1518        tree.add(FrameTickListener {
1519            ticks: ticks.clone(),
1520            last_delta: last_delta.clone(),
1521        });
1522
1523        // Flush the initial layout-dirty flag from widget insertion.
1524        tree.layout(teksilo_canvas::SizeProposal::exact(400.0, 300.0));
1525        assert!(
1526            !tree.frame_requested(),
1527            "observing frame_tick does not set the request flag"
1528        );
1529
1530        tree.tick_animations(std::time::Duration::from_millis(16));
1531        assert_eq!(
1532            ticks.get(),
1533            0,
1534            "an un-requested tick_animations must not fire frame_tick observers"
1535        );
1536        assert_eq!(last_delta.get(), -1.0);
1537    }
1538
1539    #[test]
1540    fn frame_tick_fires_once_per_request() {
1541        let mut tree = WidgetTree::new();
1542        let ticks = Signal::new(0_u32);
1543        let last_delta = Signal::new(-1.0_f32);
1544        let id = tree.add(FrameTickListener {
1545            ticks: ticks.clone(),
1546            last_delta: last_delta.clone(),
1547        });
1548
1549        // Flush initial layout-dirty flag so assertions reflect only
1550        // the frame-tick contract.
1551        tree.layout(teksilo_canvas::SizeProposal::exact(400.0, 300.0));
1552
1553        tree.request_frame();
1554        assert!(tree.needs_redraw(), "explicit request marks the tree dirty");
1555        assert!(tree.frame_requested());
1556
1557        tree.tick_animations(std::time::Duration::from_millis(16));
1558        assert_eq!(ticks.get(), 1);
1559        assert!((last_delta.get() - 0.016).abs() < 0.001);
1560        assert!(
1561            !tree.frame_requested(),
1562            "request flag must be cleared after the tick fired"
1563        );
1564
1565        // Second request fires exactly one more tick.
1566        tree.request_frame();
1567        tree.tick_animations(std::time::Duration::from_millis(16));
1568        assert_eq!(ticks.get(), 2);
1569
1570        // Without a request, further ticks silently advance time.
1571        tree.tick_animations(std::time::Duration::from_millis(16));
1572        assert_eq!(ticks.get(), 2);
1573
1574        tree.destroy_subtree(id);
1575        tree.request_frame();
1576        tree.tick_animations(std::time::Duration::from_millis(16));
1577        assert_eq!(
1578            ticks.get(),
1579            2,
1580            "destroyed widget's observer must not resurrect"
1581        );
1582    }
1583
1584    #[test]
1585    fn frame_tick_delta_clamped_against_huge_pauses() {
1586        let mut tree = WidgetTree::new();
1587        let ticks = Signal::new(0_u32);
1588        let last_delta = Signal::new(-1.0_f32);
1589        tree.add(FrameTickListener {
1590            ticks: ticks.clone(),
1591            last_delta: last_delta.clone(),
1592        });
1593
1594        tree.request_frame();
1595        tree.tick_animations(std::time::Duration::from_secs(5));
1596        assert_eq!(ticks.get(), 1);
1597        assert!(
1598            (last_delta.get() - 0.1).abs() < 1e-4,
1599            "frame delta must clamp at 0.1s even after a multi-second pause"
1600        );
1601    }
1602
1603    #[test]
1604    fn leaf_widget_effect_fires_and_is_cleaned_up_on_destroy() {
1605        // Regression guard: before the insert_widget / add_child fix,
1606        // effect_handles for a leaf widget (Vec::new() from build()) were
1607        // dropped the moment BuildContext went out of scope, silently
1608        // unregistering the observer. After the fix, the handle is
1609        // transferred to the arena node and the effect fires on signal
1610        // changes until the widget is destroyed.
1611        let mut tree = WidgetTree::new();
1612        let source = Signal::new(0_i32);
1613        let mirror = Signal::new(0_i32);
1614
1615        let id = tree.add(LeafWithEffect {
1616            source: source.clone(),
1617            mirror: mirror.clone(),
1618        });
1619
1620        // The effect should be live after insertion.
1621        source.set(42);
1622        assert_eq!(
1623            mirror.get(),
1624            42,
1625            "leaf widget effect must survive build() and fire on signal change"
1626        );
1627
1628        source.set(7);
1629        assert_eq!(mirror.get(), 7);
1630
1631        // Destroying the widget drops its effect_handles, which in turn
1632        // drops each ObserverHandle and unregisters the observer.
1633        tree.destroy_subtree(id);
1634        source.set(100);
1635        assert_eq!(
1636            mirror.get(),
1637            7,
1638            "effect must be unregistered after widget destruction"
1639        );
1640    }
1641}
1642
1643#[cfg(test)]
1644mod focus_into_tests {
1645    use super::*;
1646    use crate::widget::{LayoutContext, Widget};
1647    use crate::widget_builder::HandlerSet;
1648    use crate::widget_id::WidgetId;
1649    use crate::widget_tree::WidgetTree;
1650    use teksilo_canvas::SizeProposal;
1651
1652    /// A leaf that is focusable when asked, so the walk has something real to
1653    /// find — or nothing at all.
1654    #[derive(Debug)]
1655    struct Leaf {
1656        focusable: bool,
1657    }
1658
1659    impl Widget for Leaf {
1660        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1661            if self.focusable {
1662                ctx.apply_self_handlers(HandlerSet::new().focusable(true));
1663            }
1664            Vec::new()
1665        }
1666        fn layout_response(
1667            &self,
1668            proposal: SizeProposal,
1669            _ctx: &LayoutContext,
1670        ) -> crate::widget::LayoutResponse {
1671            proposal.resolve(10.0, 10.0).into()
1672        }
1673    }
1674
1675    /// Holds `focusable` focusable leaves and publishes their ids.
1676    #[derive(Debug)]
1677    struct Panel {
1678        focusable: usize,
1679        leaves: Signal<Vec<WidgetId>>,
1680    }
1681
1682    impl Widget for Panel {
1683        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1684            let kids: Vec<WidgetId> = (0..2)
1685                .map(|i| {
1686                    ctx.add(Leaf {
1687                        focusable: i < self.focusable,
1688                    })
1689                })
1690                .collect();
1691            self.leaves.set(kids.clone());
1692            kids
1693        }
1694        fn layout_response(
1695            &self,
1696            proposal: SizeProposal,
1697            _ctx: &LayoutContext,
1698        ) -> crate::widget::LayoutResponse {
1699            proposal.resolve(10.0, 10.0).into()
1700        }
1701    }
1702
1703    /// Calls `focus_into(panel)` on every one of *its own* builds, which is how
1704    /// a composing widget uses it. Rebuilt on demand through `tick` — and
1705    /// rebuilding it leaves the panel and its leaves alive, which is the whole
1706    /// point: that is the situation the idempotence has to survive.
1707    #[derive(Debug)]
1708    struct Driver {
1709        panel: Signal<Option<WidgetId>>,
1710        tick: Signal<u64>,
1711        moved: Signal<bool>,
1712    }
1713
1714    impl Widget for Driver {
1715        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1716            self.tick.bind_to(
1717                ctx.self_id(),
1718                ctx.binding_registry(),
1719                crate::binding::BindingLevel::Rebuild,
1720            );
1721            if let Some(panel) = self.panel.get() {
1722                let moved = ctx.focus_into(panel);
1723                self.moved.set(moved);
1724            }
1725            Vec::new()
1726        }
1727        fn layout_response(
1728            &self,
1729            proposal: SizeProposal,
1730            _ctx: &LayoutContext,
1731        ) -> crate::widget::LayoutResponse {
1732            proposal.resolve(0.0, 0.0).into()
1733        }
1734    }
1735
1736    struct Probe {
1737        tree: WidgetTree,
1738        leaves: Vec<WidgetId>,
1739        tick: Signal<u64>,
1740        moved: Signal<bool>,
1741    }
1742
1743    impl Probe {
1744        fn rebuild_driver(&mut self) {
1745            self.tick.set(self.tick.get() + 1);
1746            self.tree.layout(SizeProposal::exact(100.0, 100.0));
1747        }
1748    }
1749
1750    /// A panel with `focusable` focusable leaves, plus a sibling driver that
1751    /// calls `focus_into` on it from `build`. `outside` is focusable and lives
1752    /// outside the panel, so "focus did not move" is observable.
1753    fn probe(focusable: usize) -> (Probe, WidgetId) {
1754        let leaves = Signal::new(Vec::new());
1755        let panel_id = Signal::new(None);
1756        let tick = Signal::new(0_u64);
1757        let moved = Signal::new(false);
1758
1759        let mut tree = WidgetTree::new();
1760        let outside = tree.add(Leaf { focusable: true });
1761        let panel = tree.add(Panel {
1762            focusable,
1763            leaves: leaves.clone(),
1764        });
1765        panel_id.set(Some(panel));
1766        tree.add(Driver {
1767            panel: panel_id,
1768            tick: tick.clone(),
1769            moved: moved.clone(),
1770        });
1771        tree.layout(SizeProposal::exact(100.0, 100.0));
1772        (
1773            Probe {
1774                tree,
1775                leaves: leaves.get(),
1776                tick,
1777                moved,
1778            },
1779            outside,
1780        )
1781    }
1782
1783    /// It lands on the first focusable descendant, not on the container.
1784    #[test]
1785    fn focus_into_lands_on_the_first_focusable_descendant() {
1786        let (p, _) = probe(2);
1787        assert!(p.moved.get());
1788        assert_eq!(p.tree.focused(), Some(p.leaves[0]));
1789    }
1790
1791    /// **It is a no-op while focus is already inside** — the property that lets
1792    /// it be called from `build`, which re-runs on every rebuild. A bare
1793    /// `focus` on the first focusable descendant would drag focus back to the
1794    /// first field every time the caller rebuilt for an unrelated reason, which
1795    /// mid-edit is the caret jumping to the start of the line.
1796    #[test]
1797    fn focus_into_leaves_focus_alone_when_it_is_already_inside() {
1798        let (mut p, _) = probe(2);
1799        p.tree.focus(p.leaves[1]);
1800        p.rebuild_driver();
1801        assert!(p.moved.get(), "focus is inside, so the answer is still yes");
1802        assert_eq!(
1803            p.tree.focused(),
1804            Some(p.leaves[1]),
1805            "focus was dragged back to the first focusable child"
1806        );
1807    }
1808
1809    /// A subtree with nothing focusable leaves focus exactly where it was: an
1810    /// empty region never traps it, and the caller is told so.
1811    #[test]
1812    fn focus_into_an_unfocusable_subtree_moves_nothing() {
1813        let (mut p, outside) = probe(0);
1814        p.tree.focus(outside);
1815        p.rebuild_driver();
1816        assert!(!p.moved.get());
1817        assert_eq!(p.tree.focused(), Some(outside));
1818    }
1819}