collective

Browse / github.com/storytold/craft-libs / patch

docking: over a native palette the main window loses the pointer, and gets the keyboard back when the pointer returns

docking: over a native palette the main window loses the pointer, and gets the keyboard back when the pointer returns Two related fixes for floating palettes in their own OS windows on macOS. Both were found in VectorCraft after its panels moved onto craft-ui's docking (storytold/vectorcraft#4135); neither happened before, when floating panels were drawn inside the main window. ## The main window loses the pointer over a native window macOS tells a window the pointer left only when the pointer leaves the window's frame. Over a native window in front of the main window, the main window got neither a leave nor any more moves. It kept its last pointer position, so VectorCraft's canvas cursor stayed drawn at

Status
open
Score
0.00
Adopters
0 other operators
Author
danngreen (imported from pull request #370, open)
Base
9ff719499d8f4730b93071abed993e5f12fe1be8
Size
+242 −0 in 3 files
Review
pending
Shared
2026-10-28
Id
62aab0142ae2aa4515945594

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui/src/docking/native_tests.rs crates/ui/src/docking/render/native.rs docs/ui-docking.md

diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs--- a/crates/ui/src/docking/native_tests.rs+++ b/crates/ui/src/docking/native_tests.rs@@ -1999,6 +1999,125 @@ fn blocking_input_hands_focus_from_a_native_window_to_the_main_window() {     assert_eq!(focus_main(&d), 0); } +#[test]+fn the_main_window_loses_the_pointer_while_a_native_window_has_it() {+    // macOS tells a window the pointer left only when it leaves the window's frame: over a+    // native window in front of it, the main window kept its last pointer, and an app's canvas+    // cursor stayed drawn at the native window's edge.+    let main_pointer = |d: &DockDriver<u32, Content>| d.ctx.input_for(egui::ViewportId::ROOT, |i| i.pointer.hover_pos());+    let mut d = driver(layout(true));+    let id = window(&d, 5).id;+    let main = pos2(50.0, 50.0);+    d.move_to(main);+    assert_eq!(main_pointer(&d), Some(main));+    d.window_pointer(id, ORIGIN + vec2(400.0, 200.0));+    d.step(vec![]);+    assert_eq!(main_pointer(&d), None, "the pointer is the native window's");+    // Back over the main window, it has the pointer again.+    d.move_to(main);+    assert_eq!(main_pointer(&d), Some(main));+    // The native window's input can tell of the move into it and of the pointer leaving it at+    // once: the pointer was there, so the main window is still told it left.+    let local = d.window_local(id, ORIGIN + vec2(400.0, 200.0)).unwrap();+    d.window_step(id, vec![Event::PointerMoved(local), Event::PointerGone]);+    d.step(vec![]);+    assert_eq!(main_pointer(&d), None, "the pointer moved over the native window");+}++/// AppKit's resize area reaches a few points past a palette's frame, over the main window:+/// there AppKit shows its resize cursor and takes a press, so the main window is told the+/// pointer left, as over the palette itself, except while a drag in the main window holds it.+#[cfg(target_os = "macos")]+#[test]+fn the_main_window_loses_the_pointer_over_a_palettes_outer_resize_area() {+    let main_pointer = |d: &DockDriver<u32, Content>| d.ctx.input_for(egui::ViewportId::ROOT, |i| i.pointer.hover_pos());+    let mut d = driver(layout(true));+    let viewport = d.viewport(window(&d, 5).id);+    d.step(vec![]);+    let rect = d.host().windows[&viewport].rect;+    // 2 pt left of the palette's left edge, in the main window's coordinates.+    let band = pos2(rect.left() - 2.0, rect.center().y) - ORIGIN.to_vec2();+    let main = pos2(50.0, 50.0);+    d.move_to(main);+    assert_eq!(main_pointer(&d), Some(main));+    d.move_to(band);+    d.step(vec![]);+    assert_eq!(main_pointer(&d), None, "AppKit's resize area is the palette's");+    d.move_to(main);+    assert_eq!(main_pointer(&d), Some(main));+    // Further out, the pointer stays the main window's.+    let beyond = band - vec2(6.0, 0.0);+    d.move_to(beyond);+    assert_eq!(main_pointer(&d), Some(beyond));+    // A drag in the main window keeps its moves over the resize area, also once another button+    // was pressed and released during it.+    d.press(main);+    let secondary = |pressed| Event::PointerButton { pos: main, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() };+    d.step(vec![secondary(true)]);+    d.step(vec![secondary(false)]);+    d.move_to(band);+    d.step(vec![]);+    assert_eq!(main_pointer(&d), Some(band));+    d.release(band);+}++#[test]+fn a_palette_hands_the_keyboard_back_when_the_pointer_comes_back_to_the_main_window() {+    // After a click in a palette, its OS window has the keyboard: the main window's tool cursor+    // and shortcuts waited for a click there. Once the pointer comes back from the palette to+    // the main window, the main window takes the keyboard, after the palette's text field is+    // done with it if one has it.+    let focus_main = |d: &DockDriver<u32, Content>| {+        d.host().commands.iter().filter(|(v, c)| *v == egui::ViewportId::ROOT && matches!(c, ViewportCommand::Focus)).count()+    };+    let palette = || {+        let mut d = driver(layout(true));+        let id = window(&d, 5).id;+        let viewport = d.viewport(id);+        d.host().focus = Some(viewport);+        d.window_pointer(id, ORIGIN + vec2(400.0, 200.0));+        d.step(vec![]);+        (d, id)+    };+    let (mut d, _) = palette();+    assert_eq!(focus_main(&d), 0, "the pointer is over the palette");+    d.move_to(pos2(50.0, 50.0));+    d.step(vec![]);+    assert_eq!(focus_main(&d), 1, "the pointer came back to the main window, once");+    // A text field of the palette keeps the keyboard until it is done.+    let (mut d, _) = palette();+    d.content.text = Some(5);+    d.content.focus_text = true;+    d.step(vec![]);+    d.move_to(pos2(50.0, 50.0));+    d.step(vec![]);+    assert_eq!(focus_main(&d), 0, "a field of the palette has the keyboard");+    d.content.blur_text = true;+    d.step(vec![]);+    d.step(vec![]);+    assert_eq!(focus_main(&d), 1, "the field is done");+    // A viewer is an ordinary window: it keeps the keyboard.+    let (mut d, _) = palette();+    d.content.normal = vec![5];+    d.step(vec![]);+    d.move_to(pos2(50.0, 50.0));+    d.step(vec![]);+    assert_eq!(focus_main(&d), 0, "a viewer has the keyboard");+    // A press in AppKit's resize area, which reaches past the palette's frame, focuses the+    // palette with the pointer still over the main window: the palette keeps the keyboard, or it+    // would not redraw while it resizes.+    let mut d = driver(layout(true));+    let viewport = d.viewport(window(&d, 5).id);+    d.move_to(pos2(296.0, 200.0));+    d.host().focus = Some(viewport);+    for _ in 0..3 {+        d.step(vec![]);+    }+    d.move_to(pos2(290.0, 200.0));+    d.step(vec![]);+    assert_eq!(focus_main(&d), 0, "the pointer never left the main window");+}+ #[test] fn blocking_input_cancels_a_drag_in_progress() {     // A tab drag a native window owns.diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs--- a/crates/ui/src/docking/render/native.rs+++ b/crates/ui/src/docking/render/native.rs@@ -60,6 +60,10 @@ pub(in crate::docking) const APPKIT_CORNER: f32 = 15.0; /// How far AppKit's edge resize area reaches into the content between the corners: a few /// points; most of it lies outside the window. const APPKIT_EDGE: f32 = 4.0;+/// How far AppKit's edge resize area reaches past the window's frame, over what lies behind+/// it: 3 pt resizes on macOS 26, 4 pt doesn't. Past a rounded corner it reaches about 1 pt,+/// which [`in_appkit_outer_band`] leaves out.+const APPKIT_OUTSIDE: f32 = 3.5;  /// Where the × of a native window's bar sits and where it takes clicks, in points from the /// window's top and trailing edges, mirrored in a right-to-left layout.@@ -662,6 +666,26 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {             if own && !blocked && ui.ctx().text_edit_focused() {                 let _ = body_input(ui.ctx()).lock().typing_next.insert(viewport);             }+            // A palette keeps the keyboard only while one of its text fields has it, as macOS+            // palettes do: once the pointer comes back from it to the main window, the main+            // window takes the keyboard, so its shortcuts and tool cursor work without a click+            // there. Only on that return: a press in AppKit's resize area, which reaches past the+            // palette's frame over the main window, focuses the palette with the pointer still the+            // main window's, and handing the keyboard back then kept the palette from redrawing+            // while it resized. While a text field has the keyboard, a button is held or a menu of+            // the palette is open, the hand-back waits. A viewer is an ordinary window: it keeps+            // the keyboard.+            if own+                && !normal+                && !blocked+                && ui.input(|i| i.viewport().focused == Some(true) && !i.pointer.any_down())+                && !ui.ctx().text_edit_focused()+                && !egui::Popup::is_any_open(ui.ctx())+                && !ui.ctx().input_for(ViewportId::ROOT, |i| i.pointer.any_down())+                && std::mem::take(&mut body_input(ui.ctx()).lock().pointer_returned)+            {+                ui.ctx().send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::Focus);+            }             self.cross.held |= ui.ctx().dragged_id().is_some_and(|id| self.owned_interactions.contains(&id));             if debug_close() {                 debug_outlines(ui, full, &buttons);@@ -672,6 +696,13 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {             if window.panels().all(|p| self.permissions(p).movable) {                 if OS_RESIZES {                     leave_cursor_to_os(ui, full);+                    // Where AppKit resizes the window from outside it, over the main window.+                    if !blocked && let Some(rect) = ui.input(|i| i.viewport().inner_rect) {+                        let body = body_input(ui.ctx());+                        let mut body = body.lock();+                        let pass = body.pass;+                        body.resize_bands.insert(viewport, (pass, rect));+                    }                 } else {                     resize_edges(ui, self.id.with(("native-edges", window.id)), full, &buttons);                 }@@ -1067,6 +1098,19 @@ pub(super) struct NativeBodyInput {     typing_at: u64,     /// Key and clipboard events for each window's next input, and the pass they were queued in.     keys: std::collections::HashMap<ViewportId, (u64, Vec<Event>)>,+    /// The native window the pointer moved over last, by its own input, since it last moved or+    /// pressed in the main window.+    pointer_window: Option<ViewportId>,+    /// The main window was told the pointer left it for `pointer_window`.+    root_pointer_gone: bool,+    /// The pointer came back to the main window from a native window while the main window had+    /// no keyboard: a palette with the keyboard hands it back.+    pointer_returned: bool,+    /// The desktop content rect of each native window AppKit resizes, and the main window's pass+    /// that drew it: its resize area reaches past it, over the main window.+    resize_bands: std::collections::HashMap<ViewportId, (u64, Rect)>,+    /// The buttons held in the main window, a bit each.+    root_held: u8, }  /// The app drag routing and routed events shared by every native input hook of `ctx`.@@ -1107,6 +1151,64 @@ impl egui::Plugin for NativeInput {         {             let mut body = body.lock();             let body = &mut *body;+            // macOS tells a window the pointer left it only when the pointer leaves its frame:+            // over a native window in front of the main window, the main window got no exit and+            // kept its last pointer (an app's canvas cursor stayed drawn at the window's edge).+            // The pointer is the native window's once it moves there, and the main window is told+            // it left, until it moves or presses in the main window again.+            if viewport == ViewportId::ROOT {+                if input.focused {+                    body.pointer_returned = false;+                }+                // AppKit's resize area of a palette reaches a few points past its frame: there+                // the main window gets the moves, but AppKit shows its resize cursor and takes a+                // press, so the pointer is the palette's. Not while a button is held in the main+                // window: a drag there keeps its moves.+                let pass = body.pass;+                body.resize_bands.retain(|_, (at, _)| *at + 1 >= pass);+                for event in &input.events {+                    if let Event::PointerButton { button, pressed, .. } = event {+                        let bit = 1 << (*button as u8);+                        if *pressed {+                            body.root_held |= bit;+                        } else {+                            body.root_held &= !bit;+                        }+                    }+                }+                let origin = input.viewports.get(&ViewportId::ROOT).and_then(|info| info.inner_rect).map(|r| r.min.to_vec2());+                let last_move = input.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None });+                let pressing = input.events.iter().any(|e| matches!(e, Event::PointerButton { .. }));+                if let (Some(origin), Some(pos), false, false) = (origin, last_move, pressing, body.root_held != 0)+                    && let Some(window) = body.resize_bands.iter().find(|(_, (_, rect))| in_appkit_outer_band(*rect, pos + origin)).map(|(v, _)| *v)+                {+                    input.events.retain(|e| !matches!(e, Event::PointerMoved(_)));+                    if body.pointer_window != Some(window) {+                        body.pointer_window = Some(window);+                        body.root_pointer_gone = false;+                        body.pointer_returned = false;+                    }+                }+                if input.events.iter().any(|e| matches!(e, Event::PointerMoved(_) | Event::PointerButton { .. })) {+                    body.pointer_returned |= body.pointer_window.take().is_some() && !input.focused;+                    body.root_pointer_gone = false;+                } else if body.pointer_window.is_some() && !body.root_pointer_gone {+                    input.events.push(Event::PointerGone);+                    body.root_pointer_gone = true;+                }+            } else {+                for event in &input.events {+                    match event {+                        Event::PointerMoved(_) if body.pointer_window != Some(viewport) => {+                            body.pointer_window = Some(viewport);+                            body.root_pointer_gone = false;+                            body.pointer_returned = false;+                            ctx.request_repaint_of(ViewportId::ROOT);+                        }+                        _ => {}+                    }+                }+            }             if viewport == ViewportId::ROOT {                 body.pass += 1;                 // Every window draws in the main window's pass: events for one that has not@@ -1389,6 +1491,12 @@ fn leave_cursor_to_os(ui: &Ui, full: Rect) {     } } +/// Whether desktop point `p` is in AppKit's resize area outside the window whose content is+/// `rect`: within [`APPKIT_OUTSIDE`] of an edge, beside it rather than past a corner.+fn in_appkit_outer_band(rect: Rect, p: Pos2) -> bool {+    !rect.contains(p) && rect.expand(APPKIT_OUTSIDE).contains(p) && (rect.x_range().contains(p.x) || rect.y_range().contains(p.y))+}+ /// AppKit's resize areas inside a window's content `rect`: a square at each corner and a strip /// along each edge. pub(in crate::docking) fn appkit_resize_areas(rect: Rect) -> [Rect; 8] {diff --git a/docs/ui-docking.md b/docs/ui-docking.md--- a/docs/ui-docking.md+++ b/docs/ui-docking.md@@ -575,6 +575,21 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho   `Paste` without the key). The app feeds them to the shortcut handling it runs on the main   window. A window where any widget has keyboard focus forwards nothing, and an Escape that   cancels a drag is not forwarded.+- **Keyboard back to the main window.** A click in a palette gives its OS window the keyboard.+  Once the pointer comes back from the palette to the main window, the palette hands the+  keyboard to it (`ViewportCommand::Focus`), as macOS palettes do, so the app's shortcuts and+  tool cursor work there without a click. While one of its text fields has the keyboard, a+  button is held or one of its popups is open, the hand-back waits. A press in AppKit's resize+  area, which reaches a few points past the palette's frame over the main window, focuses the+  palette without the pointer coming from it, and the palette keeps the keyboard. A normal+  window (a viewer) keeps the keyboard as any window does.+- **Pointer.** macOS tells a window that the pointer left only when it leaves the window's+  frame, so over a native window in front of the main window, the main window kept its last+  pointer position and the hover there. Once the pointer moves over a native window, the main+  window gets `Event::PointerGone` at the start of its next pass, until the pointer moves or+  presses in the main window again. On macOS the same holds within 3.5 pt outside an edge of a+  palette AppKit resizes: its resize area reaches past the frame there, and the main window,+  which gets those moves, drops them, unless a button is held in it. - **Input methods.** egui's IME descriptor (`PlatformOutput::ime`) goes out from the pass of   the window whose editor has the keyboard. A `craft_ui::ime::Composer` in a panel body of a   native window records its descriptor while the window draws, and the dock area publishes it