Skip to main content

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}