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    pub fn has_pending_modal_dismissal(&self) -> bool {
266        self.pending_modal_dismissal
267    }
268
269    /// The cursor the window would show right now.
270    ///
271    /// This is what `teksilo-app` hands to the OS after each pointer
272    /// dispatch. It is the tree's running answer, not a function of the
273    /// widgets: the hovered chain's `.cursor(..)` declaration is written into
274    /// it on `PointerEnter` and undone (back to `Default`) on `PointerLeave`,
275    /// and a handler's `ctx.set_cursor(..)` / `ctx.release_cursor()` moves it
276    /// within a dispatch. So it follows the *hover owner* (a mouse, or a pen
277    /// in proximity; a touch contact never hovers), and it changes when the
278    /// pointer enters or leaves a node, never on the moves in between.
279    ///
280    /// It can therefore go **stale**: if a node's cursor is changed (a rebuild
281    /// re-declaring it, for one) under a pointer that does not move, nothing
282    /// re-applies it until the next enter / leave pair. Read
283    /// [`declared_cursor`](Self::declared_cursor) for what a node asks for
284    /// regardless of where the pointer is.
285    pub fn current_cursor(&self) -> crate::widget::CursorIcon {
286        self.current_cursor
287    }
288
289    /// The cursor a node *declares* for itself, if it declares one.
290    ///
291    /// Reads what `.cursor(..)` (or `HandlerSet::cursor` through
292    /// `BuildContext::apply_self_handlers`) recorded on the node, whatever the
293    /// pointer is doing; it says nothing about a handler's `ctx.set_cursor(..)`
294    /// override, which lives only in [`current_cursor`](Self::current_cursor).
295    ///
296    /// `None` means the node declares nothing: entering it leaves the cursor
297    /// as it was. `Some(CursorIcon::Default)` is a different answer, an
298    /// explicit declaration of the default cursor, which entering the node
299    /// does apply. `None` for an id that is not in the tree.
300    pub fn declared_cursor(&self, id: WidgetId) -> Option<crate::widget::CursorIcon> {
301        self.arena.get(id).and_then(|node| node.node_cursor)
302    }
303
304    /// The widget currently under the pointer, if any. The framework
305    /// updates this on `PointerMove` / hover routing; widgets that have
306    /// captured the pointer or that opt out via `event_pass_through`
307    /// affect what shows up here. Mirrors the hover owner's entry in the
308    /// private pointer table for read-only consumers (debug inspector,
309    /// layout introspection).
310    pub fn hovered(&self) -> Option<WidgetId> {
311        self.hovered_id()
312    }
313
314    /// The widget a *named* pointer is over.
315    ///
316    /// Only ever `Some` for the hover owner: a contact produces no hover, so
317    /// asking a finger what it is hovering always answers `None`.
318    pub fn hovered_for(&self, pointer: crate::pointer::PointerId) -> Option<WidgetId> {
319        self.pointers.get(pointer).and_then(|e| e.hovered)
320    }
321
322    /// Whether any dispatch is waiting to be replayed.
323    ///
324    /// Always `false` outside a dispatch — the queue is drained before a
325    /// top-level `dispatch_*` returns. A test asserts that; nothing else
326    /// should need to ask.
327    pub fn has_pending_dispatch(&self) -> bool {
328        !self.pending_dispatch.is_empty()
329    }
330
331    /// Reactive handle to the kind of the pointer that most recently produced
332    /// a sample — mouse, finger, stylus.
333    ///
334    /// The one question an adaptive affordance actually needs: whether the
335    /// user is currently working by hover or by contact. Bind it rather than
336    /// remembering an `on_pointer_event` purely to learn the modality.
337    pub fn last_pointer_kind_signal(&self) -> crate::signal::Signal<teksilo_tokens::PointerKind> {
338        self.last_pointer_kind_signal.clone()
339    }
340
341    /// Reactive handle to the hovered widget id. Cheap clone — the
342    /// underlying `Signal` is shared. Set whenever `hovered` changes
343    /// during dispatch, post-layout hover recovery, widget destruction,
344    /// or overlay subtree dormancy. Intended for debug tooling that
345    /// wants to react to hover without polling (the inspector's hover
346    /// tooltip).
347    pub fn hovered_signal(&self) -> crate::signal::Signal<Option<WidgetId>> {
348        self.hovered_signal.clone()
349    }
350
351    /// Reactive handle to the focused widget id. Mirror of
352    /// [`hovered_signal`](Self::hovered_signal) for the focus chain;
353    /// drives the inspector's Focus tab without polling.
354    pub fn focused_signal(&self) -> crate::signal::Signal<Option<WidgetId>> {
355        self.focused_signal.clone()
356    }
357
358    /// Drain and run all pending idle callbacks with the given time budget.
359    /// Called by the event loop during idle periods between frames.
360    pub fn run_idle_callbacks(&mut self, budget: std::time::Duration) {
361        let callbacks = self.idle_queue.drain();
362        for callback in callbacks {
363            callback(crate::idle::IdleDeadline::new(budget));
364        }
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
372    use crate::test_widgets::FillWidget;
373    use crate::widget_builder::WidgetBuilder;
374    use crate::{ModalCloseBehavior, ModalContent, ModalPresentation, ModalRequest};
375    use std::cell::Cell;
376    use std::rc::Rc;
377    use teksilo_canvas::Size;
378
379    #[derive(Debug)]
380    struct FixedWidget(f32, f32);
381
382    impl Widget for FixedWidget {
383        fn layout_response(
384            &self,
385            _proposal: SizeProposal,
386            _ctx: &LayoutContext,
387        ) -> crate::widget::LayoutResponse {
388            Size::new(self.0, self.1).into()
389        }
390    }
391
392    #[test]
393    fn destroy_removes_from_arena() {
394        let mut tree = WidgetTree::new();
395        let widget = tree.add(FillWidget::new().label("Gone"));
396        tree.layout(SizeProposal::exact(100.0, 50.0));
397        assert!(tree.find_by_label("Gone").is_some());
398
399        tree.arena.destroy(widget);
400        assert!(tree.find_by_label("Gone").is_none());
401    }
402
403    #[test]
404    fn idle_callback_requested_from_event_handler() {
405        let called = Rc::new(Cell::new(false));
406        let called_flag = called.clone();
407        let mut tree = WidgetTree::new();
408        let widget = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
409            let called = called_flag.clone();
410            ctx.request_idle_callback(move |_deadline| {
411                called.set(true);
412            });
413        }));
414        tree.layout(SizeProposal::exact(100.0, 50.0));
415
416        assert!(!tree.has_idle_work());
417
418        tree.click(widget);
419
420        assert!(tree.has_idle_work());
421        assert!(!called.get());
422
423        tree.run_idle_callbacks(std::time::Duration::from_millis(16));
424
425        assert!(called.get());
426        assert!(!tree.has_idle_work());
427    }
428
429    #[test]
430    fn set_locale_from_event_handler_is_parked_not_applied() {
431        let mut tree = WidgetTree::new();
432        let widget = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
433            ctx.set_locale("fr-FR");
434        }));
435        tree.layout(SizeProposal::exact(100.0, 50.0));
436        assert!(tree.locale().is_none());
437
438        tree.click(widget);
439
440        // The tree's own locale signal must NOT have been flipped — the
441        // app layer is responsible for routing the switch through
442        // `WindowManager::set_locale` so the `I18nManager`'s active
443        // locale, version signal, and RTL direction stay in sync.
444        assert_eq!(tree.locale(), None);
445        // The request is parked for the app layer to drain.
446        assert_eq!(
447            tree.take_pending_locale_request(),
448            Some("fr-FR".to_string())
449        );
450        // Drained exactly once.
451        assert_eq!(tree.take_pending_locale_request(), None);
452    }
453
454    #[test]
455    fn set_theme_from_event_handler_is_parked_not_applied() {
456        use crate::ThemeAppearance;
457
458        let mut tree = WidgetTree::new();
459        // `WidgetTree::new()` starts on the light preset.
460        assert_eq!(tree.theme().appearance, ThemeAppearance::Light);
461
462        let widget = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
463            ctx.set_theme(crate::presets::intui::dark());
464        }));
465        tree.layout(SizeProposal::exact(100.0, 50.0));
466
467        tree.click(widget);
468
469        // The tree's own theme must NOT have been flipped inline — the app
470        // layer routes the switch through `WindowManager::set_theme` so it
471        // fans out to *every* window, not just this one.
472        assert_eq!(tree.theme().appearance, ThemeAppearance::Light);
473        // The request is parked for the app layer to drain.
474        let parked = tree.take_pending_theme_request();
475        assert_eq!(parked.map(|t| t.appearance), Some(ThemeAppearance::Dark));
476        // Drained exactly once.
477        assert!(tree.take_pending_theme_request().is_none());
478    }
479
480    #[test]
481    fn idle_deadline_provides_time_budget() {
482        let deadline = crate::idle::IdleDeadline::new(std::time::Duration::from_millis(100));
483        assert!(!deadline.did_timeout());
484        assert!(deadline.time_remaining() > std::time::Duration::ZERO);
485    }
486
487    #[test]
488    fn modal_request_requested_from_event_handler() {
489        let mut tree = WidgetTree::new();
490        let content = tree.add(FillWidget::new().label("Modal content"));
491        let trigger = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
492            ctx.present_modal(
493                ModalRequest::in_tree(content)
494                    .presentation(ModalPresentation::InTree)
495                    .close_behavior(ModalCloseBehavior::Manual),
496            );
497        }));
498        tree.layout(SizeProposal::exact(100.0, 50.0));
499
500        assert!(!tree.has_pending_modal_requests());
501
502        tree.click(trigger);
503
504        assert!(tree.has_pending_modal_requests());
505        let requests = tree.drain_pending_modal_requests();
506        assert_eq!(requests.len(), 1);
507        assert_eq!(requests[0].source_widget, trigger);
508        assert_eq!(requests[0].request.presentation, ModalPresentation::InTree);
509        assert_eq!(
510            requests[0].request.close_behavior,
511            ModalCloseBehavior::Manual
512        );
513        match requests[0].request.content {
514            ModalContent::ExistingWidget(id) => assert_eq!(id, content),
515            ModalContent::Deferred(_) => panic!("expected ExistingWidget content"),
516        }
517        assert!(!tree.has_pending_modal_requests());
518    }
519
520    #[test]
521    fn draining_modal_requests_clears_queue() {
522        let mut tree = WidgetTree::new();
523        let content = tree.add(FillWidget::new());
524        let trigger = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
525            ctx.present_modal(ModalRequest::in_tree(content));
526        }));
527        tree.layout(SizeProposal::exact(100.0, 50.0));
528
529        tree.click(trigger);
530        assert_eq!(tree.drain_pending_modal_requests().len(), 1);
531        assert!(tree.drain_pending_modal_requests().is_empty());
532    }
533
534    #[test]
535    fn dismiss_modal_closes_centered_overlay_for_source_widget() {
536        let mut tree = WidgetTree::new();
537        let trigger = tree.add(FillWidget::new().label("Trigger"));
538        let modal_content = tree.add(FixedWidget(120.0, 48.0).on_tap(|_pos, ctx| {
539            ctx.dismiss_modal();
540        }));
541        tree.layout(SizeProposal::exact(320.0, 200.0));
542
543        tree.show_overlay(OverlayRequest::new(
544            modal_content,
545            trigger,
546            OverlayPlacement::Centered,
547            DismissBehavior::Manual,
548        ));
549        tree.layout(SizeProposal::exact(320.0, 200.0));
550
551        assert_eq!(tree.active_overlays().len(), 1);
552
553        let center = tree
554            .overlay_manager()
555            .topmost_centered()
556            .expect("expected centered modal overlay")
557            .bounds
558            .center();
559        tree.pointer_down_button(center, PointerButton::Primary);
560        tree.pointer_up_button(center, PointerButton::Primary);
561
562        assert!(tree.active_overlays().is_empty());
563        assert!(!tree.has_pending_modal_dismissal());
564    }
565
566    #[test]
567    fn dismiss_modal_without_in_tree_modal_queues_window_dismissal() {
568        let mut tree = WidgetTree::new();
569        let trigger = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
570            ctx.dismiss_modal();
571        }));
572        tree.layout(SizeProposal::exact(100.0, 50.0));
573
574        assert!(!tree.has_pending_modal_dismissal());
575
576        tree.click(trigger);
577
578        assert!(tree.has_pending_modal_dismissal());
579        assert!(tree.drain_pending_modal_dismissal());
580        assert!(!tree.has_pending_modal_dismissal());
581    }
582}