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    /// Last known pointer position from a `PointerMove` event. Used
18    /// by the safe-triangle submenu hover gate to compare the
19    /// cursor trajectory against the open submenu's bounds without
20    /// requiring the gate's evaluation site to receive a fresh
21    /// `PointerMove` itself.
22    pub fn last_pointer_position(&self) -> Option<teksilo_canvas::Point> {
23        self.last_pointer_position
24    }
25
26    /// Borrow the widget at `id` as `&dyn Any` for concrete-type
27    /// introspection. Uses the `Widget::as_any` hook — widgets that
28    /// haven't opted in return `None`. Primarily for tests that need
29    /// to inspect a widget's private Signal state.
30    pub fn widget_as_any(&self, id: WidgetId) -> Option<&dyn std::any::Any> {
31        self.arena.get(id).and_then(|node| node.widget.as_any())
32    }
33
34    /// Mutable variant of [`widget_as_any`](Self::widget_as_any).
35    /// Widgets opt in by overriding `Widget::as_any_mut`. Used by
36    /// tests that need to mutate widget state post-layout (e.g.
37    /// declaring a logical AT parent on a `SceneView` after the
38    /// arena allocated the inner widget's `WidgetId`).
39    pub fn widget_as_any_mut(&mut self, id: WidgetId) -> Option<&mut dyn std::any::Any> {
40        self.arena
41            .get_mut(id)
42            .and_then(|node| node.widget.as_any_mut())
43    }
44
45    pub fn children(&self, id: WidgetId) -> Vec<WidgetId> {
46        self.arena.children(id).to_vec()
47    }
48
49    /// Root widget ids of the arena (the entry points for a full widget-tree
50    /// walk). Mirrors what the debug inspector starts its tree view from.
51    pub fn roots(&self) -> Vec<WidgetId> {
52        self.arena.roots()
53    }
54
55    /// The concrete Rust type name of the widget at `id` (e.g.
56    /// `"teksilo_widgets::button::Button"`), or `None` if the id isn't in the
57    /// arena. The same `Widget::type_name()` the inspector's tree view labels
58    /// rows with.
59    pub fn widget_type_name(&self, id: WidgetId) -> Option<&'static str> {
60        self.arena.get(id).map(|n| n.widget.type_name())
61    }
62
63    /// The widget at `id` formatted via its `Debug` impl — its constructor
64    /// parameters / fields, the same "debug repr" the inspector's Properties
65    /// tab shows. `None` if the id isn't in the arena.
66    pub fn widget_debug_string(&self, id: WidgetId) -> Option<String> {
67        self.arena.get(id).map(|n| format!("{:?}", n.widget))
68    }
69
70    /// Whether the widget at `id` clips its children (e.g. `ScrollArea`,
71    /// `MaxSize`). `false` if the id isn't in the arena.
72    pub fn widget_clips_children(&self, id: WidgetId) -> bool {
73        self.arena
74            .get(id)
75            .map(|n| n.clips_children)
76            .unwrap_or(false)
77    }
78
79    /// The most recent layout proposal applied to this tree (the size
80    /// last passed to [`layout`](Self::layout) / `layout_with_ops`).
81    /// Lets a settle pass re-run layout at the current size without
82    /// recomputing it from a surface dimension. Returns
83    /// `SizeProposal::exact(800.0, 600.0)` on a tree that was never
84    /// laid out.
85    pub fn last_proposal(&self) -> SizeProposal {
86        self.last_proposal
87    }
88
89    /// Monotonic accessibility-tree version. Bumped in
90    /// [`sync_accessibility`](Self::sync_accessibility) only when a rebuild
91    /// produces a tree whose *content* actually differs from the cached one
92    /// (cache hits don't bump, and a rebuild that reproduces an identical
93    /// `TreeUpdate` — e.g. from a shortcut-rebind / locale invalidation —
94    /// doesn't either). Saturating, so the monotonic contract holds past
95    /// `u64::MAX`. Mirrors
96    /// [`ShortcutRegistry::version`](crate::shortcut::ShortcutRegistry::version):
97    /// poll it to detect AT-tree changes without diffing the whole
98    /// `TreeUpdate`.
99    pub fn at_version(&self) -> &crate::signal::Signal<u64> {
100        &self.at_version
101    }
102
103    /// Drain the captured live-region announcements with `seq` strictly
104    /// greater than `seq`. See [`crate::accessibility::Announcement`].
105    /// The buffer is capped at 256 entries, so a caller that lags far
106    /// behind sees only the retained tail. Read after a
107    /// [`sync_accessibility`](Self::sync_accessibility) (or a settle that
108    /// ends in one) to observe announcements raised by the latest
109    /// rebuild.
110    pub fn announcements_since(&self, seq: u64) -> Vec<crate::accessibility::Announcement> {
111        self.automation_announcements
112            .iter()
113            .filter(|a| a.seq > seq)
114            .cloned()
115            .collect()
116    }
117
118    /// Parent widget id in the arena graph, or `None` for roots.
119    pub fn parent(&self, id: WidgetId) -> Option<WidgetId> {
120        self.arena.parent(id)
121    }
122
123    pub fn needs_layout(&self) -> bool {
124        self.arena.any_needs_layout()
125    }
126
127    pub fn needs_paint(&self) -> bool {
128        self.arena.any_needs_paint()
129    }
130
131    pub fn active_animation_count(&self) -> usize {
132        self.animation_scheduler.active_count()
133    }
134
135    pub fn pending_tooltip_count(&self) -> usize {
136        self.tooltips
137            .iter()
138            .filter(|entry| entry.overlay_id.is_none() && entry.real_hover_start.is_some())
139            .count()
140    }
141
142    /// Whether there are pending idle callbacks to run.
143    pub fn has_idle_work(&self) -> bool {
144        !self.idle_queue.is_empty()
145    }
146
147    pub fn has_pending_modal_requests(&self) -> bool {
148        !self.pending_modal_requests.is_empty()
149    }
150
151    pub fn has_pending_modal_dismissal(&self) -> bool {
152        self.pending_modal_dismissal
153    }
154
155    pub fn current_cursor(&self) -> crate::widget::CursorIcon {
156        self.current_cursor
157    }
158
159    /// The widget currently under the pointer, if any. The framework
160    /// updates this on `PointerMove` / hover routing; widgets that have
161    /// captured the pointer or that opt out via `event_pass_through`
162    /// affect what shows up here. Mirrors the private `hovered` field
163    /// for read-only consumers (debug inspector, layout introspection).
164    pub fn hovered(&self) -> Option<WidgetId> {
165        self.hovered
166    }
167
168    /// Reactive handle to the hovered widget id. Cheap clone — the
169    /// underlying `Signal` is shared. Set whenever `hovered` changes
170    /// during dispatch, post-layout hover recovery, widget destruction,
171    /// or overlay subtree dormancy. Intended for debug tooling that
172    /// wants to react to hover without polling (the inspector's hover
173    /// tooltip).
174    pub fn hovered_signal(&self) -> crate::signal::Signal<Option<WidgetId>> {
175        self.hovered_signal.clone()
176    }
177
178    /// Reactive handle to the focused widget id. Mirror of
179    /// [`hovered_signal`](Self::hovered_signal) for the focus chain;
180    /// drives the inspector's Focus tab without polling.
181    pub fn focused_signal(&self) -> crate::signal::Signal<Option<WidgetId>> {
182        self.focused_signal.clone()
183    }
184
185    /// Drain and run all pending idle callbacks with the given time budget.
186    /// Called by the event loop during idle periods between frames.
187    pub fn run_idle_callbacks(&mut self, budget: std::time::Duration) {
188        let callbacks = self.idle_queue.drain();
189        for callback in callbacks {
190            callback(crate::idle::IdleDeadline::new(budget));
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
199    use crate::test_widgets::FillWidget;
200    use crate::widget_builder::WidgetBuilder;
201    use crate::{ModalCloseBehavior, ModalContent, ModalPresentation, ModalRequest};
202    use std::cell::Cell;
203    use std::rc::Rc;
204    use teksilo_canvas::Size;
205
206    #[derive(Debug)]
207    struct FixedWidget(f32, f32);
208
209    impl Widget for FixedWidget {
210        fn layout_response(
211            &self,
212            _proposal: SizeProposal,
213            _ctx: &LayoutContext,
214        ) -> crate::widget::LayoutResponse {
215            Size::new(self.0, self.1).into()
216        }
217    }
218
219    #[test]
220    fn destroy_removes_from_arena() {
221        let mut tree = WidgetTree::new();
222        let widget = tree.add(FillWidget::new().label("Gone"));
223        tree.layout(SizeProposal::exact(100.0, 50.0));
224        assert!(tree.find_by_label("Gone").is_some());
225
226        tree.arena.destroy(widget);
227        assert!(tree.find_by_label("Gone").is_none());
228    }
229
230    #[test]
231    fn idle_callback_requested_from_event_handler() {
232        let called = Rc::new(Cell::new(false));
233        let called_flag = called.clone();
234        let mut tree = WidgetTree::new();
235        let widget = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
236            let called = called_flag.clone();
237            ctx.request_idle_callback(move |_deadline| {
238                called.set(true);
239            });
240        }));
241        tree.layout(SizeProposal::exact(100.0, 50.0));
242
243        assert!(!tree.has_idle_work());
244
245        tree.click(widget);
246
247        assert!(tree.has_idle_work());
248        assert!(!called.get());
249
250        tree.run_idle_callbacks(std::time::Duration::from_millis(16));
251
252        assert!(called.get());
253        assert!(!tree.has_idle_work());
254    }
255
256    #[test]
257    fn set_locale_from_event_handler_is_parked_not_applied() {
258        let mut tree = WidgetTree::new();
259        let widget = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
260            ctx.set_locale("fr-FR");
261        }));
262        tree.layout(SizeProposal::exact(100.0, 50.0));
263        assert!(tree.locale().is_none());
264
265        tree.click(widget);
266
267        // The tree's own locale signal must NOT have been flipped — the
268        // app layer is responsible for routing the switch through
269        // `WindowManager::set_locale` so the `I18nManager`'s active
270        // locale, version signal, and RTL direction stay in sync.
271        assert_eq!(tree.locale(), None);
272        // The request is parked for the app layer to drain.
273        assert_eq!(
274            tree.take_pending_locale_request(),
275            Some("fr-FR".to_string())
276        );
277        // Drained exactly once.
278        assert_eq!(tree.take_pending_locale_request(), None);
279    }
280
281    #[test]
282    fn set_theme_from_event_handler_is_parked_not_applied() {
283        use crate::ThemeAppearance;
284
285        let mut tree = WidgetTree::new();
286        // `WidgetTree::new()` starts on the light preset.
287        assert_eq!(tree.theme().appearance, ThemeAppearance::Light);
288
289        let widget = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
290            ctx.set_theme(crate::presets::intui::dark());
291        }));
292        tree.layout(SizeProposal::exact(100.0, 50.0));
293
294        tree.click(widget);
295
296        // The tree's own theme must NOT have been flipped inline — the app
297        // layer routes the switch through `WindowManager::set_theme` so it
298        // fans out to *every* window, not just this one.
299        assert_eq!(tree.theme().appearance, ThemeAppearance::Light);
300        // The request is parked for the app layer to drain.
301        let parked = tree.take_pending_theme_request();
302        assert_eq!(parked.map(|t| t.appearance), Some(ThemeAppearance::Dark));
303        // Drained exactly once.
304        assert!(tree.take_pending_theme_request().is_none());
305    }
306
307    #[test]
308    fn idle_deadline_provides_time_budget() {
309        let deadline = crate::idle::IdleDeadline::new(std::time::Duration::from_millis(100));
310        assert!(!deadline.did_timeout());
311        assert!(deadline.time_remaining() > std::time::Duration::ZERO);
312    }
313
314    #[test]
315    fn modal_request_requested_from_event_handler() {
316        let mut tree = WidgetTree::new();
317        let content = tree.add(FillWidget::new().label("Modal content"));
318        let trigger = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
319            ctx.present_modal(
320                ModalRequest::in_tree(content)
321                    .presentation(ModalPresentation::InTree)
322                    .close_behavior(ModalCloseBehavior::Manual),
323            );
324        }));
325        tree.layout(SizeProposal::exact(100.0, 50.0));
326
327        assert!(!tree.has_pending_modal_requests());
328
329        tree.click(trigger);
330
331        assert!(tree.has_pending_modal_requests());
332        let requests = tree.drain_pending_modal_requests();
333        assert_eq!(requests.len(), 1);
334        assert_eq!(requests[0].source_widget, trigger);
335        assert_eq!(requests[0].request.presentation, ModalPresentation::InTree);
336        assert_eq!(
337            requests[0].request.close_behavior,
338            ModalCloseBehavior::Manual
339        );
340        match requests[0].request.content {
341            ModalContent::ExistingWidget(id) => assert_eq!(id, content),
342            ModalContent::Deferred(_) => panic!("expected ExistingWidget content"),
343        }
344        assert!(!tree.has_pending_modal_requests());
345    }
346
347    #[test]
348    fn draining_modal_requests_clears_queue() {
349        let mut tree = WidgetTree::new();
350        let content = tree.add(FillWidget::new());
351        let trigger = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
352            ctx.present_modal(ModalRequest::in_tree(content));
353        }));
354        tree.layout(SizeProposal::exact(100.0, 50.0));
355
356        tree.click(trigger);
357        assert_eq!(tree.drain_pending_modal_requests().len(), 1);
358        assert!(tree.drain_pending_modal_requests().is_empty());
359    }
360
361    #[test]
362    fn dismiss_modal_closes_centered_overlay_for_source_widget() {
363        let mut tree = WidgetTree::new();
364        let trigger = tree.add(FillWidget::new().label("Trigger"));
365        let modal_content = tree.add(FixedWidget(120.0, 48.0).on_tap(|_pos, ctx| {
366            ctx.dismiss_modal();
367        }));
368        tree.layout(SizeProposal::exact(320.0, 200.0));
369
370        tree.show_overlay(OverlayRequest {
371            content_id: modal_content,
372            anchor: trigger,
373            placement: OverlayPlacement::Centered,
374            dismiss: DismissBehavior::Manual,
375            layer: OverlayLayer::InTree,
376            parent_overlay: None,
377            on_dismiss: None,
378            fade_duration: None,
379        });
380        tree.layout(SizeProposal::exact(320.0, 200.0));
381
382        assert_eq!(tree.active_overlays().len(), 1);
383
384        let center = tree
385            .overlay_manager()
386            .topmost_centered()
387            .expect("expected centered modal overlay")
388            .bounds
389            .center();
390        tree.pointer_down_button(center, PointerButton::Primary);
391        tree.pointer_up_button(center, PointerButton::Primary);
392
393        assert!(tree.active_overlays().is_empty());
394        assert!(!tree.has_pending_modal_dismissal());
395    }
396
397    #[test]
398    fn dismiss_modal_without_in_tree_modal_queues_window_dismissal() {
399        let mut tree = WidgetTree::new();
400        let trigger = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
401            ctx.dismiss_modal();
402        }));
403        tree.layout(SizeProposal::exact(100.0, 50.0));
404
405        assert!(!tree.has_pending_modal_dismissal());
406
407        tree.click(trigger);
408
409        assert!(tree.has_pending_modal_dismissal());
410        assert!(tree.drain_pending_modal_dismissal());
411        assert!(!tree.has_pending_modal_dismissal());
412    }
413}