teksilo_core/widget_tree/query_impl.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6impl WidgetTree {
7 /// Get an immutable reference to a widget node (for internal use).
8 #[allow(dead_code)]
9 pub(crate) fn arena_get(&self, id: WidgetId) -> Option<&crate::arena::WidgetNode> {
10 self.arena.get(id)
11 }
12
13 pub fn bounds(&self, id: WidgetId) -> Rect {
14 self.arena.bounds(id)
15 }
16
17 /// The point `id`'s handlers receive for a window-logical point: the
18 /// conversion the dispatcher applies to every pointer position before a
19 /// handler of `id` sees it, transform scopes above and on `id` undone.
20 ///
21 /// For most widgets that is relative to the widget's top-left. A widget
22 /// that owns a content transform (a `SceneView`) maps its content itself,
23 /// so its handlers receive its parent's space instead, and so does this.
24 /// With no transform anywhere above `id` it is `window_point` minus the
25 /// widget's origin.
26 ///
27 /// The window-logical positions a handler is handed as such —
28 /// [`WidgetEvent::Scroll`]'s and [`WidgetEvent::PointerCancel`]'s
29 /// `window_position`, [`EventContext::coalesced`](crate::widget::EventContext::coalesced) —
30 /// convert inside the handler with
31 /// [`EventContext::to_local`](crate::widget::EventContext::to_local).
32 pub fn window_to_local(&self, id: WidgetId, window_point: Point) -> Point {
33 self.arena.local_pointer_position(id, window_point)
34 }
35
36 /// The window-logical point whose position for `id`'s handlers is
37 /// `local`: the inverse of [`window_to_local`](Self::window_to_local).
38 ///
39 /// What aims input at a point inside a widget, exact under a `Scale`, a
40 /// `Rotate` or a `SceneView` where adding the widget's origin is not:
41 /// a press sent at `local_to_window(id, p)` reaches `id`'s handlers at `p`
42 /// (to rounding). Where a transform above `id` is singular the subtree is
43 /// collapsed and nothing can aim inside it; both conversions then apply no
44 /// transform, as the dispatcher does, and stay each other's inverse.
45 pub fn local_to_window(&self, id: WidgetId, local: Point) -> Point {
46 self.arena.local_frame(id).to_window(local)
47 }
48
49 /// Last known position of the **primary** pointer, refreshed by
50 /// every positioned event (`PointerDown` / `PointerMove` /
51 /// `PointerUp`) rather than by moves alone. Used by the
52 /// safe-triangle submenu hover gate to compare the cursor
53 /// trajectory against the open submenu's bounds without
54 /// requiring the gate's evaluation site to receive a fresh
55 /// `PointerMove` itself.
56 pub fn last_pointer_position(&self) -> Option<teksilo_canvas::Point> {
57 self.pointers.primary().map(|e| e.position)
58 }
59
60 /// Where a *named* pointer is, in window-logical coordinates.
61 ///
62 /// `None` if that pointer is not live. The per-pointer companion to
63 /// [`last_pointer_position`](Self::last_pointer_position), which reports
64 /// the primary.
65 pub fn pointer_position(
66 &self,
67 pointer: crate::pointer::PointerId,
68 ) -> Option<teksilo_canvas::Point> {
69 self.pointers.get(pointer).map(|e| e.position)
70 }
71
72 /// The widget holding `pointer`'s capture.
73 ///
74 /// Capture is per pointer: two contacts hold independent captures, and
75 /// each is released only by its own Up or Cancel.
76 pub fn captured_by(&self, pointer: crate::pointer::PointerId) -> Option<WidgetId> {
77 self.pointers.get(pointer).and_then(|e| e.captured_by)
78 }
79
80 /// The widget holding the **primary** pointer's capture — the singular
81 /// view of capture, and for a mouse-only machine the whole story.
82 pub fn pointer_captured_by(&self) -> Option<WidgetId> {
83 self.pointers.primary().and_then(|e| e.captured_by)
84 }
85
86 /// Every live pointer.
87 ///
88 /// A mouse appears once it has produced a sample and stays for the life of
89 /// the tree (it never lifts); a contact appears at its press and is gone
90 /// after its Up or Cancel.
91 pub fn live_pointers(&self) -> impl Iterator<Item = crate::pointer::PointerInfo> + '_ {
92 self.pointers.iter().map(|e| e.info)
93 }
94
95 /// Teksilo's single pointer — the one backing
96 /// [`hovered`](Self::hovered) and
97 /// [`last_pointer_position`](Self::last_pointer_position). A mouse wins
98 /// the role whenever one is live; failing that, the oldest pointer does.
99 ///
100 /// Not the same thing as
101 /// [`PointerInfo::primary`](crate::pointer::PointerInfo::primary), which
102 /// is the W3C per-kind flag and can be true for two pointers at once.
103 pub fn primary_pointer(&self) -> Option<crate::pointer::PointerInfo> {
104 self.pointers.primary().map(|e| e.info)
105 }
106
107 /// The most recent hovering-capable pointer — a mouse, or a pen in
108 /// proximity. Hover, the cursor and tooltip dwell all follow it, and a
109 /// touch contact is never it.
110 pub fn hover_owner(&self) -> Option<crate::pointer::PointerInfo> {
111 self.pointers.hover_owner().map(|e| e.info)
112 }
113
114 /// Borrow the widget at `id` as `&dyn Any` for concrete-type
115 /// introspection. Uses the `Widget::as_any` hook — widgets that
116 /// haven't opted in return `None`. Primarily for tests that need
117 /// to inspect a widget's private Signal state.
118 pub fn widget_as_any(&self, id: WidgetId) -> Option<&dyn std::any::Any> {
119 self.arena.get(id).and_then(|node| node.widget.as_any())
120 }
121
122 /// Mutable variant of [`widget_as_any`](Self::widget_as_any).
123 /// Widgets opt in by overriding `Widget::as_any_mut`. Used by
124 /// tests that need to mutate widget state post-layout (e.g.
125 /// declaring a logical AT parent on a `SceneView` after the
126 /// arena allocated the inner widget's `WidgetId`).
127 pub fn widget_as_any_mut(&mut self, id: WidgetId) -> Option<&mut dyn std::any::Any> {
128 self.arena
129 .get_mut(id)
130 .and_then(|node| node.widget.as_any_mut())
131 }
132
133 pub fn children(&self, id: WidgetId) -> Vec<WidgetId> {
134 self.arena.children(id).to_vec()
135 }
136
137 /// Root widget ids of the arena (the entry points for a full widget-tree
138 /// walk). Mirrors what the debug inspector starts its tree view from.
139 pub fn roots(&self) -> Vec<WidgetId> {
140 self.arena.roots()
141 }
142
143 /// The concrete Rust type name of the widget at `id` (e.g.
144 /// `"teksilo_widgets::button::Button"`), or `None` if the id isn't in the
145 /// arena. The same `Widget::type_name()` the inspector's tree view labels
146 /// rows with.
147 pub fn widget_type_name(&self, id: WidgetId) -> Option<&'static str> {
148 self.arena.get(id).map(|n| n.widget.type_name())
149 }
150
151 /// The widget at `id` formatted via its `Debug` impl — its constructor
152 /// parameters / fields, the same "debug repr" the inspector's Properties
153 /// tab shows. `None` if the id isn't in the arena.
154 pub fn widget_debug_string(&self, id: WidgetId) -> Option<String> {
155 self.arena.get(id).map(|n| format!("{:?}", n.widget))
156 }
157
158 /// Whether the widget at `id` clips its children (e.g. `ScrollArea`,
159 /// `MaxSize`). `false` if the id isn't in the arena.
160 pub fn widget_clips_children(&self, id: WidgetId) -> bool {
161 self.arena
162 .get(id)
163 .map(|n| n.clips_children)
164 .unwrap_or(false)
165 }
166
167 /// The most recent layout proposal applied to this tree (the size
168 /// last passed to [`layout`](Self::layout) / `layout_with_ops`).
169 /// Lets a settle pass re-run layout at the current size without
170 /// recomputing it from a surface dimension. Returns
171 /// `SizeProposal::exact(800.0, 600.0)` on a tree that was never
172 /// laid out.
173 pub fn last_proposal(&self) -> SizeProposal {
174 self.last_proposal
175 }
176
177 /// Monotonic accessibility-tree version. Bumped in
178 /// [`sync_accessibility`](Self::sync_accessibility) only when a rebuild
179 /// produces a tree whose *content* actually differs from the cached one
180 /// (cache hits don't bump, and a rebuild that reproduces an identical
181 /// `TreeUpdate` — e.g. from a shortcut-rebind / locale invalidation —
182 /// doesn't either). Saturating, so the monotonic contract holds past
183 /// `u64::MAX`. Mirrors
184 /// [`ShortcutRegistry::version`](crate::shortcut::ShortcutRegistry::version):
185 /// poll it to detect AT-tree changes without diffing the whole
186 /// `TreeUpdate`.
187 pub fn at_version(&self) -> &crate::signal::Signal<u64> {
188 &self.at_version
189 }
190
191 /// The live-region announcements with `seq` strictly greater than
192 /// `seq`: what the platform adapters would have announced from the
193 /// updates [`sync_accessibility`](Self::sync_accessibility) has returned.
194 /// See [`crate::accessibility::Announcement`] for what is and is not
195 /// recorded. The ring keeps the last 256, so a caller that lags far
196 /// behind sees only those. Read after a `sync_accessibility` (or a
197 /// settle that ends in one) to observe what the latest update said.
198 ///
199 /// Only a tree that [records announcements](Self::records_announcements)
200 /// has anything here. A tree built with `WidgetTree::new()` does, from
201 /// its first update, so a headless test reads this unchanged; the tree
202 /// of a window `teksilo-app` opens does only when the automation bridge
203 /// is installed.
204 pub fn announcements_since(&self, seq: u64) -> Vec<crate::accessibility::Announcement> {
205 self.announcement_ring.since(seq)
206 }
207
208 /// Whether this tree replays each update it hands out through
209 /// `accesskit_consumer` and records what the platform adapters would
210 /// announce, for [`announcements_since`](Self::announcements_since).
211 /// `true` for a tree built with `WidgetTree::new()`.
212 pub fn records_announcements(&self) -> bool {
213 self.announcement_ring.is_recording()
214 }
215
216 /// Start or stop recording announcements.
217 ///
218 /// The replay costs a pass of the consumer over every node of every
219 /// update, on top of the walk, and keeps a second copy of the tree, for
220 /// the benefit of a reader in process: the automation bridge, or a test.
221 /// `teksilo-app` turns it off on the tree of each window it opens unless
222 /// the bridge is installed, so an application that nobody drives pays
223 /// nothing for it. Turned on again, the tree records from its next update
224 /// on: what that update already holds is taken as heard, since the
225 /// updates handed out meanwhile reached whoever was listening, and the
226 /// changes after it are recorded. What was recorded before stays
227 /// readable either way.
228 pub fn set_records_announcements(&mut self, record: bool) {
229 self.announcement_ring.set_recording(record);
230 }
231
232 /// Parent widget id in the arena graph, or `None` for roots.
233 pub fn parent(&self, id: WidgetId) -> Option<WidgetId> {
234 self.arena.parent(id)
235 }
236
237 pub fn needs_layout(&self) -> bool {
238 self.arena.any_needs_layout()
239 }
240
241 pub fn needs_paint(&self) -> bool {
242 self.arena.any_needs_paint()
243 }
244
245 pub fn active_animation_count(&self) -> usize {
246 self.animation_scheduler.active_count()
247 }
248
249 pub fn pending_tooltip_count(&self) -> usize {
250 self.tooltips
251 .iter()
252 .filter(|entry| entry.overlay_id.is_none() && entry.real_hover_start.is_some())
253 .count()
254 }
255
256 /// Whether there are pending idle callbacks to run.
257 pub fn has_idle_work(&self) -> bool {
258 !self.idle_queue.is_empty()
259 }
260
261 pub fn has_pending_modal_requests(&self) -> bool {
262 !self.pending_modal_requests.is_empty()
263 }
264
265 /// Whether a modal overlay is open in this tree: a dialog presented
266 /// in the window, or a command palette. A modal request still queued
267 /// ([`has_pending_modal_requests`](Self::has_pending_modal_requests))
268 /// does not count until it is presented.
269 pub fn has_modal_overlay(&self) -> bool {
270 self.overlay_manager.topmost_modal().is_some()
271 }
272
273 pub fn has_pending_modal_dismissal(&self) -> bool {
274 self.pending_modal_dismissal
275 }
276
277 /// The cursor the window would show right now.
278 ///
279 /// This is what `teksilo-app` hands to the OS after each pointer
280 /// dispatch. It is the tree's running answer, not a function of the
281 /// widgets: the hovered chain's `.cursor(..)` declaration is written into
282 /// it on `PointerEnter` and undone (back to `Default`) on `PointerLeave`,
283 /// and a handler's `ctx.set_cursor(..)` / `ctx.release_cursor()` moves it
284 /// within a dispatch. So it follows the *hover owner* (a mouse, or a pen
285 /// in proximity; a touch contact never hovers), and it changes when the
286 /// pointer enters or leaves a node, never on the moves in between.
287 ///
288 /// It can therefore go **stale**: if a node's cursor is changed (a rebuild
289 /// re-declaring it, for one) under a pointer that does not move, nothing
290 /// re-applies it until the next enter / leave pair. Read
291 /// [`declared_cursor`](Self::declared_cursor) for what a node asks for
292 /// regardless of where the pointer is.
293 pub fn current_cursor(&self) -> crate::widget::CursorIcon {
294 self.current_cursor
295 }
296
297 /// The cursor a node *declares* for itself, if it declares one.
298 ///
299 /// Reads what `.cursor(..)` (or `HandlerSet::cursor` through
300 /// `BuildContext::apply_self_handlers`) recorded on the node, whatever the
301 /// pointer is doing; it says nothing about a handler's `ctx.set_cursor(..)`
302 /// override, which lives only in [`current_cursor`](Self::current_cursor).
303 ///
304 /// `None` means the node declares nothing: entering it leaves the cursor
305 /// as it was. `Some(CursorIcon::Default)` is a different answer, an
306 /// explicit declaration of the default cursor, which entering the node
307 /// does apply. `None` for an id that is not in the tree.
308 pub fn declared_cursor(&self, id: WidgetId) -> Option<crate::widget::CursorIcon> {
309 self.arena.get(id).and_then(|node| node.node_cursor)
310 }
311
312 /// The widget currently under the pointer, if any. The framework
313 /// updates this on `PointerMove` / hover routing; widgets that have
314 /// captured the pointer or that opt out via `event_pass_through`
315 /// affect what shows up here. Mirrors the hover owner's entry in the
316 /// private pointer table for read-only consumers (debug inspector,
317 /// layout introspection).
318 pub fn hovered(&self) -> Option<WidgetId> {
319 self.hovered_id()
320 }
321
322 /// The widget a *named* pointer is over.
323 ///
324 /// Only ever `Some` for the hover owner: a contact produces no hover, so
325 /// asking a finger what it is hovering always answers `None`.
326 pub fn hovered_for(&self, pointer: crate::pointer::PointerId) -> Option<WidgetId> {
327 self.pointers.get(pointer).and_then(|e| e.hovered)
328 }
329
330 /// Whether any dispatch is waiting to be replayed.
331 ///
332 /// Always `false` outside a dispatch — the queue is drained before a
333 /// top-level `dispatch_*` returns. A test asserts that; nothing else
334 /// should need to ask.
335 pub fn has_pending_dispatch(&self) -> bool {
336 !self.pending_dispatch.is_empty()
337 }
338
339 /// Reactive handle to the kind of the pointer that most recently produced
340 /// a sample — mouse, finger, stylus.
341 ///
342 /// The one question an adaptive affordance actually needs: whether the
343 /// user is currently working by hover or by contact. Bind it rather than
344 /// remembering an `on_pointer_event` purely to learn the modality.
345 pub fn last_pointer_kind_signal(&self) -> crate::signal::Signal<teksilo_tokens::PointerKind> {
346 self.last_pointer_kind_signal.clone()
347 }
348
349 /// Reactive handle to the hovered widget id. Cheap clone — the
350 /// underlying `Signal` is shared. Set whenever `hovered` changes
351 /// during dispatch, post-layout hover recovery, widget destruction,
352 /// or overlay subtree dormancy. Intended for debug tooling that
353 /// wants to react to hover without polling (the inspector's hover
354 /// tooltip).
355 pub fn hovered_signal(&self) -> crate::signal::Signal<Option<WidgetId>> {
356 self.hovered_signal.clone()
357 }
358
359 /// Reactive handle to the focused widget id. Mirror of
360 /// [`hovered_signal`](Self::hovered_signal) for the focus chain;
361 /// drives the inspector's Focus tab without polling.
362 pub fn focused_signal(&self) -> crate::signal::Signal<Option<WidgetId>> {
363 self.focused_signal.clone()
364 }
365
366 /// Drain and run all pending idle callbacks with the given time budget.
367 /// Called by the event loop during idle periods between frames.
368 pub fn run_idle_callbacks(&mut self, budget: std::time::Duration) {
369 let callbacks = self.idle_queue.drain();
370 for callback in callbacks {
371 callback(crate::idle::IdleDeadline::new(budget));
372 }
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
380 use crate::test_widgets::FillWidget;
381 use crate::widget_builder::WidgetBuilder;
382 use crate::{ModalCloseBehavior, ModalContent, ModalPresentation, ModalRequest};
383 use std::cell::Cell;
384 use std::rc::Rc;
385 use teksilo_canvas::Size;
386
387 #[derive(Debug)]
388 struct FixedWidget(f32, f32);
389
390 impl Widget for FixedWidget {
391 fn layout_response(
392 &self,
393 _proposal: SizeProposal,
394 _ctx: &LayoutContext,
395 ) -> crate::widget::LayoutResponse {
396 Size::new(self.0, self.1).into()
397 }
398 }
399
400 #[test]
401 fn destroy_removes_from_arena() {
402 let mut tree = WidgetTree::new();
403 let widget = tree.add(FillWidget::new().label("Gone"));
404 tree.layout(SizeProposal::exact(100.0, 50.0));
405 assert!(tree.find_by_label("Gone").is_some());
406
407 tree.arena.destroy(widget);
408 assert!(tree.find_by_label("Gone").is_none());
409 }
410
411 #[test]
412 fn idle_callback_requested_from_event_handler() {
413 let called = Rc::new(Cell::new(false));
414 let called_flag = called.clone();
415 let mut tree = WidgetTree::new();
416 let widget = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
417 let called = called_flag.clone();
418 ctx.request_idle_callback(move |_deadline| {
419 called.set(true);
420 });
421 }));
422 tree.layout(SizeProposal::exact(100.0, 50.0));
423
424 assert!(!tree.has_idle_work());
425
426 tree.click(widget);
427
428 assert!(tree.has_idle_work());
429 assert!(!called.get());
430
431 tree.run_idle_callbacks(std::time::Duration::from_millis(16));
432
433 assert!(called.get());
434 assert!(!tree.has_idle_work());
435 }
436
437 #[test]
438 fn set_locale_from_event_handler_is_parked_not_applied() {
439 let mut tree = WidgetTree::new();
440 let widget = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
441 ctx.set_locale("fr-FR");
442 }));
443 tree.layout(SizeProposal::exact(100.0, 50.0));
444 assert!(tree.locale().is_none());
445
446 tree.click(widget);
447
448 // The tree's own locale signal must NOT have been flipped — the
449 // app layer is responsible for routing the switch through
450 // `WindowManager::set_locale` so the `I18nManager`'s active
451 // locale, version signal, and RTL direction stay in sync.
452 assert_eq!(tree.locale(), None);
453 // The request is parked for the app layer to drain.
454 assert_eq!(
455 tree.take_pending_locale_request(),
456 Some("fr-FR".to_string())
457 );
458 // Drained exactly once.
459 assert_eq!(tree.take_pending_locale_request(), None);
460 }
461
462 #[test]
463 fn set_theme_from_event_handler_is_parked_not_applied() {
464 use crate::ThemeAppearance;
465
466 let mut tree = WidgetTree::new();
467 // `WidgetTree::new()` starts on the light preset.
468 assert_eq!(tree.theme().appearance, ThemeAppearance::Light);
469
470 let widget = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
471 ctx.set_theme(crate::presets::intui::dark());
472 }));
473 tree.layout(SizeProposal::exact(100.0, 50.0));
474
475 tree.click(widget);
476
477 // The tree's own theme must NOT have been flipped inline — the app
478 // layer routes the switch through `WindowManager::set_theme` so it
479 // fans out to *every* window, not just this one.
480 assert_eq!(tree.theme().appearance, ThemeAppearance::Light);
481 // The request is parked for the app layer to drain.
482 let parked = tree.take_pending_theme_request();
483 assert_eq!(parked.map(|t| t.appearance), Some(ThemeAppearance::Dark));
484 // Drained exactly once.
485 assert!(tree.take_pending_theme_request().is_none());
486 }
487
488 #[test]
489 fn idle_deadline_provides_time_budget() {
490 let deadline = crate::idle::IdleDeadline::new(std::time::Duration::from_millis(100));
491 assert!(!deadline.did_timeout());
492 assert!(deadline.time_remaining() > std::time::Duration::ZERO);
493 }
494
495 #[test]
496 fn modal_request_requested_from_event_handler() {
497 let mut tree = WidgetTree::new();
498 let content = tree.add(FillWidget::new().label("Modal content"));
499 let trigger = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
500 ctx.present_modal(
501 ModalRequest::in_tree(content)
502 .presentation(ModalPresentation::InTree)
503 .close_behavior(ModalCloseBehavior::Manual),
504 );
505 }));
506 tree.layout(SizeProposal::exact(100.0, 50.0));
507
508 assert!(!tree.has_pending_modal_requests());
509
510 tree.click(trigger);
511
512 assert!(tree.has_pending_modal_requests());
513 let requests = tree.drain_pending_modal_requests();
514 assert_eq!(requests.len(), 1);
515 assert_eq!(requests[0].source_widget, trigger);
516 assert_eq!(requests[0].request.presentation, ModalPresentation::InTree);
517 assert_eq!(
518 requests[0].request.close_behavior,
519 ModalCloseBehavior::Manual
520 );
521 match requests[0].request.content {
522 ModalContent::ExistingWidget(id) => assert_eq!(id, content),
523 ModalContent::Deferred(_) => panic!("expected ExistingWidget content"),
524 }
525 assert!(!tree.has_pending_modal_requests());
526 }
527
528 #[test]
529 fn draining_modal_requests_clears_queue() {
530 let mut tree = WidgetTree::new();
531 let content = tree.add(FillWidget::new());
532 let trigger = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
533 ctx.present_modal(ModalRequest::in_tree(content));
534 }));
535 tree.layout(SizeProposal::exact(100.0, 50.0));
536
537 tree.click(trigger);
538 assert_eq!(tree.drain_pending_modal_requests().len(), 1);
539 assert!(tree.drain_pending_modal_requests().is_empty());
540 }
541
542 #[test]
543 fn dismiss_modal_closes_centered_overlay_for_source_widget() {
544 let mut tree = WidgetTree::new();
545 let trigger = tree.add(FillWidget::new().label("Trigger"));
546 let modal_content = tree.add(FixedWidget(120.0, 48.0).on_tap(|_pos, ctx| {
547 ctx.dismiss_modal();
548 }));
549 tree.layout(SizeProposal::exact(320.0, 200.0));
550
551 tree.show_overlay(OverlayRequest::new(
552 modal_content,
553 trigger,
554 OverlayPlacement::Centered,
555 DismissBehavior::Manual,
556 ));
557 tree.layout(SizeProposal::exact(320.0, 200.0));
558
559 assert_eq!(tree.active_overlays().len(), 1);
560
561 let center = tree
562 .overlay_manager()
563 .topmost_modal()
564 .expect("expected centered modal overlay")
565 .bounds
566 .center();
567 tree.pointer_down_button(center, PointerButton::Primary);
568 tree.pointer_up_button(center, PointerButton::Primary);
569
570 assert!(tree.active_overlays().is_empty());
571 assert!(!tree.has_pending_modal_dismissal());
572 }
573
574 #[test]
575 fn dismiss_modal_without_in_tree_modal_queues_window_dismissal() {
576 let mut tree = WidgetTree::new();
577 let trigger = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
578 ctx.dismiss_modal();
579 }));
580 tree.layout(SizeProposal::exact(100.0, 50.0));
581
582 assert!(!tree.has_pending_modal_dismissal());
583
584 tree.click(trigger);
585
586 assert!(tree.has_pending_modal_dismissal());
587 assert!(tree.drain_pending_modal_dismissal());
588 assert!(!tree.has_pending_modal_dismissal());
589 }
590}