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    /// 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    pub fn current_cursor(&self) -> crate::widget::CursorIcon {
270        self.current_cursor
271    }
272
273    /// The widget currently under the pointer, if any. The framework
274    /// updates this on `PointerMove` / hover routing; widgets that have
275    /// captured the pointer or that opt out via `event_pass_through`
276    /// affect what shows up here. Mirrors the hover owner's entry in the
277    /// private pointer table for read-only consumers (debug inspector,
278    /// layout introspection).
279    pub fn hovered(&self) -> Option<WidgetId> {
280        self.hovered_id()
281    }
282
283    /// The widget a *named* pointer is over.
284    ///
285    /// Only ever `Some` for the hover owner: a contact produces no hover, so
286    /// asking a finger what it is hovering always answers `None`.
287    pub fn hovered_for(&self, pointer: crate::pointer::PointerId) -> Option<WidgetId> {
288        self.pointers.get(pointer).and_then(|e| e.hovered)
289    }
290
291    /// Whether any dispatch is waiting to be replayed.
292    ///
293    /// Always `false` outside a dispatch — the queue is drained before a
294    /// top-level `dispatch_*` returns. A test asserts that; nothing else
295    /// should need to ask.
296    pub fn has_pending_dispatch(&self) -> bool {
297        !self.pending_dispatch.is_empty()
298    }
299
300    /// Reactive handle to the kind of the pointer that most recently produced
301    /// a sample — mouse, finger, stylus.
302    ///
303    /// The one question an adaptive affordance actually needs: whether the
304    /// user is currently working by hover or by contact. Bind it rather than
305    /// remembering an `on_pointer_event` purely to learn the modality.
306    pub fn last_pointer_kind_signal(&self) -> crate::signal::Signal<teksilo_tokens::PointerKind> {
307        self.last_pointer_kind_signal.clone()
308    }
309
310    /// Reactive handle to the hovered widget id. Cheap clone — the
311    /// underlying `Signal` is shared. Set whenever `hovered` changes
312    /// during dispatch, post-layout hover recovery, widget destruction,
313    /// or overlay subtree dormancy. Intended for debug tooling that
314    /// wants to react to hover without polling (the inspector's hover
315    /// tooltip).
316    pub fn hovered_signal(&self) -> crate::signal::Signal<Option<WidgetId>> {
317        self.hovered_signal.clone()
318    }
319
320    /// Reactive handle to the focused widget id. Mirror of
321    /// [`hovered_signal`](Self::hovered_signal) for the focus chain;
322    /// drives the inspector's Focus tab without polling.
323    pub fn focused_signal(&self) -> crate::signal::Signal<Option<WidgetId>> {
324        self.focused_signal.clone()
325    }
326
327    /// Drain and run all pending idle callbacks with the given time budget.
328    /// Called by the event loop during idle periods between frames.
329    pub fn run_idle_callbacks(&mut self, budget: std::time::Duration) {
330        let callbacks = self.idle_queue.drain();
331        for callback in callbacks {
332            callback(crate::idle::IdleDeadline::new(budget));
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
341    use crate::test_widgets::FillWidget;
342    use crate::widget_builder::WidgetBuilder;
343    use crate::{ModalCloseBehavior, ModalContent, ModalPresentation, ModalRequest};
344    use std::cell::Cell;
345    use std::rc::Rc;
346    use teksilo_canvas::Size;
347
348    #[derive(Debug)]
349    struct FixedWidget(f32, f32);
350
351    impl Widget for FixedWidget {
352        fn layout_response(
353            &self,
354            _proposal: SizeProposal,
355            _ctx: &LayoutContext,
356        ) -> crate::widget::LayoutResponse {
357            Size::new(self.0, self.1).into()
358        }
359    }
360
361    #[test]
362    fn destroy_removes_from_arena() {
363        let mut tree = WidgetTree::new();
364        let widget = tree.add(FillWidget::new().label("Gone"));
365        tree.layout(SizeProposal::exact(100.0, 50.0));
366        assert!(tree.find_by_label("Gone").is_some());
367
368        tree.arena.destroy(widget);
369        assert!(tree.find_by_label("Gone").is_none());
370    }
371
372    #[test]
373    fn idle_callback_requested_from_event_handler() {
374        let called = Rc::new(Cell::new(false));
375        let called_flag = called.clone();
376        let mut tree = WidgetTree::new();
377        let widget = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
378            let called = called_flag.clone();
379            ctx.request_idle_callback(move |_deadline| {
380                called.set(true);
381            });
382        }));
383        tree.layout(SizeProposal::exact(100.0, 50.0));
384
385        assert!(!tree.has_idle_work());
386
387        tree.click(widget);
388
389        assert!(tree.has_idle_work());
390        assert!(!called.get());
391
392        tree.run_idle_callbacks(std::time::Duration::from_millis(16));
393
394        assert!(called.get());
395        assert!(!tree.has_idle_work());
396    }
397
398    #[test]
399    fn set_locale_from_event_handler_is_parked_not_applied() {
400        let mut tree = WidgetTree::new();
401        let widget = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
402            ctx.set_locale("fr-FR");
403        }));
404        tree.layout(SizeProposal::exact(100.0, 50.0));
405        assert!(tree.locale().is_none());
406
407        tree.click(widget);
408
409        // The tree's own locale signal must NOT have been flipped — the
410        // app layer is responsible for routing the switch through
411        // `WindowManager::set_locale` so the `I18nManager`'s active
412        // locale, version signal, and RTL direction stay in sync.
413        assert_eq!(tree.locale(), None);
414        // The request is parked for the app layer to drain.
415        assert_eq!(
416            tree.take_pending_locale_request(),
417            Some("fr-FR".to_string())
418        );
419        // Drained exactly once.
420        assert_eq!(tree.take_pending_locale_request(), None);
421    }
422
423    #[test]
424    fn set_theme_from_event_handler_is_parked_not_applied() {
425        use crate::ThemeAppearance;
426
427        let mut tree = WidgetTree::new();
428        // `WidgetTree::new()` starts on the light preset.
429        assert_eq!(tree.theme().appearance, ThemeAppearance::Light);
430
431        let widget = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
432            ctx.set_theme(crate::presets::intui::dark());
433        }));
434        tree.layout(SizeProposal::exact(100.0, 50.0));
435
436        tree.click(widget);
437
438        // The tree's own theme must NOT have been flipped inline — the app
439        // layer routes the switch through `WindowManager::set_theme` so it
440        // fans out to *every* window, not just this one.
441        assert_eq!(tree.theme().appearance, ThemeAppearance::Light);
442        // The request is parked for the app layer to drain.
443        let parked = tree.take_pending_theme_request();
444        assert_eq!(parked.map(|t| t.appearance), Some(ThemeAppearance::Dark));
445        // Drained exactly once.
446        assert!(tree.take_pending_theme_request().is_none());
447    }
448
449    #[test]
450    fn idle_deadline_provides_time_budget() {
451        let deadline = crate::idle::IdleDeadline::new(std::time::Duration::from_millis(100));
452        assert!(!deadline.did_timeout());
453        assert!(deadline.time_remaining() > std::time::Duration::ZERO);
454    }
455
456    #[test]
457    fn modal_request_requested_from_event_handler() {
458        let mut tree = WidgetTree::new();
459        let content = tree.add(FillWidget::new().label("Modal content"));
460        let trigger = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
461            ctx.present_modal(
462                ModalRequest::in_tree(content)
463                    .presentation(ModalPresentation::InTree)
464                    .close_behavior(ModalCloseBehavior::Manual),
465            );
466        }));
467        tree.layout(SizeProposal::exact(100.0, 50.0));
468
469        assert!(!tree.has_pending_modal_requests());
470
471        tree.click(trigger);
472
473        assert!(tree.has_pending_modal_requests());
474        let requests = tree.drain_pending_modal_requests();
475        assert_eq!(requests.len(), 1);
476        assert_eq!(requests[0].source_widget, trigger);
477        assert_eq!(requests[0].request.presentation, ModalPresentation::InTree);
478        assert_eq!(
479            requests[0].request.close_behavior,
480            ModalCloseBehavior::Manual
481        );
482        match requests[0].request.content {
483            ModalContent::ExistingWidget(id) => assert_eq!(id, content),
484            ModalContent::Deferred(_) => panic!("expected ExistingWidget content"),
485        }
486        assert!(!tree.has_pending_modal_requests());
487    }
488
489    #[test]
490    fn draining_modal_requests_clears_queue() {
491        let mut tree = WidgetTree::new();
492        let content = tree.add(FillWidget::new());
493        let trigger = tree.add(FillWidget::new().on_tap(move |_pos, ctx| {
494            ctx.present_modal(ModalRequest::in_tree(content));
495        }));
496        tree.layout(SizeProposal::exact(100.0, 50.0));
497
498        tree.click(trigger);
499        assert_eq!(tree.drain_pending_modal_requests().len(), 1);
500        assert!(tree.drain_pending_modal_requests().is_empty());
501    }
502
503    #[test]
504    fn dismiss_modal_closes_centered_overlay_for_source_widget() {
505        let mut tree = WidgetTree::new();
506        let trigger = tree.add(FillWidget::new().label("Trigger"));
507        let modal_content = tree.add(FixedWidget(120.0, 48.0).on_tap(|_pos, ctx| {
508            ctx.dismiss_modal();
509        }));
510        tree.layout(SizeProposal::exact(320.0, 200.0));
511
512        tree.show_overlay(OverlayRequest::new(
513            modal_content,
514            trigger,
515            OverlayPlacement::Centered,
516            DismissBehavior::Manual,
517        ));
518        tree.layout(SizeProposal::exact(320.0, 200.0));
519
520        assert_eq!(tree.active_overlays().len(), 1);
521
522        let center = tree
523            .overlay_manager()
524            .topmost_centered()
525            .expect("expected centered modal overlay")
526            .bounds
527            .center();
528        tree.pointer_down_button(center, PointerButton::Primary);
529        tree.pointer_up_button(center, PointerButton::Primary);
530
531        assert!(tree.active_overlays().is_empty());
532        assert!(!tree.has_pending_modal_dismissal());
533    }
534
535    #[test]
536    fn dismiss_modal_without_in_tree_modal_queues_window_dismissal() {
537        let mut tree = WidgetTree::new();
538        let trigger = tree.add(FillWidget::new().on_tap(|_pos, ctx| {
539            ctx.dismiss_modal();
540        }));
541        tree.layout(SizeProposal::exact(100.0, 50.0));
542
543        assert!(!tree.has_pending_modal_dismissal());
544
545        tree.click(trigger);
546
547        assert!(tree.has_pending_modal_dismissal());
548        assert!(tree.drain_pending_modal_dismissal());
549        assert!(!tree.has_pending_modal_dismissal());
550    }
551}