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}