agg_gui/widget/app.rs
1use super::*;
2
3mod gesture;
4mod keyboard;
5mod pointer;
6mod touch;
7mod tree_paths;
8use tree_paths::{collect_focusable, widget_at_path, widget_at_path_ref};
9
10// ---------------------------------------------------------------------------
11// App — top-level owner of the widget tree
12// ---------------------------------------------------------------------------
13
14/// Owns the widget tree, handles focus, and converts OS events to Y-up coords.
15///
16/// Create with [`App::new`], call [`App::layout`] every frame before
17/// [`App::paint`], and feed OS events through the `on_*` methods.
18pub struct App {
19 root: Box<dyn Widget>,
20 /// Current focus path (indices from root into children vec).
21 /// `None` means no widget has focus.
22 focus: Option<Vec<usize>>,
23 /// Path to the widget last seen under the cursor (for hover clearing).
24 hovered: Option<Vec<usize>>,
25 /// Mouse-captured widget path. Set when a widget consumes `MouseDown`;
26 /// cleared on `MouseUp`. While set, `MouseMove` events go to the captured
27 /// widget regardless of cursor position — enabling slider drag-outside-bounds.
28 captured: Option<Vec<usize>>,
29 /// Gesture-captured widget path. Set on the frame a multi-touch gesture
30 /// begins if the widget under the initial centroid consumes
31 /// `Event::MultiTouch`; the whole gesture then routes there (capture
32 /// semantics) even as the centroid drifts. Cleared when the aggregate
33 /// returns to `None`. See [`super::app::gesture`].
34 gesture_captured: Option<Vec<usize>>,
35 /// Whether last frame's aggregate was `Some` — lets the gesture router
36 /// detect the `None`→`Some` start edge (when it hit-tests) versus an
37 /// ongoing gesture (when it delivers to the captured path only).
38 gesture_in_progress: bool,
39 /// Viewport height in pixels — used for Y-down → Y-up conversion.
40 viewport_height: f64,
41 /// Viewport size in logical pixels from the most recent layout pass.
42 viewport_size: Size,
43 /// Optional legacy key handler called after widget-tree dispatch.
44 /// Returns `true` if the key was handled.
45 global_key_handler: Option<Box<dyn FnMut(Key, Modifiers) -> bool>>,
46 /// Multi-touch gesture recogniser. Platform shells feed raw touches
47 /// through [`App::on_touch_start/move/end/cancel`]; widgets read the
48 /// per-frame aggregate via [`crate::current_multi_touch`].
49 touch_state: crate::touch_state::TouchState,
50 /// Primary-finger mouse emulation (tap = left click, drag =
51 /// middle-drag pan). Fed by the same `on_touch_*` entry points;
52 /// its commands are replayed through `on_mouse_*` so shells only
53 /// ever forward raw touches. See [`crate::touch_emulation`].
54 touch_mouse_emu: crate::touch_emulation::TouchMouseEmu,
55 /// Last `async_state_epoch` `App::paint` observed. At the top of
56 /// each paint, if the current epoch differs we explicitly mark
57 /// every widget dirty via `mark_subtree_dirty`, so a freshly-
58 /// loaded image (or any other async result that landed outside
59 /// the event-dispatch dirty-propagation path) lands in newly-
60 /// rasterised retained backbuffers, not the previous frame's
61 /// stale FBO contents.
62 last_async_state_epoch: u64,
63}
64
65impl App {
66 /// Create a new `App` with `root` as the root widget.
67 pub fn new(root: Box<dyn Widget>) -> Self {
68 Self {
69 root,
70 focus: None,
71 hovered: None,
72 captured: None,
73 gesture_captured: None,
74 gesture_in_progress: false,
75 viewport_height: 1.0,
76 viewport_size: Size::new(1.0, 1.0),
77 global_key_handler: None,
78 touch_state: crate::touch_state::TouchState::new(),
79 touch_mouse_emu: crate::touch_emulation::TouchMouseEmu::new(),
80 last_async_state_epoch: 0,
81 }
82 }
83
84 /// Access the root widget — used by tests and inspectors that need to
85 /// introspect the laid-out tree without re-routing events through the
86 /// full dispatch machinery. Pair with [`find_widget_by_id`] to locate
87 /// a specific widget by its `Widget::id()` (e.g. a Window's title).
88 pub fn root(&self) -> &dyn Widget {
89 self.root.as_ref()
90 }
91
92 /// Mutable counterpart to [`root`]. Required when a test wants to
93 /// drive a specific sub-widget directly (e.g. reading ScrollView
94 /// scroll offset) after the App has routed an event.
95 pub fn root_mut(&mut self) -> &mut dyn Widget {
96 self.root.as_mut()
97 }
98
99 /// Return the type name of the currently focused widget, if any.
100 pub fn focused_widget_type_name(&self) -> Option<&'static str> {
101 self.focus
102 .as_deref()
103 .map(|path| widget_at_path_ref(self.root.as_ref(), path).type_name())
104 }
105
106 /// Whether the focused widget accepts typed text (a `TextField`, `TextArea`,
107 /// `RichTextEdit`, or a `DragValue` in its edit mode).
108 ///
109 /// Web shells use this to decide when to focus the hidden DOM `<textarea>`
110 /// that backs IME composition *and* clipboard events: without it focused,
111 /// the browser never delivers `copy` / `cut` / `paste` events to a
112 /// canvas-only app, so the in-canvas editors get no clipboard bridge.
113 ///
114 /// Delegates straight to [`Widget::accepts_text_input`] on the focused
115 /// widget rather than matching type-name strings, so any new editor that
116 /// overrides the trait is enrolled automatically (a hardcoded name list
117 /// would silently miss it and reintroduce exactly the wasm clipboard bug).
118 pub fn focused_is_text_input(&self) -> bool {
119 self.focus
120 .as_deref()
121 .is_some_and(|path| widget_at_path_ref(self.root.as_ref(), path).accepts_text_input())
122 }
123
124 /// Register a legacy global key handler invoked only after the widget tree
125 /// has ignored the key. Prefer widget-owned key handling for new behavior.
126 ///
127 /// # Example
128 /// ```ignore
129 /// app.set_global_key_handler(|key, mods| {
130 /// if mods.ctrl && mods.shift && key == Key::O {
131 /// organize_windows();
132 /// return true;
133 /// }
134 /// false
135 /// });
136 /// ```
137 pub fn set_global_key_handler(
138 &mut self,
139 handler: impl FnMut(Key, Modifiers) -> bool + 'static,
140 ) {
141 self.global_key_handler = Some(Box::new(handler));
142 }
143
144 /// Lay out the widget tree to fill `viewport`. `viewport` is in **physical
145 /// pixels** (e.g. `window.inner_size()` on native, `canvas.width/height` on
146 /// wasm); this method divides by the current device scale factor so the
147 /// widget tree lays out in logical (device-independent) units. Call once
148 /// per frame before [`paint`][Self::paint].
149 pub fn layout(&mut self, viewport: Size) {
150 // Effective scale combines hardware DPR with the UX zoom
151 // factor — mobile platforms set ux_scale ≈ 1.7 so widgets at
152 // their natural logical size read comfortably at arm's length.
153 let scale = crate::ux_scale::effective_scale().max(1e-6);
154 let logical = Size::new(viewport.width / scale, viewport.height / scale);
155 self.viewport_height = logical.height;
156 self.viewport_size = logical;
157 set_current_viewport(logical);
158 // Fresh safe-area for this frame. The on-screen keyboard is the
159 // one library-owned edge obstruction, so it reserves its strip
160 // here; app chrome (rails, trays) reserves via
161 // `widgets::ReserveInset` during the tree layout below.
162 crate::overlay_insets::begin_frame();
163 if crate::widgets::on_screen_keyboard::is_visible() {
164 crate::overlay_insets::reserve(crate::layout_props::Insets {
165 bottom: crate::widgets::on_screen_keyboard::target_panel_height(logical.width),
166 ..crate::layout_props::Insets::default()
167 });
168 }
169 self.root
170 .set_bounds(Rect::new(0.0, 0.0, logical.width, logical.height));
171 self.root.layout(logical);
172 self.apply_pending_focus();
173 // Re-evaluate the keyboard-avoidance lift against FRESH bounds
174 // (see `keyboard_scroll::relift_after_layout` for the why).
175 crate::widget::keyboard_scroll::relift_after_layout(
176 self.focus.as_deref(),
177 logical.width,
178 self.root.as_mut(),
179 );
180 }
181
182 /// Service a pending programmatic focus request
183 /// ([`crate::focus::request_focus`]). Runs at the end of [`layout`] so the
184 /// tree (and thus the set of focusable widgets) reflects any visibility
185 /// change made in the same handler that requested focus. Moves focus to
186 /// the focusable widget whose [`Widget::focus_id`] matches; no-op when
187 /// there's no request or no match.
188 fn apply_pending_focus(&mut self) {
189 let Some(id) = crate::focus::take_focus_request() else {
190 return;
191 };
192 let mut all: Vec<Vec<usize>> = Vec::new();
193 collect_focusable(self.root.as_ref(), &mut Vec::new(), &mut all);
194 let target = all
195 .into_iter()
196 .find(|p| widget_at_path_ref(self.root.as_ref(), p).focus_id() == Some(id));
197 if let Some(path) = target {
198 self.set_focus(Some(path));
199 }
200 }
201
202 /// Paint the entire widget tree into `ctx`. Call after [`layout`][Self::layout].
203 ///
204 /// Applies a `ctx.scale(dps, dps)` transform up-front so the whole tree —
205 /// widget dimensions, font sizes, margins — is rendered at physical pixel
206 /// density on HiDPI screens without any widget having to know about DPI.
207 ///
208 /// Also clears the immediate draw flag so widgets can re-request it during
209 /// this paint if they need another frame; hosts read [`wants_draw`]
210 /// after `paint` returns to decide whether to schedule continuous draws.
211 pub fn paint(&mut self, ctx: &mut dyn DrawCtx) {
212 crate::animation::clear_draw_request();
213 // Async-state dirty walk: an image load (or other async source)
214 // that finished outside event dispatch bumped
215 // `async_state_epoch`. Walk the whole tree and mark every
216 // widget dirty so retained backbuffers re-rasterise on this
217 // frame — without this, the freshly-decoded pixels would land
218 // inside a Window FBO whose cache check sees no other change
219 // and composites the previous frame's stale bitmap. The
220 // explicit walk replaces a brittle "compare an extra epoch
221 // inside every cache" mechanism with a single deterministic
222 // hook at the start of paint.
223 let async_epoch = crate::animation::async_state_epoch();
224 if async_epoch != self.last_async_state_epoch {
225 tree::mark_subtree_dirty(self.root.as_mut());
226 self.last_async_state_epoch = async_epoch;
227 }
228 let viewport = self.viewport_size;
229 crate::widgets::combo_box::begin_combo_popup_frame(viewport);
230 crate::widgets::tooltip::begin_tooltip_frame(viewport);
231 // Central tooltip pass: find the deepest hovered widget carrying a
232 // `with_tooltip` string and drive the app-wide tip state machine. Any
233 // resulting tip is submitted to the tooltip queue and painted below in
234 // the global-overlay drain, above all clips.
235 self.drive_tooltip_controller();
236 // Recompute the multi-touch aggregate once per paint and publish
237 // to the thread-local — widgets read it during `on_event` or
238 // `paint` without an explicit `&App` reference.
239 self.touch_state.update_gesture();
240 crate::touch_state::set_current(self.touch_state.current());
241 // Route the aggregate as a captured `Event::MultiTouch` before the
242 // paint traversal runs: a consuming widget marks its cached window
243 // subtree dirty (via the normal Consumed → request_draw → epoch-bump
244 // → mark_dirty chain), so the fold it applied here re-rasters this
245 // same frame. See `super::app::gesture`.
246 self.dispatch_gesture();
247 // Tick the keyboard-driven lift once per paint. Translates
248 // the widget tree (and its global overlays) upward by `lift`
249 // pixels so a focused field doesn't disappear behind the
250 // soft-keyboard panel; the panel itself paints unlifted so
251 // it always sits at the bottom of the viewport.
252 let lift = super::keyboard_scroll::tick_lift();
253 // Use the combined device-DPR × UX-zoom scale so widgets at
254 // their natural logical size render at the right physical pixel
255 // count *and* at a comfortable on-screen footprint.
256 let scale = crate::ux_scale::effective_scale();
257 if (scale - 1.0).abs() > 1e-6 {
258 ctx.save();
259 ctx.scale(scale, scale);
260 super::keyboard_scroll::paint_lifted_tree(self.root.as_mut(), ctx, viewport, lift);
261 crate::widgets::on_screen_keyboard::paint_software_keyboard(ctx, viewport);
262 ctx.restore();
263 } else {
264 super::keyboard_scroll::paint_lifted_tree(self.root.as_mut(), ctx, viewport, lift);
265 crate::widgets::on_screen_keyboard::paint_software_keyboard(ctx, viewport);
266 }
267 }
268
269 /// After a paint pass, returns `true` if any widget requested another frame
270 /// (e.g. an in-progress hover animation). Hosts should use this to set
271 /// their event-loop control flow to continuous polling while it's `true`.
272 ///
273 /// Combines the visibility-gated tree-walk signal ([`Widget::needs_draw`])
274 /// with the immediate draw request flag ([`crate::animation::wants_draw`]).
275 /// Widgets call `request_draw` for ordinary visual invalidation; scheduled
276 /// draw needs such as cursor blink should use `needs_draw` /
277 /// `next_draw_deadline` so hidden subtrees do not keep the loop awake.
278 pub fn wants_draw(&self) -> bool {
279 self.root.needs_draw()
280 || crate::animation::wants_draw()
281 || crate::widgets::on_screen_keyboard::needs_draw()
282 || super::keyboard_scroll::is_lift_animating()
283 }
284
285 /// Pump pending synthetic keys back through [`Self::on_key_down`]
286 /// AND apply any pending dismiss request — the close key on the
287 /// keyboard panel clears focus, which then drops the
288 /// keyboard-aware screen lift via `notify_focus_change`.
289 fn drain_keyboard_synthetic_keys(&mut self) {
290 let pending = crate::widgets::on_screen_keyboard::drain_synthetic_keys();
291 for (key, mods) in pending {
292 self.on_key_down(key, mods);
293 }
294 if crate::widgets::on_screen_keyboard::take_dismiss_request() {
295 self.set_focus(None);
296 }
297 }
298
299 /// Test-only mirror of the end-of-event-loop drain.
300 #[cfg(test)]
301 pub fn drain_keyboard_events_for_test(&mut self) {
302 self.drain_keyboard_synthetic_keys();
303 }
304
305 /// Earliest scheduled draw deadline across the visible widget tree.
306 /// Hosts translate `Some(t)` into `ControlFlow::WaitUntil(t)` so that
307 /// e.g. a text field's cursor blink wakes the loop exactly at the flip
308 /// boundary. Invisible subtrees contribute nothing.
309 pub fn next_draw_deadline(&self) -> Option<web_time::Instant> {
310 // Two schedule channels feed the host's WaitUntil: per-widget
311 // deadlines (cursor blink) from the tree walk, and the global
312 // `animation::request_draw_after` thread-local. Both are read
313 // non-destructively so a host can re-arm `WaitUntil` idempotently
314 // every idle iteration — an intervening non-repainting event can no
315 // longer strand the scheduled wake. A due global deadline surfaces
316 // separately through `animation::wants_draw`. Serve the earliest.
317 let widget = self.root.next_draw_deadline();
318 let scheduled = crate::animation::peek_next_draw_deadline();
319 match (widget, scheduled) {
320 (Some(a), Some(b)) => Some(a.min(b)),
321 (deadline, None) | (None, deadline) => deadline,
322 }
323 }
324
325 // --- Platform event ingestion ---
326 //
327 // Hosts pass raw physical-pixel coordinates (e.g. `e.clientX * devicePixelRatio`
328 // in wasm, or `WindowEvent::CursorMoved.position` on native). These methods
329 // divide by the current device scale factor and flip Y so widget code sees
330 // logical Y-up coordinates matching the layout pass.
331
332 /// Mouse wheel scrolled. `screen_y` is Y-down. Convention matches
333 /// `winit` / `WheelEvent`: positive `delta_y` = wheel rotated
334 /// forward = user wants to see content ABOVE the current view.
335 /// Scroll containers DECREASE their offset when `delta_y` is
336 /// positive. Positive `delta_x` = see content to the LEFT.
337 pub fn on_mouse_wheel(&mut self, screen_x: f64, screen_y: f64, delta_y: f64) {
338 self.on_mouse_wheel_xy_mods(screen_x, screen_y, 0.0, delta_y, Modifiers::default());
339 }
340
341 /// Mouse wheel with an explicit horizontal component (trackpad pan,
342 /// shift+wheel via the platform harness).
343 pub fn on_mouse_wheel_xy(&mut self, screen_x: f64, screen_y: f64, delta_x: f64, delta_y: f64) {
344 self.on_mouse_wheel_xy_mods(screen_x, screen_y, delta_x, delta_y, Modifiers::default());
345 }
346
347 /// Mouse wheel with explicit horizontal component and modifier state.
348 pub fn on_mouse_wheel_xy_mods(
349 &mut self,
350 screen_x: f64,
351 screen_y: f64,
352 delta_x: f64,
353 delta_y: f64,
354 modifiers: Modifiers,
355 ) {
356 let pos = super::keyboard_scroll::lift_to_world(self.flip_y(screen_x, screen_y));
357 set_current_mouse_world(pos);
358 let hit = active_modal_path(self.root.as_ref())
359 .map(|path| self.extend_modal_path(&path, pos))
360 .or_else(|| self.compute_hit(pos));
361 let event = Event::MouseWheel {
362 pos,
363 delta_y,
364 delta_x,
365 modifiers,
366 };
367 if let Some(path) = hit {
368 dispatch_event(&mut self.root, &path, &event, pos);
369 }
370 }
371
372 /// Snapshot the entire widget tree for the inspector.
373 pub fn collect_inspector_nodes(&self) -> Vec<InspectorNode> {
374 let mut out = Vec::new();
375 collect_inspector_nodes(self.root.as_ref(), 0, Point::ORIGIN, &mut out);
376 out
377 }
378
379 /// `true` while a widget is actively capturing the pointer — i.e. the
380 /// user is mid-drag (a window edge, slider thumb, scrollbar, etc.).
381 /// Used by the demo harness to throttle expensive per-frame snapshots
382 /// (the inspector tree walk) during interactions; the snapshot can
383 /// safely defer until the user releases without changing the visible
384 /// outcome (the underlying widget tree topology doesn't change during
385 /// a drag, only the widgets' bounds).
386 pub fn has_captured_pointer(&self) -> bool {
387 self.captured.is_some()
388 }
389
390 /// Serialize the widget tree — types, bounds, depth, properties — as JSON.
391 ///
392 /// Produces a flat array of nodes in paint-order DFS. Suitable for writing
393 /// to a file and diffing between runs to verify layout stability. Used by
394 /// the demo harness's debug hotkey.
395 pub fn dump_tree_json(&self) -> String {
396 let nodes = self.collect_inspector_nodes();
397 let mut s = String::from("[\n");
398 for (i, n) in nodes.iter().enumerate() {
399 let props_json = n
400 .properties
401 .iter()
402 .map(|(k, v)| format!("{:?}: {:?}", k, v))
403 .collect::<Vec<_>>()
404 .join(", ");
405 s.push_str(&format!(
406 " {{\"type\":{:?},\"depth\":{},\"x\":{:.2},\"y\":{:.2},\"w\":{:.2},\"h\":{:.2},\"props\":{{{}}}}}",
407 n.type_name, n.depth,
408 n.screen_bounds.x, n.screen_bounds.y,
409 n.screen_bounds.width, n.screen_bounds.height,
410 props_json,
411 ));
412 if i + 1 < nodes.len() {
413 s.push(',');
414 }
415 s.push('\n');
416 }
417 s.push(']');
418 s
419 }
420
421 /// Returns `true` if any widget currently holds keyboard focus.
422 /// Used by the render loop to schedule cursor-blink repaints.
423 pub fn has_focus(&self) -> bool {
424 self.focus.is_some()
425 }
426
427 /// Call when the cursor leaves the window to clear hover state.
428 pub fn on_mouse_leave(&mut self) {
429 crate::cursor::reset_cursor_icon();
430 self.dispatch_mouse_move(Point::new(-1.0, -1.0));
431 }
432
433 /// Native drag-and-drop landed `paths` on the window at the given
434 /// screen position. Dispatches an [`Event::FileDropped`] to the
435 /// widget under the cursor (same hit-test path as `on_mouse_down`),
436 /// so a widget can opt in by handling the event in `on_event`.
437 ///
438 /// Native shells typically receive one path per `DroppedFile` event
439 /// from winit; they may forward each separately, or batch a single
440 /// drag gesture into one call. The widget receives `paths` as-is.
441 pub fn on_file_dropped(
442 &mut self,
443 screen_x: f64,
444 screen_y: f64,
445 paths: Vec<std::path::PathBuf>,
446 ) {
447 if paths.is_empty() {
448 return;
449 }
450 let pos = super::keyboard_scroll::lift_to_world(self.flip_y(screen_x, screen_y));
451 let event = Event::FileDropped { pos, paths };
452 let hit = self.compute_hit(pos);
453 let consumed = match hit {
454 Some(path) => dispatch_event(&mut self.root, &path, &event, pos),
455 // No hit target: dispatch to the root anyway so app-level
456 // handlers (e.g. "open the dropped .atmr project") can run
457 // even when the user drops on chrome rather than canvas.
458 None => dispatch_event(&mut self.root, &[], &event, pos),
459 }
460 .is_consumed();
461 if !consumed {
462 // The widget under the drop point ignored the files. Offer
463 // the event to the rest of the tree before giving up — the
464 // reported position is often wrong through no fault of the
465 // user (winit's Windows backend discards the OLE drop point
466 // and emits no CursorMoved during the drag, so shells fall
467 // back to the last pre-drag cursor position). A drop must
468 // find the app's file handler even when it "lands" on
469 // chrome or a sibling pane.
470 super::tree::dispatch_event_broadcast(&mut self.root, &event, pos);
471 }
472 crate::animation::request_draw();
473 }
474
475 // --- Touch ingestion ---
476 //
477 // Raw touches go into the multi-touch gesture recogniser; widgets
478 // read `current_multi_touch()` each frame. The primary finger is
479 // ALSO replayed through the `on_mouse_*` entry points by the
480 // core-owned `TouchMouseEmu` (see `crate::touch_emulation`), so
481 // widgets that only understand mouse input keep working and
482 // platform shells forward raw touches only. Coordinates are the
483 // same physical-pixel Y-down units the mouse entry points accept.
484 // --- Private helpers ---
485
486 /// If the click path passes through a `Window` widget, move that window to
487 /// the end of its parent's children list so it paints on top of siblings.
488 /// All stored paths (focus, hovered, captured, plus the clicked path itself)
489 /// are updated to reflect the new index.
490 fn maybe_bring_to_front(&mut self, clicked_path: &mut Vec<usize>) {
491 // Walk the clicked path and record the deepest Window encountered.
492 // At each step we descend into children[idx]; after descending, if the
493 // new node is a Window we record (parent_path, win_idx). We keep
494 // scanning so a nested Window (unlikely but possible) wins.
495 let mut node: &dyn Widget = self.root.as_ref();
496 let mut window_info: Option<(Vec<usize>, usize)> = None; // (parent_path, win_idx)
497 for (depth, &idx) in clicked_path.iter().enumerate() {
498 let children = node.children();
499 if idx >= children.len() {
500 break;
501 }
502 node = &*children[idx];
503 if node.type_name() == "Window" {
504 // parent_path = clicked_path[..depth], win_idx = idx
505 window_info = Some((clicked_path[..depth].to_vec(), idx));
506 }
507 }
508
509 let (parent_path, win_idx) = match window_info {
510 Some(x) => x,
511 None => return,
512 };
513
514 // Check there's actually a sibling to leapfrog.
515 let n = {
516 let parent = widget_at_path(&mut self.root, &parent_path);
517 parent.children().len()
518 };
519 if win_idx >= n - 1 {
520 return;
521 } // already at front
522
523 // Move the window to the end of its parent's children (mutable pass).
524 {
525 let parent = widget_at_path(&mut self.root, &parent_path);
526 let child = parent.children_mut().remove(win_idx);
527 parent.children_mut().push(child);
528 }
529 let new_idx = n - 1;
530 let depth = parent_path.len(); // depth at which the window index sits
531
532 // Update any stored path whose element at `depth` was affected by the move.
533 fn shift_path(p: &mut Vec<usize>, depth: usize, old: usize, new: usize) {
534 if p.len() > depth {
535 let i = p[depth];
536 if i == old {
537 p[depth] = new;
538 } else if i > old && i <= new {
539 // Siblings that were after the removed window shift left by 1.
540 p[depth] -= 1;
541 }
542 }
543 }
544 shift_path(clicked_path, depth, win_idx, new_idx);
545 if let Some(ref mut p) = self.focus {
546 shift_path(p, depth, win_idx, new_idx);
547 }
548 if let Some(ref mut p) = self.hovered {
549 shift_path(p, depth, win_idx, new_idx);
550 }
551 if let Some(ref mut p) = self.captured {
552 shift_path(p, depth, win_idx, new_idx);
553 }
554 }
555
556 #[inline]
557 /// Convert a platform-supplied physical Y-down coordinate into the
558 /// logical Y-up SCREEN space (unlifted). Global overlays such as
559 /// the on-screen keyboard panel test against this; widget-tree
560 /// dispatch then calls
561 /// [`keyboard_scroll::lift_to_world`](super::keyboard_scroll::lift_to_world)
562 /// to drop into the lifted frame.
563 fn flip_y(&self, x: f64, y_down: f64) -> Point {
564 // Same effective scale used for layout / paint so event coords
565 // arrive in the same logical space the widget tree was laid out in.
566 let scale = crate::ux_scale::effective_scale().max(1e-6);
567 let lx = x / scale;
568 let ly_down = y_down / scale;
569 Point::new(lx, self.viewport_height - ly_down)
570 }
571
572 fn compute_hit(&self, pos: Point) -> Option<Vec<usize>> {
573 global_overlay_hit_path(self.root.as_ref(), pos)
574 .or_else(|| hit_test_subtree(self.root.as_ref(), pos))
575 }
576
577 /// Feed the central tooltip controller from the current hover path: the
578 /// deepest hovered widget whose [`Widget::tooltip_text`] is `Some` supplies
579 /// the tip (its index-path identity + text), anchored at the pointer.
580 fn drive_tooltip_controller(&self) {
581 let target = self
582 .hovered
583 .as_deref()
584 .and_then(|path| deepest_tipped(self.root.as_ref(), path));
585 crate::widgets::tooltip::controller::drive(target, current_mouse_world());
586 }
587
588 /// Test hook: run the central tooltip pass without a full paint (no
589 /// `DrawCtx` needed). Mirrors what [`paint`](Self::paint) does after
590 /// `begin_tooltip_frame`, so a test can drive hover + clock + this and then
591 /// assert controller state.
592 #[cfg(test)]
593 pub fn update_tooltips_for_test(&self) {
594 crate::widgets::tooltip::begin_tooltip_frame(self.viewport_size);
595 self.drive_tooltip_controller();
596 }
597
598 fn dispatch_mouse_move(&mut self, pos: Point) {
599 let new_hit = self.compute_hit(pos);
600
601 // If the hovered widget changed, clear the old one — but skip the clear
602 // event when the old widget still has mouse capture (it should keep
603 // receiving real positions, not a (-1,-1) sentinel that snaps state).
604 if new_hit != self.hovered {
605 if let Some(old_path) = self.hovered.take() {
606 let is_captured = self.captured.as_ref() == Some(&old_path);
607 if !is_captured {
608 let clear = Event::MouseMove {
609 pos: Point::new(-1.0, -1.0),
610 };
611 dispatch_event(&mut self.root, &old_path, &clear, Point::new(-1.0, -1.0));
612 }
613 }
614 self.hovered = new_hit.clone();
615 }
616
617 let event = Event::MouseMove { pos };
618 if let Some(ref cap_path) = self.captured.clone() {
619 // Captured widget always receives the real position, regardless of
620 // whether the cursor is over it — this is what keeps a slider
621 // tracking the cursor when dragged outside its bounds.
622 dispatch_event(&mut self.root, cap_path, &event, pos);
623 } else if let Some(path) = new_hit {
624 dispatch_event(&mut self.root, &path, &event, pos);
625 }
626 }
627
628 /// Set focus to `new_path`, sending `FocusLost` / `FocusGained` as needed.
629 fn set_focus(&mut self, new_path: Option<Vec<usize>>) {
630 if self.focus == new_path {
631 return;
632 }
633 if let Some(old) = self.focus.take() {
634 dispatch_event(&mut self.root, &old, &Event::FocusLost, Point::ORIGIN);
635 }
636 self.focus = new_path.clone();
637 if let Some(new) = new_path.clone() {
638 dispatch_event(&mut self.root, &new, &Event::FocusGained, Point::ORIGIN);
639 }
640 super::keyboard_scroll::notify_focus_change(
641 new_path.as_deref(),
642 self.viewport_size.width,
643 self.root.as_mut(),
644 );
645 }
646
647 /// Lift the focused widget above the on-screen keyboard panel so
648 /// typing never disappears behind it. No-op when already visible.
649 pub fn ensure_focused_visible_above_keyboard(&mut self) {
650 super::keyboard_scroll::ensure_focused_visible_above_keyboard(
651 self.focus.as_deref(),
652 self.viewport_size.width,
653 self.root.as_mut(),
654 );
655 }
656
657 /// Focus the first focusable widget in paint order.
658 ///
659 /// Apps whose root is a game/canvas widget need this at startup:
660 /// [`on_key_up`](Self::on_key_up) only routes to the focused widget
661 /// (there is no unconsumed-key fallback for releases), so held-key
662 /// state tracking breaks until the user clicks or tabs. Calling this
663 /// right after building the app gives that widget key delivery from
664 /// the first frame. No-op when nothing is focusable.
665 pub fn focus_first(&mut self) {
666 if self.focus.is_none() {
667 self.advance_focus(true);
668 }
669 }
670
671 /// Move focus to the next (or previous) focusable widget in paint order.
672 fn advance_focus(&mut self, forward: bool) {
673 let mut all: Vec<Vec<usize>> = Vec::new();
674 collect_focusable(self.root.as_ref(), &mut vec![], &mut all);
675 if all.is_empty() {
676 return;
677 }
678 let current_idx = self
679 .focus
680 .as_ref()
681 .and_then(|f| all.iter().position(|p| p == f));
682 let next_idx = match current_idx {
683 None => {
684 if forward {
685 0
686 } else {
687 all.len() - 1
688 }
689 }
690 Some(i) => {
691 if forward {
692 (i + 1) % all.len()
693 } else {
694 if i == 0 {
695 all.len() - 1
696 } else {
697 i - 1
698 }
699 }
700 }
701 };
702 let next_path = all[next_idx].clone();
703 self.set_focus(Some(next_path));
704 }
705}
706
707/// Walk the hover `path` from `root` and return the **deepest** widget along it
708/// whose [`Widget::tooltip_text`] is `Some`, as `(identity_path, text)`. The
709/// identity is the path prefix down to that widget, which the controller uses
710/// to detect target changes (moving to a different tipped control ⇒ reshow).
711/// A shallower tipped ancestor is used only when no deeper descendant has a tip.
712fn deepest_tipped(root: &dyn Widget, path: &[usize]) -> Option<(Vec<usize>, String)> {
713 let mut widget: &dyn Widget = root;
714 let mut best: Option<(usize, String)> = None;
715 if let Some(t) = widget.tooltip_text() {
716 best = Some((0, t.to_string()));
717 }
718 for (i, &idx) in path.iter().enumerate() {
719 let Some(child) = widget.children().get(idx) else {
720 break;
721 };
722 widget = child.as_ref();
723 if let Some(t) = widget.tooltip_text() {
724 best = Some((i + 1, t.to_string()));
725 }
726 }
727 best.map(|(depth, text)| (path[..depth].to_vec(), text))
728}