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}