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