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    /// The live-region announcements with `seq` strictly greater than
160    /// `seq`: what the platform adapters would have announced from the
161    /// updates [`sync_accessibility`](Self::sync_accessibility) has returned.
162    /// See [`crate::accessibility::Announcement`] for what is and is not
163    /// recorded. The ring keeps the last 256, so a caller that lags far
164    /// behind sees only those. Read after a `sync_accessibility` (or a
165    /// settle that ends in one) to observe what the latest update said.
166    ///
167    /// Only a tree that [records announcements](Self::records_announcements)
168    /// has anything here. A tree built with `WidgetTree::new()` does, from
169    /// its first update, so a headless test reads this unchanged; the tree
170    /// of a window `teksilo-app` opens does only when the automation bridge
171    /// is installed.
172    pub fn announcements_since(&self, seq: u64) -> Vec<crate::accessibility::Announcement> {
173        self.announcement_ring.since(seq)
174    }
175
176    /// Whether this tree replays each update it hands out through
177    /// `accesskit_consumer` and records what the platform adapters would
178    /// announce, for [`announcements_since`](Self::announcements_since).
179    /// `true` for a tree built with `WidgetTree::new()`.
180    pub fn records_announcements(&self) -> bool {
181        self.announcement_ring.is_recording()
182    }
183
184    /// Start or stop recording announcements.
185    ///
186    /// The replay costs a pass of the consumer over every node of every
187    /// update, on top of the walk, and keeps a second copy of the tree, for
188    /// the benefit of a reader in process: the automation bridge, or a test.
189    /// `teksilo-app` turns it off on the tree of each window it opens unless
190    /// the bridge is installed, so an application that nobody drives pays
191    /// nothing for it. Turned on again, the tree records from its next update
192    /// on: what that update already holds is taken as heard, since the
193    /// updates handed out meanwhile reached whoever was listening, and the
194    /// changes after it are recorded. What was recorded before stays
195    /// readable either way.
196    pub fn set_records_announcements(&mut self, record: bool) {
197        self.announcement_ring.set_recording(record);
198    }
199
200    /// Parent widget id in the arena graph, or `None` for roots.
201    pub fn parent(&self, id: WidgetId) -> Option<WidgetId> {
202        self.arena.parent(id)
203    }
204
205    pub fn needs_layout(&self) -> bool {
206        self.arena.any_needs_layout()
207    }
208
209    pub fn needs_paint(&self) -> bool {
210        self.arena.any_needs_paint()
211    }
212
213    pub fn active_animation_count(&self) -> usize {
214        self.animation_scheduler.active_count()
215    }
216
217    pub fn pending_tooltip_count(&self) -> usize {
218        self.tooltips
219            .iter()
220            .filter(|entry| entry.overlay_id.is_none() && entry.real_hover_start.is_some())
221            .count()
222    }
223
224    /// Whether there are pending idle callbacks to run.
225    pub fn has_idle_work(&self) -> bool {
226        !self.idle_queue.is_empty()
227    }
228
229    pub fn has_pending_modal_requests(&self) -> bool {
230        !self.pending_modal_requests.is_empty()
231    }
232
233    pub fn has_pending_modal_dismissal(&self) -> bool {
234        self.pending_modal_dismissal
235    }
236
237    pub fn current_cursor(&self) -> crate::widget::CursorIcon {
238        self.current_cursor
239    }
240
241    /// The widget currently under the pointer, if any. The framework
242    /// updates this on `PointerMove` / hover routing; widgets that have
243    /// captured the pointer or that opt out via `event_pass_through`
244    /// affect what shows up here. Mirrors the hover owner's entry in the
245    /// private pointer table for read-only consumers (debug inspector,
246    /// layout introspection).
247    pub fn hovered(&self) -> Option<WidgetId> {
248        self.hovered_id()
249    }
250
251    /// The widget a *named* pointer is over.
252    ///
253    /// Only ever `Some` for the hover owner: a contact produces no hover, so
254    /// asking a finger what it is hovering always answers `None`.
255    pub fn hovered_for(&self, pointer: crate::pointer::PointerId) -> Option<WidgetId> {
256        self.pointers.get(pointer).and_then(|e| e.hovered)
257    }
258
259    /// Whether any dispatch is waiting to be replayed.
260    ///
261    /// Always `false` outside a dispatch — the queue is drained before a
262    /// top-level `dispatch_*` returns. A test asserts that; nothing else
263    /// should need to ask.
264    pub fn has_pending_dispatch(&self) -> bool {
265        !self.pending_dispatch.is_empty()
266    }
267
268    /// Reactive handle to the kind of the pointer that most recently produced
269    /// a sample — mouse, finger, stylus.
270    ///
271    /// The one question an adaptive affordance actually needs: whether the
272    /// user is currently working by hover or by contact. Bind it rather than
273    /// remembering an `on_pointer_event` purely to learn the modality.
274    pub fn last_pointer_kind_signal(&self) -> crate::signal::Signal<teksilo_tokens::PointerKind> {
275        self.last_pointer_kind_signal.clone()
276    }
277
278    /// Reactive handle to the hovered widget id. Cheap clone — the
279    /// underlying `Signal` is shared. Set whenever `hovered` changes
280    /// during dispatch, post-layout hover recovery, widget destruction,
281    /// or overlay subtree dormancy. Intended for debug tooling that
282    /// wants to react to hover without polling (the inspector's hover
283    /// tooltip).
284    pub fn hovered_signal(&self) -> crate::signal::Signal<Option<WidgetId>> {
285        self.hovered_signal.clone()
286    }
287
288    /// Reactive handle to the focused widget id. Mirror of
289    /// [`hovered_signal`](Self::hovered_signal) for the focus chain;
290    /// drives the inspector's Focus tab without polling.
291    pub fn focused_signal(&self) -> crate::signal::Signal<Option<WidgetId>> {
292        self.focused_signal.clone()
293    }
294
295    /// Drain and run all pending idle callbacks with the given time budget.
296    /// Called by the event loop during idle periods between frames.
297    pub fn run_idle_callbacks(&mut self, budget: std::time::Duration) {
298        let callbacks = self.idle_queue.drain();
299        for callback in callbacks {
300            callback(crate::idle::IdleDeadline::new(budget));
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
309    use crate::test_widgets::FillWidget;
310    use crate::widget_builder::WidgetBuilder;
311    use crate::{ModalCloseBehavior, ModalContent, ModalPresentation, ModalRequest};
312    use std::cell::Cell;
313    use std::rc::Rc;
314    use teksilo_canvas::Size;
315
316    #[derive(Debug)]
317    struct FixedWidget(f32, f32);
318
319    impl Widget for FixedWidget {
320        fn layout_response(
321            &self,
322            _proposal: SizeProposal,
323            _ctx: &LayoutContext,
324        ) -> crate::widget::LayoutResponse {
325            Size::new(self.0, self.1).into()
326        }
327    }
328
329    #[test]
330    fn destroy_removes_from_arena() {
331        let mut tree = WidgetTree::new();
332        let widget = tree.add(FillWidget::new().label("Gone"));
333        tree.layout(SizeProposal::exact(100.0, 50.0));
334        assert!(tree.find_by_label("Gone").is_some());
335
336        tree.arena.destroy(widget);
337        assert!(tree.find_by_label("Gone").is_none());
338    }
339
340    #[test]
341    fn idle_callback_requested_from_event_handler() {
342        let called = Rc::new(Cell::new(false));
343        let called_flag = called.clone();
344        let mut tree = WidgetTree::new();
345        let widget = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
346            let called = called_flag.clone();
347            ctx.request_idle_callback(move |_deadline| {
348                called.set(true);
349            });
350        }));
351        tree.layout(SizeProposal::exact(100.0, 50.0));
352
353        assert!(!tree.has_idle_work());
354
355        tree.click(widget);
356
357        assert!(tree.has_idle_work());
358        assert!(!called.get());
359
360        tree.run_idle_callbacks(std::time::Duration::from_millis(16));
361
362        assert!(called.get());
363        assert!(!tree.has_idle_work());
364    }
365
366    #[test]
367    fn set_locale_from_event_handler_is_parked_not_applied() {
368        let mut tree = WidgetTree::new();
369        let widget = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
370            ctx.set_locale("fr-FR");
371        }));
372        tree.layout(SizeProposal::exact(100.0, 50.0));
373        assert!(tree.locale().is_none());
374
375        tree.click(widget);
376
377        // The tree's own locale signal must NOT have been flipped — the
378        // app layer is responsible for routing the switch through
379        // `WindowManager::set_locale` so the `I18nManager`'s active
380        // locale, version signal, and RTL direction stay in sync.
381        assert_eq!(tree.locale(), None);
382        // The request is parked for the app layer to drain.
383        assert_eq!(
384            tree.take_pending_locale_request(),
385            Some("fr-FR".to_string())
386        );
387        // Drained exactly once.
388        assert_eq!(tree.take_pending_locale_request(), None);
389    }
390
391    #[test]
392    fn set_theme_from_event_handler_is_parked_not_applied() {
393        use crate::ThemeAppearance;
394
395        let mut tree = WidgetTree::new();
396        // `WidgetTree::new()` starts on the light preset.
397        assert_eq!(tree.theme().appearance, ThemeAppearance::Light);
398
399        let widget = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
400            ctx.set_theme(crate::presets::intui::dark());
401        }));
402        tree.layout(SizeProposal::exact(100.0, 50.0));
403
404        tree.click(widget);
405
406        // The tree's own theme must NOT have been flipped inline — the app
407        // layer routes the switch through `WindowManager::set_theme` so it
408        // fans out to *every* window, not just this one.
409        assert_eq!(tree.theme().appearance, ThemeAppearance::Light);
410        // The request is parked for the app layer to drain.
411        let parked = tree.take_pending_theme_request();
412        assert_eq!(parked.map(|t| t.appearance), Some(ThemeAppearance::Dark));
413        // Drained exactly once.
414        assert!(tree.take_pending_theme_request().is_none());
415    }
416
417    #[test]
418    fn idle_deadline_provides_time_budget() {
419        let deadline = crate::idle::IdleDeadline::new(std::time::Duration::from_millis(100));
420        assert!(!deadline.did_timeout());
421        assert!(deadline.time_remaining() > std::time::Duration::ZERO);
422    }
423
424    #[test]
425    fn modal_request_requested_from_event_handler() {
426        let mut tree = WidgetTree::new();
427        let content = tree.add(FillWidget::new().label("Modal content"));
428        let trigger = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
429            ctx.present_modal(
430                ModalRequest::in_tree(content)
431                    .presentation(ModalPresentation::InTree)
432                    .close_behavior(ModalCloseBehavior::Manual),
433            );
434        }));
435        tree.layout(SizeProposal::exact(100.0, 50.0));
436
437        assert!(!tree.has_pending_modal_requests());
438
439        tree.click(trigger);
440
441        assert!(tree.has_pending_modal_requests());
442        let requests = tree.drain_pending_modal_requests();
443        assert_eq!(requests.len(), 1);
444        assert_eq!(requests[0].source_widget, trigger);
445        assert_eq!(requests[0].request.presentation, ModalPresentation::InTree);
446        assert_eq!(
447            requests[0].request.close_behavior,
448            ModalCloseBehavior::Manual
449        );
450        match requests[0].request.content {
451            ModalContent::ExistingWidget(id) => assert_eq!(id, content),
452            ModalContent::Deferred(_) => panic!("expected ExistingWidget content"),
453        }
454        assert!(!tree.has_pending_modal_requests());
455    }
456
457    #[test]
458    fn draining_modal_requests_clears_queue() {
459        let mut tree = WidgetTree::new();
460        let content = tree.add(FillWidget::new());
461        let trigger = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
462            ctx.present_modal(ModalRequest::in_tree(content));
463        }));
464        tree.layout(SizeProposal::exact(100.0, 50.0));
465
466        tree.click(trigger);
467        assert_eq!(tree.drain_pending_modal_requests().len(), 1);
468        assert!(tree.drain_pending_modal_requests().is_empty());
469    }
470
471    #[test]
472    fn dismiss_modal_closes_centered_overlay_for_source_widget() {
473        let mut tree = WidgetTree::new();
474        let trigger = tree.add(FillWidget::new().label("Trigger"));
475        let modal_content = tree.add(FixedWidget(120.0, 48.0).on_tap(|_pos, ctx| {
476            ctx.dismiss_modal();
477        }));
478        tree.layout(SizeProposal::exact(320.0, 200.0));
479
480        tree.show_overlay(OverlayRequest {
481            content_id: modal_content,
482            anchor: trigger,
483            placement: OverlayPlacement::Centered,
484            dismiss: DismissBehavior::Manual,
485            layer: OverlayLayer::InTree,
486            parent_overlay: None,
487            on_dismiss: None,
488            fade_duration: None,
489        });
490        tree.layout(SizeProposal::exact(320.0, 200.0));
491
492        assert_eq!(tree.active_overlays().len(), 1);
493
494        let center = tree
495            .overlay_manager()
496            .topmost_centered()
497            .expect("expected centered modal overlay")
498            .bounds
499            .center();
500        tree.pointer_down_button(center, PointerButton::Primary);
501        tree.pointer_up_button(center, PointerButton::Primary);
502
503        assert!(tree.active_overlays().is_empty());
504        assert!(!tree.has_pending_modal_dismissal());
505    }
506
507    #[test]
508    fn dismiss_modal_without_in_tree_modal_queues_window_dismissal() {
509        let mut tree = WidgetTree::new();
510        let trigger = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
511            ctx.dismiss_modal();
512        }));
513        tree.layout(SizeProposal::exact(100.0, 50.0));
514
515        assert!(!tree.has_pending_modal_dismissal());
516
517        tree.click(trigger);
518
519        assert!(tree.has_pending_modal_dismissal());
520        assert!(tree.drain_pending_modal_dismissal());
521        assert!(!tree.has_pending_modal_dismissal());
522    }
523}