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}