Skip to main content

agg_gui/widget/
app.rs

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