Skip to main content

teksilo_core/widget_tree/
test_api.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6impl WidgetTree {
7    /// The content id of the tooltip anchored at `widget` or anywhere inside
8    /// it.
9    ///
10    /// The attach helpers keep the content id to themselves, so a test that
11    /// needs to drive a tooltip's own surface (promote it, focus into it) has
12    /// no other way to name it. Matching the whole subtree, not just the id,
13    /// is what makes this work for composing controls: `Button` keeps focus on
14    /// its outer node but attaches its tooltip to an inner body root.
15    pub fn tooltip_content_within(&self, widget: WidgetId) -> Option<WidgetId> {
16        self.tooltips
17            .iter()
18            .find(|e| self.is_descendant_of(e.anchor_id, widget))
19            .map(|e| e.content_id)
20    }
21
22    /// Whether that tooltip has been promoted.
23    ///
24    /// Promotion is the line between an informational tip and a panel the user
25    /// asked for: it decides the AT role, the dismiss behaviour, and whether
26    /// the surface takes a Tab stop.
27    pub fn tooltip_is_sticky_within(&self, widget: WidgetId) -> bool {
28        self.tooltips
29            .iter()
30            .any(|e| self.is_descendant_of(e.anchor_id, widget) && e.is_sticky)
31    }
32
33    /// Simulate a click at the center of a widget.
34    pub fn click(&mut self, id: WidgetId) {
35        self.synthesise_tap(id);
36    }
37
38    /// Synthesise a primary-button tap at the center of `id`'s
39    /// resolved bounds. The OS hands the click off to the widget tree
40    /// even though the click never went through the normal hit-test
41    /// path. Used by the Windows custom-title-bar backend when
42    /// `WM_NCHITTEST` reported `HTMINBUTTON`/`HTMAXBUTTON`/`HTCLOSE`
43    /// for an area covering a `ControlButton` — the OS treated the
44    /// area as non-client and `WM_LBUTTONDOWN`/`UP` never fired in
45    /// widget land, so we re-issue a synthetic primary-button down
46    /// + up on the right widget.
47    ///
48    /// Equivalent semantics to [`Self::click`]; named differently so
49    /// production call sites read clearly.
50    ///
51    /// The tap runs on a standalone dispatch, so a handler it reaches
52    /// cannot use the multi-window API. Call
53    /// [`synthesise_tap_with_ops`](Self::synthesise_tap_with_ops) from
54    /// anywhere that already holds a real
55    /// [`WindowOps`](crate::window::WindowOps) sink.
56    pub fn synthesise_tap(&mut self, id: WidgetId) {
57        let mut noop = crate::window::NoopWindowOps;
58        self.synthesise_tap_with_ops(id, &mut noop);
59    }
60
61    /// [`synthesise_tap`](Self::synthesise_tap), dispatched over the
62    /// caller's app-level [`WindowOps`](crate::window::WindowOps) sink.
63    ///
64    /// A synthetic tap is a *nested* dispatch, and everything the tapped
65    /// widget does happens inside it — including the intent it sends and
66    /// the action that intent resolves to. Dispatching it standalone
67    /// therefore hands that action a context with no window sink:
68    /// `ctx.open_window` panics, and `find_window` / `focus_window` /
69    /// `close_window_by_id` silently do nothing. That is how keyboard
70    /// activation in a menu (Enter, Space, a mnemonic, type-ahead — all
71    /// four route through `EventContext::synthetic_click`) lost the
72    /// multi-window API that the same row reached fine by mouse.
73    pub fn synthesise_tap_with_ops(
74        &mut self,
75        id: WidgetId,
76        ops: &mut dyn crate::window::WindowOps,
77    ) {
78        let center = self.arena.bounds(id).center();
79        self.dispatch_event_with_ops(
80            WidgetEvent::PointerDown {
81                position: center,
82                button: PointerButton::Primary,
83                modifiers: Modifiers::NONE,
84            },
85            &mut *ops,
86        );
87        self.dispatch_event_with_ops(
88            WidgetEvent::PointerUp {
89                position: center,
90                button: PointerButton::Primary,
91                modifiers: Modifiers::NONE,
92            },
93            &mut *ops,
94        );
95    }
96
97    /// Simulate pointer movement to a position.
98    pub fn pointer_move(&mut self, position: Point) {
99        self.dispatch_event(WidgetEvent::PointerMove { position });
100    }
101
102    /// Simulate a key press (down + up), carrying the text the platform
103    /// attaches to the key ([`Key::to_text`]).
104    ///
105    /// That text is not decoration: Escape arrives as U+001B, and a widget
106    /// that inspects `text` behaves differently with it than without. This
107    /// helper used to send `text: None` for every key, so a whole class of
108    /// bug was invisible to every test in the workspace — a field that
109    /// swallowed Escape passed the suite while failing in the user's hands.
110    pub fn press_key(&mut self, key: Key, modifiers: Modifiers) {
111        self.dispatch_event(WidgetEvent::KeyDown {
112            key,
113            modifiers,
114            text: key.to_text().map(str::to_string),
115        });
116        self.dispatch_event(WidgetEvent::KeyUp { key, modifiers });
117    }
118
119    /// Simulate typing text into the focused widget.
120    pub fn type_text(&mut self, _widget: WidgetId, text: &str) {
121        for ch in text.chars() {
122            self.dispatch_event(WidgetEvent::KeyDown {
123                key: Key::Character(ch),
124                modifiers: Modifiers::NONE,
125                text: Some(ch.to_string()),
126            });
127        }
128    }
129
130    /// Simulate a pointer down at a specific position with a specific button.
131    pub fn pointer_down_button(&mut self, position: Point, button: PointerButton) {
132        self.dispatch_event(WidgetEvent::PointerDown {
133            position,
134            button,
135            modifiers: Modifiers::NONE,
136        });
137    }
138
139    /// Simulate a pointer up at a specific position with a specific button.
140    pub fn pointer_up_button(&mut self, position: Point, button: PointerButton) {
141        self.dispatch_event(WidgetEvent::PointerUp {
142            position,
143            button,
144            modifiers: Modifiers::NONE,
145        });
146    }
147
148    /// Simulate a drag from one position to another.
149    pub fn drag(&mut self, from: Point, to: Point) {
150        self.dispatch_event(WidgetEvent::PointerDown {
151            position: from,
152            button: PointerButton::Primary,
153            modifiers: Modifiers::NONE,
154        });
155        self.dispatch_event(WidgetEvent::PointerMove { position: to });
156        self.dispatch_event(WidgetEvent::PointerUp {
157            position: to,
158            button: PointerButton::Primary,
159            modifiers: Modifiers::NONE,
160        });
161    }
162
163    /// The draggable ancestors armed by the current pointer press — the
164    /// observable state of the cross-widget tap-vs-drag disambiguation (see
165    /// `arm_drag_observers`).
166    ///
167    /// Empty when the press landed inside a
168    /// [`gesture_dead_zone`](crate::arena::WidgetNode::gesture_dead_zone), or when
169    /// the pressed widget carries its own drag (the innermost drag owns the
170    /// gesture). Exposed so an **app** built on teksilo can assert the same thing
171    /// this crate's own `gesture_dead_zone_blocks_ancestor_drag_arming` asserts —
172    /// that a press on an interactive control inside a draggable container cannot
173    /// start the container's drag. Read-only; test support.
174    pub fn armed_drag_observers(&self) -> &[WidgetId] {
175        &self.drag_observers
176    }
177
178    /// Get bounds of a child by index.
179    pub fn child_bounds(&self, parent: WidgetId, index: usize) -> Rect {
180        let children = self.children(parent);
181        self.bounds(children[index])
182    }
183
184    /// Get a child widget ID by index.
185    pub fn child_widget(&self, parent: WidgetId, index: usize) -> WidgetId {
186        self.children(parent)[index]
187    }
188
189    /// Advance the simulated clock by the given duration.
190    /// Triggers time-dependent behavior such as long-press gesture recognition
191    /// and tooltip timers. Enables deterministic testing without real delays.
192    pub fn advance_time(&mut self, duration: std::time::Duration) {
193        self.sim_clock += duration;
194        // Mirror the new sim_clock onto the overlay manager so any
195        // dismiss triggered by the process_* steps below stamps its
196        // sim-time start in lockstep with real time.
197        self.overlay_manager.set_sim_clock(self.sim_clock);
198        self.process_tooltips();
199        self.process_delayed_overlays();
200        self.process_pointer_leave_overlays();
201        self.process_auto_dismiss_overlays();
202        self.process_overlay_fade_dismissals_sim();
203    }
204
205    /// Get the current simulated clock value.
206    pub fn simulated_now(&self) -> std::time::Instant {
207        self.sim_clock
208    }
209
210    /// Total number of live tooltip attachments, dead ones included.
211    ///
212    /// Distinct from `pending_tooltip_count`, which only counts entries with a
213    /// running dwell. This is the raw table size — the number that must stay
214    /// flat across rebuilds, since `attach_tooltip*` is called from `build()`
215    /// and the table is scanned on every pointer move, every layout pass and
216    /// once per widget in the accessibility walk.
217    pub fn tooltip_entry_count(&self) -> usize {
218        self.tooltips.len()
219    }
220
221    /// Every widget the arena still holds — active, dormant and orphaned alike.
222    ///
223    /// The number a leak test must assert on. `active_widget_count` walks the
224    /// tree from its roots and so cannot see the failure mode that matters
225    /// here: a node kept alive in the arena with nothing pointing at it. A
226    /// parentless orphan (tooltip content is `ctx.add`ed, hence parentless by
227    /// construction) is invisible to every other count in this file, and to the
228    /// accessibility tree, while still paying for itself in the arena's slotmap
229    /// forever.
230    /// Every node inside `root` (inclusive) that Tab traversal would stop on:
231    /// focusable, and not suppressed by a `tab_stop` flag on itself or any
232    /// ancestor.
233    ///
234    /// Pressing Tab and watching focus cannot answer this for a view that
235    /// claims the key for its own navigation — `TableView` moves a cell cursor
236    /// on Tab, so focus never moves and the traversal graph underneath stays
237    /// invisible. A data view should expose exactly one stop however many rows
238    /// are realized; more than one means a control inside a row has leaked
239    /// into the Tab order, where its presence would track the scroll position.
240    pub fn tab_stops_within(&self, root: WidgetId) -> Vec<WidgetId> {
241        let mut out = Vec::new();
242        self.collect_tab_stops_within(root, &mut out);
243        out
244    }
245
246    fn collect_tab_stops_within(&self, id: WidgetId, out: &mut Vec<WidgetId>) {
247        let Some(node) = self.arena.get(id) else {
248            return;
249        };
250        if self.is_node_focusable(node) && self.tab_stop_effective(id) {
251            out.push(id);
252        }
253        for &child in self.arena.children(id) {
254            self.collect_tab_stops_within(child, out);
255        }
256    }
257
258    pub fn widget_count(&self) -> usize {
259        self.arena.len()
260    }
261
262    /// Tear down a widget and everything it owns — its subtree, its tooltip,
263    /// and the parentless content it built with
264    /// [`add_detached`](crate::build_context::BuildContext::add_detached).
265    ///
266    /// The application-facing door is `BuildContext::destroy_subtree`; this is
267    /// the same call for tests that hold the tree directly.
268    pub fn destroy_subtree_for_testing(&mut self, id: WidgetId) {
269        self.destroy_subtree(id);
270    }
271
272    /// Mark a widget as needing repaint.
273    pub fn mark_needs_paint(&mut self, id: WidgetId) {
274        self.arena.mark_needs_paint(id);
275    }
276
277    /// Set a widget subtree as dormant.
278    pub fn set_dormant(&mut self, id: WidgetId) {
279        self.arena.set_dormant(id);
280        self.arena.mark_ancestors_need_layout(id);
281        self.cached_frame = None;
282        self.a11y_dirty = true;
283    }
284
285    /// Activate a dormant widget subtree.
286    pub fn activate(&mut self, id: WidgetId) {
287        self.arena.activate(id);
288        self.arena.mark_ancestors_need_layout(id);
289        self.cached_frame = None;
290        self.a11y_dirty = true;
291    }
292
293    /// Invalidate all per-widget paint caches (paint AND post-paint) and
294    /// the assembled frame cache. Forces every widget to repaint on the
295    /// next `render()` call. Used by the glyph-atlas eviction recovery:
296    /// after an eviction, any retained frame may hold quads whose atlas
297    /// UVs now point at recycled slots.
298    pub fn invalidate_all_paints(&mut self) {
299        for id in self.arena.active_ids() {
300            if let Some(node) = self.arena.get_mut(id) {
301                node.dirty.needs_paint = true;
302                node.cached_paint = None;
303                node.cached_post_paint = None;
304            }
305        }
306        self.cached_frame = None;
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::signal::Signal;
314    use crate::test_widgets::{FillWidget, InsetWidget};
315
316    #[test]
317    fn child_bounds_helper() {
318        let mut tree = WidgetTree::new();
319        let child = tree.add(FillWidget::new());
320        let parent = tree.add(InsetWidget::new(5.0).set_child(child));
321        tree.layout(SizeProposal::exact(100.0, 50.0));
322        let child_bounds = tree.child_bounds(parent, 0);
323        assert_eq!(child_bounds.x, 5.0);
324    }
325
326    #[test]
327    fn signal_get_set_and_derived() {
328        let text = Signal::new(String::new());
329        let is_empty = text.map(|value| value.is_empty());
330        assert!(is_empty.get());
331        text.set("hello".to_string());
332        assert!(!is_empty.get());
333    }
334
335    #[test]
336    fn advance_time_updates_simulated_clock() {
337        let mut tree = WidgetTree::new();
338        let start = tree.simulated_now();
339
340        tree.advance_time(std::time::Duration::from_millis(500));
341        let end = tree.simulated_now();
342
343        assert_eq!(
344            end.duration_since(start),
345            std::time::Duration::from_millis(500)
346        );
347    }
348
349    #[test]
350    fn animate_to_interpolates_over_time() {
351        let mut tree = WidgetTree::new();
352        let owner = tree.add(FillWidget::new());
353        let signal = Signal::<f32>::new_animated(0.0);
354        tree.register_animated_signal(&signal, owner);
355
356        signal.animate_to(
357            100.0,
358            std::time::Duration::from_millis(200),
359            teksilo_tokens::Easing::Linear,
360        );
361
362        tree.tick_animations(std::time::Duration::from_millis(100));
363        assert!(
364            (signal.get() - 50.0).abs() < 2.0,
365            "at 50%: {}",
366            signal.get()
367        );
368
369        tree.tick_animations(std::time::Duration::from_millis(100));
370        assert!(
371            (signal.get() - 100.0).abs() < 0.1,
372            "at 100%: {}",
373            signal.get()
374        );
375
376        assert!(!tree.has_active_animations());
377    }
378
379    #[test]
380    fn animate_to_with_easing() {
381        let mut tree = WidgetTree::new();
382        let owner = tree.add(FillWidget::new());
383        let signal = Signal::<f32>::new_animated(0.0);
384        tree.register_animated_signal(&signal, owner);
385
386        signal.animate_to(
387            100.0,
388            std::time::Duration::from_millis(200),
389            teksilo_tokens::Easing::EaseIn,
390        );
391
392        tree.tick_animations(std::time::Duration::from_millis(100));
393        assert!(
394            (signal.get() - 25.0).abs() < 2.0,
395            "ease-in at 50%: {}",
396            signal.get()
397        );
398    }
399
400    #[test]
401    fn animate_to_replaces_in_flight() {
402        let mut tree = WidgetTree::new();
403        let owner = tree.add(FillWidget::new());
404        let signal = Signal::<f32>::new_animated(0.0);
405        tree.register_animated_signal(&signal, owner);
406
407        signal.animate_to(
408            100.0,
409            std::time::Duration::from_millis(200),
410            teksilo_tokens::Easing::Linear,
411        );
412        tree.tick_animations(std::time::Duration::from_millis(100));
413        assert!((signal.get() - 50.0).abs() < 2.0);
414
415        signal.animate_to(
416            0.0,
417            std::time::Duration::from_millis(100),
418            teksilo_tokens::Easing::Linear,
419        );
420        tree.tick_animations(std::time::Duration::from_millis(50));
421        assert!(
422            (signal.get() - 25.0).abs() < 3.0,
423            "mid-replace: {}",
424            signal.get()
425        );
426
427        tree.tick_animations(std::time::Duration::from_millis(50));
428        assert!(
429            (signal.get() - 0.0).abs() < 0.5,
430            "end-replace: {}",
431            signal.get()
432        );
433    }
434
435    #[test]
436    fn animation_marks_widgets_dirty() {
437        let mut tree = WidgetTree::new();
438        let widget = tree.add(FillWidget::new());
439        let signal = Signal::<f32>::new_animated(100.0);
440        tree.register_animated_signal(&signal, widget);
441
442        signal.bind_to(
443            widget,
444            tree.binding_registry(),
445            crate::binding::BindingLevel::Relayout,
446        );
447
448        tree.layout(SizeProposal::exact(200.0, 100.0));
449
450        signal.animate_to(
451            0.0,
452            std::time::Duration::from_millis(100),
453            teksilo_tokens::Easing::Linear,
454        );
455
456        tree.tick_animations(std::time::Duration::from_millis(50));
457        assert!(tree.needs_redraw());
458    }
459}