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    pub fn widget_count(&self) -> usize {
231        self.arena.len()
232    }
233
234    /// Tear down a widget and everything it owns — its subtree, its tooltip,
235    /// and the parentless content it built with
236    /// [`add_detached`](crate::build_context::BuildContext::add_detached).
237    ///
238    /// The application-facing door is `BuildContext::destroy_subtree`; this is
239    /// the same call for tests that hold the tree directly.
240    pub fn destroy_subtree_for_testing(&mut self, id: WidgetId) {
241        self.destroy_subtree(id);
242    }
243
244    /// Mark a widget as needing repaint.
245    pub fn mark_needs_paint(&mut self, id: WidgetId) {
246        self.arena.mark_needs_paint(id);
247    }
248
249    /// Set a widget subtree as dormant.
250    pub fn set_dormant(&mut self, id: WidgetId) {
251        self.arena.set_dormant(id);
252        self.arena.mark_ancestors_need_layout(id);
253        self.cached_frame = None;
254        self.a11y_dirty = true;
255    }
256
257    /// Activate a dormant widget subtree.
258    pub fn activate(&mut self, id: WidgetId) {
259        self.arena.activate(id);
260        self.arena.mark_ancestors_need_layout(id);
261        self.cached_frame = None;
262        self.a11y_dirty = true;
263    }
264
265    /// Invalidate all per-widget paint caches (paint AND post-paint) and
266    /// the assembled frame cache. Forces every widget to repaint on the
267    /// next `render()` call. Used by the glyph-atlas eviction recovery:
268    /// after an eviction, any retained frame may hold quads whose atlas
269    /// UVs now point at recycled slots.
270    pub fn invalidate_all_paints(&mut self) {
271        for id in self.arena.active_ids() {
272            if let Some(node) = self.arena.get_mut(id) {
273                node.dirty.needs_paint = true;
274                node.cached_paint = None;
275                node.cached_post_paint = None;
276            }
277        }
278        self.cached_frame = None;
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::signal::Signal;
286    use crate::test_widgets::{FillWidget, InsetWidget};
287
288    #[test]
289    fn child_bounds_helper() {
290        let mut tree = WidgetTree::new();
291        let child = tree.add(FillWidget::new());
292        let parent = tree.add(InsetWidget::new(5.0).set_child(child));
293        tree.layout(SizeProposal::exact(100.0, 50.0));
294        let child_bounds = tree.child_bounds(parent, 0);
295        assert_eq!(child_bounds.x, 5.0);
296    }
297
298    #[test]
299    fn signal_get_set_and_derived() {
300        let text = Signal::new(String::new());
301        let is_empty = text.map(|value| value.is_empty());
302        assert!(is_empty.get());
303        text.set("hello".to_string());
304        assert!(!is_empty.get());
305    }
306
307    #[test]
308    fn advance_time_updates_simulated_clock() {
309        let mut tree = WidgetTree::new();
310        let start = tree.simulated_now();
311
312        tree.advance_time(std::time::Duration::from_millis(500));
313        let end = tree.simulated_now();
314
315        assert_eq!(
316            end.duration_since(start),
317            std::time::Duration::from_millis(500)
318        );
319    }
320
321    #[test]
322    fn animate_to_interpolates_over_time() {
323        let mut tree = WidgetTree::new();
324        let owner = tree.add(FillWidget::new());
325        let signal = Signal::<f32>::new_animated(0.0);
326        tree.register_animated_signal(&signal, owner);
327
328        signal.animate_to(
329            100.0,
330            std::time::Duration::from_millis(200),
331            teksilo_tokens::Easing::Linear,
332        );
333
334        tree.tick_animations(std::time::Duration::from_millis(100));
335        assert!(
336            (signal.get() - 50.0).abs() < 2.0,
337            "at 50%: {}",
338            signal.get()
339        );
340
341        tree.tick_animations(std::time::Duration::from_millis(100));
342        assert!(
343            (signal.get() - 100.0).abs() < 0.1,
344            "at 100%: {}",
345            signal.get()
346        );
347
348        assert!(!tree.has_active_animations());
349    }
350
351    #[test]
352    fn animate_to_with_easing() {
353        let mut tree = WidgetTree::new();
354        let owner = tree.add(FillWidget::new());
355        let signal = Signal::<f32>::new_animated(0.0);
356        tree.register_animated_signal(&signal, owner);
357
358        signal.animate_to(
359            100.0,
360            std::time::Duration::from_millis(200),
361            teksilo_tokens::Easing::EaseIn,
362        );
363
364        tree.tick_animations(std::time::Duration::from_millis(100));
365        assert!(
366            (signal.get() - 25.0).abs() < 2.0,
367            "ease-in at 50%: {}",
368            signal.get()
369        );
370    }
371
372    #[test]
373    fn animate_to_replaces_in_flight() {
374        let mut tree = WidgetTree::new();
375        let owner = tree.add(FillWidget::new());
376        let signal = Signal::<f32>::new_animated(0.0);
377        tree.register_animated_signal(&signal, owner);
378
379        signal.animate_to(
380            100.0,
381            std::time::Duration::from_millis(200),
382            teksilo_tokens::Easing::Linear,
383        );
384        tree.tick_animations(std::time::Duration::from_millis(100));
385        assert!((signal.get() - 50.0).abs() < 2.0);
386
387        signal.animate_to(
388            0.0,
389            std::time::Duration::from_millis(100),
390            teksilo_tokens::Easing::Linear,
391        );
392        tree.tick_animations(std::time::Duration::from_millis(50));
393        assert!(
394            (signal.get() - 25.0).abs() < 3.0,
395            "mid-replace: {}",
396            signal.get()
397        );
398
399        tree.tick_animations(std::time::Duration::from_millis(50));
400        assert!(
401            (signal.get() - 0.0).abs() < 0.5,
402            "end-replace: {}",
403            signal.get()
404        );
405    }
406
407    #[test]
408    fn animation_marks_widgets_dirty() {
409        let mut tree = WidgetTree::new();
410        let widget = tree.add(FillWidget::new());
411        let signal = Signal::<f32>::new_animated(100.0);
412        tree.register_animated_signal(&signal, widget);
413
414        signal.bind_to(
415            widget,
416            tree.binding_registry(),
417            crate::binding::BindingLevel::Relayout,
418        );
419
420        tree.layout(SizeProposal::exact(200.0, 100.0));
421
422        signal.animate_to(
423            0.0,
424            std::time::Duration::from_millis(100),
425            teksilo_tokens::Easing::Linear,
426        );
427
428        tree.tick_animations(std::time::Duration::from_millis(50));
429        assert!(tree.needs_redraw());
430    }
431}