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