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}