From 308163bcb52cdc0e99a5aa9933b35152d1263c26 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:40:17 +0000 Subject: [PATCH 1/3] docking: macOS resizes native windows itself, bands elsewhere are 6 pt with 16 pt corners On macOS the native window is resizable by AppKit again, with its own areas and cursors. The bar's buttons sit 19 pt in from the trailing edge, clear of AppKit's corner area (about 15 pt into the window, a 19 pt square measured on macOS 26), and the window sets no cursor of its own over AppKit's areas. Elsewhere the bands are 6 pt deep with 16 pt corner Ls, and hand the resize to the window system; the manual resize path is gone. --- crates/ui/src/docking/native_tests.rs | 91 +++++++----- crates/ui/src/docking/render/mod.rs | 5 +- crates/ui/src/docking/render/native.rs | 198 ++++++++++--------------- docs/ui-docking.md | 24 +-- 4 files changed, 156 insertions(+), 162 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 24975f0d..89269c66 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -3,7 +3,7 @@ use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, Vec2, ViewportCommand, epaint::ClippedShape, pos2, vec2}; -use super::render::{DragPointer, WindowDrag, floats_shown, resize_bands, resized}; +use super::render::{APPKIT_CORNER, DragPointer, WindowDrag, appkit_resize_areas, buttons_rect, floats_shown, resize_bands}; use super::testing::DockDriver; use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target}; use crate::layout::{SplitAxis, SplitSize}; @@ -1000,10 +1000,12 @@ fn a_drag_ends_when_its_native_window_or_panel_goes() { } /// Press at `from` in the native window of panel 5, move the pointer by `by` on the desktop -/// and release. Returns the window's OS rect and the driver. -fn resize_by(from: Pos2, by: Vec2) -> (Rect, DockDriver) { +/// and release. Returns the window's OS rect, the resize the window handed to the window +/// system, and the driver. +fn press_and_drag(from: Pos2, by: Vec2) -> (Rect, Option, DockDriver) { let mut d = driver(layout(true)); let id = window(&d, 5).id; + let viewport = d.viewport(id); let press = os_window(&d, 5).map_or(Pos2::ZERO, |(rect, _)| rect.min) + from.to_vec2(); d.window_step(id, vec![Event::PointerMoved(from), button(from, true)]); // The pointer is where it was moved on the desktop, wherever the window is now. @@ -1015,53 +1017,57 @@ fn resize_by(from: Pos2, by: Vec2) -> (Rect, DockDriver) { let pos = local(&d, by); d.window_step(id, vec![Event::PointerMoved(pos), button(pos, false)]); d.step(vec![]); - (os_window(&d, 5).map_or(Rect::NOTHING, |(rect, _)| rect), d) + let resize = d.host().commands.iter().find_map(|(v, c)| match c { + ViewportCommand::BeginResize(direction) if *v == viewport => Some(*direction), + _ => None, + }); + (os_window(&d, 5).map_or(Rect::NOTHING, |(rect, _)| rect), resize, d) } #[test] fn the_bars_close_button_wins_over_the_resize_bands() { + use egui::ResizeDirection as D; let mut d = driver(layout(true)); let id = window(&d, 5).id; let viewport = d.viewport(id); - // The × of the 240 pt wide window spans 217..235 across and the whole bar down. Its top + // The × of the 240 pt wide window spans 215..233 across and the whole bar down. Its top // right is next to the window's top-right corner, but it closes the window. - let out = d.window_click(id, pos2(234.0, 1.0)); + let out = d.window_click(id, pos2(232.0, 1.0)); assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions); - assert!(!d.host().commands.iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::InnerSize(_))), "not resized"); + assert!(!d.host().commands.iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::BeginResize(_))), "not resized"); - // Beside it, at the window's edges, the bands resize the window from (400, 130), 240 by - // 300, keeping the opposite edges. - let rect = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); - let (resized, d) = resize_by(pos2(238.0, 2.0), vec2(20.0, 20.0)); - assert_eq!(resized, rect(400.0, 150.0, 660.0, 430.0), "the top-right corner"); - assert_eq!(window(&d, 5).native, Some([400.0, 150.0, 260.0, 280.0]), "the layout follows"); - assert_eq!(resize_by(pos2(238.0, 15.0), vec2(20.0, 20.0)).0, rect(400.0, 130.0, 660.0, 430.0), "the right edge beside the ×"); - assert_eq!(resize_by(pos2(120.0, 1.0), vec2(20.0, -20.0)).0, rect(400.0, 110.0, 640.0, 430.0), "the top edge above the bar"); - assert_eq!(resize_by(pos2(2.0, 298.0), vec2(-20.0, 20.0)).0, rect(380.0, 130.0, 640.0, 450.0), "the bottom-left corner"); - assert_eq!(resize_by(pos2(2.0, 150.0), vec2(200.0, 0.0)).0, rect(480.0, 130.0, 640.0, 430.0), "no narrower than 160"); + // Beside it, at the window's edges, the bands hand the resize to the window system. + let resize = |from: Pos2| press_and_drag(from, vec2(20.0, 20.0)).1; + assert_eq!(resize(pos2(238.0, 2.0)), Some(D::NorthEast), "the top-right corner"); + assert_eq!(resize(pos2(237.0, 15.0)), Some(D::NorthEast), "the corner beside the ×"); + assert_eq!(resize(pos2(237.0, 100.0)), Some(D::East), "the right edge"); + assert_eq!(resize(pos2(120.0, 1.0)), Some(D::North), "the top edge above the bar"); + assert_eq!(resize(pos2(2.0, 298.0)), Some(D::SouthWest), "the bottom-left corner"); // Inside a corner's L, the bar moves the window. - let (moved, _) = resize_by(pos2(10.0, 10.0), vec2(20.0, 20.0)); - assert_eq!(moved, rect(420.0, 150.0, 660.0, 450.0)); + let rect = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + let (moved, resize, _) = press_and_drag(pos2(10.0, 10.0), vec2(20.0, 20.0)); + assert_eq!((moved, resize), (rect(420.0, 150.0, 660.0, 450.0), None)); } #[test] fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() { let rect = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); - let close = Rect::from_center_size(pos2(226.0, 9.0), vec2(18.0, 18.0)); + let close = Rect::from_center_size(pos2(224.0, 9.0), vec2(18.0, 18.0)); let bands = resize_bands(rect, &[close]); let at = |p: Pos2| bands.iter().find(|(_, band)| band.contains(p)).map(|(direction, _)| *direction); for (_, band) in &bands { assert!(!band.intersect(close).is_positive(), "{band:?} covers the ×"); - assert!(band.width().min(band.height()) <= 4.0, "{band:?} is deeper than an edge band"); + assert!(band.width().min(band.height()) <= 6.0, "{band:?} is deeper than an edge band"); } - // Each edge resizes along its whole length, outside the ×, from 12 pt corners. + // Each edge resizes along its whole length, outside the ×, from 16 pt corners. use egui::ResizeDirection as D; // Which of an edge's three parts `v` is in: the first corner, the edge, the last corner. - let part = |v: f32, length: f32| usize::from(v >= 12.0) + usize::from(v > length - 12.0); + let part = |v: f32, length: f32| usize::from(v >= 16.0) + usize::from(v > length - 16.0); for i in 0..240 { let x = i as f32 + 0.5; let top = [D::NorthWest, D::North, D::NorthEast][part(x, 240.0)]; assert_eq!(at(pos2(x, 0.5)), (!close.contains(pos2(x, 0.5))).then_some(top), "top at {x}"); + assert_eq!(at(pos2(x, 5.5)), (!close.contains(pos2(x, 5.5))).then_some(top), "6 pt down at {x}"); assert_eq!(at(pos2(x, 299.5)), Some([D::SouthWest, D::South, D::SouthEast][part(x, 240.0)]), "bottom at {x}"); } for i in 0..300 { @@ -1069,20 +1075,37 @@ fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() { assert_eq!(at(pos2(0.5, y)), Some([D::NorthWest, D::West, D::SouthWest][part(y, 300.0)]), "left at {y}"); assert_eq!(at(pos2(239.5, y)), Some([D::NorthEast, D::East, D::SouthEast][part(y, 300.0)]), "right at {y}"); } - assert_eq!(at(pos2(6.0, 6.0)), None, "a corner is an L along its edges"); - assert_eq!(at(pos2(120.0, 4.5)), None, "the bar below the top band"); + assert_eq!(at(pos2(10.0, 10.0)), None, "a corner is an L along its edges"); + assert_eq!(at(pos2(120.0, 6.5)), None, "the bar below the top band"); } #[test] -fn a_manual_resize_keeps_the_opposite_edges_and_the_minimum_size() { - use egui::ResizeDirection as D; - let (start, min) = ([100.0, 50.0, 300.0, 200.0], [160.0, 100.0]); - // Right edge 400, bottom 250. - assert_eq!(resized(start, D::NorthWest, vec2(-20.0, -10.0), min), [80.0, 40.0, 320.0, 210.0]); - assert_eq!(resized(start, D::East, vec2(30.0, 99.0), min), [100.0, 50.0, 330.0, 200.0]); - assert_eq!(resized(start, D::South, vec2(30.0, -20.0), min), [100.0, 50.0, 300.0, 180.0]); - assert_eq!(resized(start, D::SouthWest, vec2(500.0, -500.0), min), [240.0, 50.0, 160.0, 100.0]); - assert_eq!(resized(start, D::NorthEast, vec2(-500.0, 500.0), min), [100.0, 150.0, 160.0, 100.0]); +fn on_macos_the_bars_buttons_keep_clear_of_appkits_corner_resize_areas() { + use crate::docking::chrome::{ChromeStyle, TitleBar}; + let ctx = egui::Context::default(); + for right_to_left in [false, true] { + let mut buttons = Vec::new(); + let full = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(full), ..Default::default() }, |ui| { + let style = ChromeStyle { right_to_left, ..ChromeStyle::from_ui(ui) }; + let bar = Rect::from_min_size(full.min, vec2(full.width(), crate::docking::placement::BAR_HEIGHT)); + let shown = TitleBar::new(egui::Id::new("bar"), "Move").title("Panel").menu("Menu").close("Close").show( + ui, + buttons_rect(bar, right_to_left, true), + &style, + ); + buttons.extend(shown.close.iter().chain(&shown.menu).map(|r| r.rect)); + }); + output.textures_delta.clear(); + assert_eq!(buttons.len(), 2); + let [top_left, top_right, ..] = appkit_resize_areas(full); + let corner = if right_to_left { top_left } else { top_right }; + assert_eq!(corner.width(), APPKIT_CORNER); + for button in buttons { + // The × keeps 4 pt clear across; the bar, 18 pt high, cannot keep it below. + assert!(corner.expand2(vec2(4.0, 0.0)).intersect(button).width() <= 0.0, "{button:?} reaches into {corner:?}"); + } + } } /// [`layout`] built anew, its window with id 7 when `with_id`. diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 862117f1..36ce1f11 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -15,7 +15,10 @@ mod iconic; #[cfg(feature = "native-windows")] mod native; #[cfg(all(test, feature = "native-windows"))] -pub(in crate::docking) use {cross::DragPointer, native::WindowDrag, native::floats_shown, native::resize_bands, native::resized}; +pub(in crate::docking) use { + cross::DragPointer, + native::{APPKIT_CORNER, WindowDrag, appkit_resize_areas, buttons_rect, floats_shown, resize_bands}, +}; /// The viewport of the native window `window` of the dock area `area`. #[cfg(feature = "native-windows")] diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 529d1c7e..0279e28c 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -26,14 +26,27 @@ const MIN_SIZE: [f32; 2] = [160.0, 100.0]; const HIDE_DELAY: f64 = 0.25; /// How long reports of a native window's place may lag a move the dock area commanded. const MOVE_WAIT: f64 = 0.5; -/// How far the resize bands reach into a native window from its edges. -const EDGE: f32 = 4.0; +/// How far the resize bands reach into a native window from its edges, where the window has +/// no frame of its own to resize from. +const EDGE: f32 = 6.0; /// How far a corner's grab runs along each of its two edges. -const CORNER: f32 = 12.0; -/// The window system runs a resize from our bands (`BeginResize`). winit cannot on macOS, so -/// there the window follows the pointer by `InnerSize` and `OuterPosition`. Unit tests take -/// that path on every platform, since the test host carries it out. -const SYSTEM_RESIZE: bool = !cfg!(any(target_os = "macos", test)); +const CORNER: f32 = 16.0; +/// macOS keeps the window's frame, and AppKit resizes the window from its edges and corners. +/// Elsewhere the window is undecorated and resizes from our bands, which hand the resize to +/// the window system (`BeginResize`). +const OS_RESIZES: bool = cfg!(target_os = "macos"); +/// How far AppKit's corner resize area reaches into a titled, resizable window along each +/// edge. AppKit takes a press there before the content sees it, and shows its resize cursor. +/// Measured on macOS 26 as a 19 pt square at each corner, 62 % of which would lie inside a +/// square-cornered window: about 15 pt inside and 4 pt outside +/// (). +/// There is no published measurement for earlier releases. +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; +/// Room between AppKit's corner area and the bar's buttons. +const CORNER_MARGIN: f32 = 4.0; /// A drag of a native window's bar, in the context's data under the area's `"window-drag"`. #[derive(Clone, Copy, Debug, PartialEq)] @@ -51,21 +64,6 @@ pub(in crate::docking) struct WindowDrag { pub docks: bool, } -/// A resize of a native window from one of its bands where the window system does not run it -/// ([`SYSTEM_RESIZE`]), in the context's data under the area's `"window-resize"`. -#[derive(Clone, Copy, Debug)] -struct WindowResize { - /// [`Floating::id`] of the resized window. - window: u64, - direction: ResizeDirection, - /// Desktop rect of the window's content when the drag began. - start: [f32; 4], - /// Desktop point where the pointer pressed. - press: Pos2, - /// Desktop rect of the window's content as last commanded. - to: [f32; 4], -} - /// What the dock area knows of a shown native window's place, in the context's data under /// the area's `("native-place", viewport)`. Rects are desktop rects of the window's content. #[derive(Clone, Copy, Debug)] @@ -163,15 +161,11 @@ impl Renderer<'_, P> { #[cfg(debug_assertions)] self.warn_rebuilt(ctx, &natives); self.escape_elsewhere(ctx, None); - // A window closed or docked during its own bar drag or resize ends it. - let shown = |window: u64| natives.iter().any(|(w, ..)| w.id == window); - let (key, resize_key) = (self.id.with("window-drag"), self.id.with("window-resize")); - if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !shown(d.window)) { + // A window closed or docked during its own bar drag ends the drag. + let key = self.id.with("window-drag"); + if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) { ctx.data_mut(|d| d.remove::(key)); } - if ctx.data(|d| d.get_temp::(resize_key)).is_some_and(|r| !shown(r.window)) { - ctx.data_mut(|d| d.remove::(resize_key)); - } self.map_desktop(ctx, &natives, visibility.0); // The main window owns drags of the panels it draws. let id = self.id; @@ -317,16 +311,16 @@ impl Renderer<'_, P> { .with_min_inner_size(MIN_SIZE) .with_window_level(WindowLevel::AlwaysOnTop) .with_taskbar(false); - // The bar replaces the OS title bar. macOS keeps the frame for its shadow and rounded - // corners, but not its resizing: AppKit's resize areas reach into the content over the - // bar's × and take the pointer from it. The bands resize the window instead. + // The bar replaces the OS title bar. macOS keeps the frame, with its title bar and + // buttons hidden: AppKit resizes a movable window from its edges and corners with its + // own cursors, and draws its rounded corners and standard shadow. #[cfg(target_os = "macos")] let builder = builder .with_titlebar_shown(false) .with_title_shown(false) .with_fullsize_content_view(true) .with_titlebar_buttons_shown(false) - .with_resizable(false); + .with_resizable(window.panels().all(|p| self.permissions(p).movable)); #[cfg(not(target_os = "macos"))] let builder = builder.with_decorations(false); if toggled || (info.is_none() && !visible) { @@ -348,8 +342,11 @@ impl Renderer<'_, P> { return Report::default(); } if window.panels().all(|p| self.permissions(p).movable) { - self.resize_edges(ui, window.id, full, &buttons); - self.follow_window_resize(ui, window.id); + if OS_RESIZES { + leave_cursor_to_os(ui, full); + } else { + resize_edges(ui, self.id.with(("native-edges", window.id)), full, &buttons); + } } let mut surface = Surface { window: window.id, @@ -433,14 +430,14 @@ impl Renderer<'_, P> { let style = ChromeStyle::from_dock(self.style); let rect = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT.min(full.height()))); ui.painter().rect_filled(rect, 0.0, style.title_background); - // The buttons keep clear of the side's resize band, so the corner's grab runs down - // beside them. - let rect = if style.right_to_left { rect.with_min_x(rect.left() + EDGE) } else { rect.with_max_x(rect.right() - EDGE) }; + let rect = buttons_rect(rect, style.right_to_left, OS_RESIZES); let movable = window.panels().all(|p| self.permissions(p).movable); let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable); let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone()); let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); - let mut bar = TitleBar::new(self.id.with(("native-bar", window.id)), &move_label).title(title).movable(movable); + // On macOS the bar keeps the arrow, as native title bars do: AppKit's resize areas + // cover its ends and top edge, and a cursor set over them flickers with AppKit's. + let mut bar = TitleBar::new(self.id.with(("native-bar", window.id)), &move_label).title(title).movable(movable && !OS_RESIZES); if closable { bar = bar.close(&close_label); } @@ -559,62 +556,6 @@ impl Renderer<'_, P> { self.cross.release = Some((drag, pointer)); } - /// The resize bands of a movable native window. Drawn after its content, so they take the - /// pointer at its edges; the bar's `buttons` are left out. - fn resize_edges(&mut self, ui: &Ui, window: u64, full: Rect, buttons: &[Rect]) { - let id = self.id.with(("native-edges", window)); - for (i, (direction, band)) in resize_bands(full, buttons).into_iter().enumerate() { - let response = ui.interact(band, id.with(i), Sense::drag()).on_hover_cursor(resize_cursor(direction)); - if response.drag_started() { - self.start_window_resize(ui, window, direction); - } - } - } - - /// Start a resize: by the window system where it runs one, else by following the pointer - /// from the place the window system reports. - fn start_window_resize(&mut self, ui: &Ui, window: u64, direction: ResizeDirection) { - let press = ui.input(|i| i.pointer.press_origin().or(i.pointer.latest_pos())); - let content = ui.input(|i| placed(i.viewport())); - match (SYSTEM_RESIZE, content, press) { - (false, Some(start), Some(press)) => { - let resize = WindowResize { window, direction, start, press: pos2(start[0], start[1]) + press.to_vec2(), to: start }; - ui.ctx().data_mut(|d| d.insert_temp(self.id.with("window-resize"), resize)); - } - _ => ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction)), - } - } - - /// Size and move the window while the button is held, the edges opposite the grabbed band - /// in place, no smaller than [`MIN_SIZE`]. Released, a window left with too little of its - /// bar on a monitor goes back onto the nearest, as after a bar drag. - fn follow_window_resize(&mut self, ui: &Ui, window: u64) { - let key = self.id.with("window-resize"); - let Some(mut resize) = ui.ctx().data(|d| d.get_temp::(key)).filter(|r| r.window == window) else { return }; - ui.ctx().set_cursor_icon(resize_cursor(resize.direction)); - let content = ui.input(|i| content_of(i.viewport())); - let frame = content.map_or(Vec2::ZERO, |(_, frame)| frame); - // As in a bar drag: a pointer move is relative to where the window system reports the - // window now. - let moved = ui.input(|i| i.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None })); - let mut to = resize.to; - if let (Some((origin, _)), Some(local)) = (content, moved.filter(|p| p.is_finite())) { - to = resized(resize.start, resize.direction, origin + local.to_vec2() - resize.press, MIN_SIZE); - } - let over = !ui.input(|i| i.pointer.primary_down()) || !self.cross.focused; - if over { - to = kept_reachable(to, &self.monitors); - ui.ctx().data_mut(|d| d.remove::(key)); - } - if differs(to, resize.to) { - place_window(ui.ctx(), ui.ctx().viewport_id(), to, frame); - resize.to = to; - if !over { - ui.ctx().data_mut(|d| d.insert_temp(key, resize)); - } - } - } - /// Keys, text and clipboard events the window's widgets left over, for the application's /// shortcuts. Nothing while a widget of the window has keyboard focus: its keys are its own. fn forward_events(&mut self, ui: &Ui) { @@ -660,6 +601,52 @@ fn place_window(ctx: &Context, viewport: ViewportId, to: [f32; 4], frame: Vec2) ctx.send_viewport_cmd_to(viewport, ViewportCommand::OuterPosition(pos2(to[0], to[1]) - frame)); } +/// The resize bands of a movable native window without a frame of its own. Drawn after its +/// content, so they take the pointer at its edges; the bar's `buttons` are left out. +fn resize_edges(ui: &Ui, id: Id, full: Rect, buttons: &[Rect]) { + for (i, (direction, band)) in resize_bands(full, buttons).into_iter().enumerate() { + let response = ui.interact(band, id.with(i), Sense::drag()).on_hover_cursor(resize_cursor(direction)); + if response.drag_started() { + ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction)); + } + } +} + +/// Over AppKit's resize areas of the window's content `full`, the default cursor whatever the +/// content asked for: AppKit shows its resize cursor there, and any other cursor we set fights +/// it. +fn leave_cursor_to_os(ui: &Ui, full: Rect) { + if ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| appkit_resize_areas(full).iter().any(|area| area.contains(p))) { + ui.ctx().set_cursor_icon(CursorIcon::Default); + } +} + +/// 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] { + let (c, e) = (Vec2::splat(APPKIT_CORNER), APPKIT_EDGE); + [ + Rect::from_min_size(rect.left_top(), c), + Rect::from_min_size(rect.right_top() - vec2(c.x, 0.0), c), + Rect::from_min_size(rect.left_bottom() - vec2(0.0, c.y), c), + Rect::from_min_size(rect.right_bottom() - c, c), + rect.with_max_y(rect.top() + e), + rect.with_min_y(rect.bottom() - e), + rect.with_max_x(rect.left() + e), + rect.with_min_x(rect.right() - e), + ] +} + +/// The part of a native window's bar `rect` its buttons are laid out in, its trailing end +/// pulled in. Where AppKit resizes the window (`os_resizes`) the buttons keep clear of its +/// corner area, which takes presses from the content; the bar is too short to keep them below +/// it. Elsewhere they keep clear of the side's resize band, so the corner's grab runs down +/// beside them. +pub(in crate::docking) fn buttons_rect(rect: Rect, right_to_left: bool, os_resizes: bool) -> Rect { + let inset = if os_resizes { APPKIT_CORNER + CORNER_MARGIN } else { EDGE }; + if right_to_left { rect.with_min_x(rect.left() + inset) } else { rect.with_max_x(rect.right() - inset) } +} + /// The bands along the edges of a native window's content `rect` that resize it: an L at each /// corner, [`EDGE`] deep and [`CORNER`] along both edges, and the edges between them. They /// leave out the bar's `buttons`, which take their own clicks. @@ -709,27 +696,6 @@ fn outside(rect: Rect, hole: Rect) -> Vec { .collect() } -/// The desktop rect `start` with the edges `direction` names moved by `delta`, the opposite -/// edges kept, and no smaller than `min`. -pub(in crate::docking) fn resized(start: [f32; 4], direction: ResizeDirection, delta: Vec2, min: [f32; 2]) -> [f32; 4] { - use ResizeDirection as D; - let [x, y, w, h] = start; - let (mut left, mut top, mut right, mut bottom) = (x, y, x + w, y + h); - if matches!(direction, D::West | D::NorthWest | D::SouthWest) { - left = (left + delta.x).min(right - min[0]); - } - if matches!(direction, D::East | D::NorthEast | D::SouthEast) { - right = (right + delta.x).max(left + min[0]); - } - if matches!(direction, D::North | D::NorthWest | D::NorthEast) { - top = (top + delta.y).min(bottom - min[1]); - } - if matches!(direction, D::South | D::SouthWest | D::SouthEast) { - bottom = (bottom + delta.y).max(top + min[1]); - } - [left, top, right - left, bottom - top] -} - fn resize_cursor(direction: ResizeDirection) -> CursorIcon { use {CursorIcon as C, ResizeDirection as D}; match direction { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 853f77cf..d014c0f9 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -347,17 +347,19 @@ that is not floatable, stays in-window. Floating windows get no taskbar or Alt-T - **Bar.** Each window has a bar at the top, 18 points high, in the floating title bar's style: the title (the shown panel of its first group) and ×. Dragging the bar moves the window. On Wayland, where a window cannot be placed, the bar hands the move to the compositor. On - Windows and Linux the OS frame is off. On macOS the frame stays for its shadow and rounded - corners, with its title bar and buttons hidden, and is not resizable: AppKit's resize areas - reach well into the content and would take clicks from the ×. -- **Resize.** A movable window resizes from bands 4 pt deep along its edges. Each corner is an - L along both edges, 12 pt long and as deep as the edges. The bands leave out the bar's - buttons, which sit 4 pt in from the window's edge so the corner's grab runs down beside - them; elsewhere the top band lies over the bar's drag area. The OS runs the resize - (`ViewportCommand::BeginResize`), except on macOS, where winit cannot: there the window - follows the pointer by `InnerSize` and `OuterPosition`, keeping the opposite edges and the - 160 by 100 pt minimum, and a window released with too little of its bar on a monitor moves - back onto the nearest one, as after a bar drag. + Windows and Linux the OS frame is off. On macOS the frame stays, with its title bar and + buttons hidden, for its resizing, rounded corners and standard window shadow. The dock area + paints no shadow of its own around a native window. +- **Resize.** A movable window resizes from its edges and corners. On macOS AppKit does it, + with its own areas and cursors. Its corner area reaches about 15 pt into the window along + each edge (a 19 pt square, measured on macOS 26) and takes presses before the content sees + them, so the bar's buttons sit 19 pt in from the window's trailing edge; the 18 pt bar is too + short to keep them below it. Over AppKit's areas the window sets no cursor of its own, which + would flicker with AppKit's. Elsewhere the window resizes from bands 6 pt deep along its + edges, and each corner is an L along both edges, 16 pt long and as deep as the edges. The + bands leave out the bar's buttons, which sit 6 pt in from the window's edge so the corner's + grab runs down beside them; elsewhere the top band lies over the bar's drag area. The OS + runs the resize from a band (`ViewportCommand::BeginResize`). - **Close.** The bar's × and the OS close request close the window's panels, as the floating window's × does. They reopen from the app's Window menu. - **Place.** `Floating::native` is the desktop rect of the window's content (egui's From ba00e9fd9e951bb5dc4593ec3c4e1d55e48b2b8a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:43:18 +0000 Subject: [PATCH 2/3] craft-ui: end drags and resizes on a focus loss, not while a window reports no focus egui-winit starts RawInput::focused false and only a winit Focused event sets it. A window that egui never heard was focused cancelled every tab, group and window drag and every resize on its first frame. Gestures now end on a focused-to-unfocused transition (or Escape); a window that never reported focus still drags while the pointer is pressed in it. --- crates/ui/src/docking/chrome.rs | 4 +- crates/ui/src/docking/dock_tests.rs | 43 +++++++++++++++- crates/ui/src/docking/native_tests.rs | 13 +++++ crates/ui/src/docking/render/cross.rs | 8 +-- crates/ui/src/docking/render/floating.rs | 3 +- crates/ui/src/docking/render/native.rs | 4 +- crates/ui/src/docking/render/preview.rs | 4 +- crates/ui/src/focus.rs | 64 ++++++++++++++++++++++++ crates/ui/src/lib.rs | 1 + crates/ui/src/panes/mod.rs | 2 +- 10 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 crates/ui/src/focus.rs diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 2933ffd0..724df73f 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -180,7 +180,7 @@ impl<'a> FloatingFrame<'a> { let mut started = None; let mut responses = Vec::new(); let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; - let canceled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); + let canceled = ui.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ui.ctx()); for (name, handle, direction) in handles(rect) { if !super::targets::usable(handle) { continue; @@ -409,7 +409,7 @@ pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Opt } let (start, origin) = ctx.data(|data| data.get_temp::<(Rect, Pos2)>(key))?; let forget = || ctx.data_mut(|data| data.remove::<(Rect, Pos2)>(key)); - if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { + if ctx.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ctx) { forget(); return Some(MoveStep::Canceled(start)); } diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 7819667f..265ae188 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -84,7 +84,23 @@ impl Driver { } fn with(layout: Layout, content: TestContent, right_to_left: bool) -> Self { - let mut driver = Self { ctx: Context::default(), layout, content, focused: true, right_to_left, shapes: Vec::new(), time: 0.0 }; + Self::start(Self { ctx: Context::default(), layout, content, focused: true, right_to_left, shapes: Vec::new(), time: 0.0 }) + } + + /// A window egui never heard has focus (macOS can make it key without a `Focused` event). + fn never_focused(layout: Layout) -> Self { + Self::start(Self { + ctx: Context::default(), + layout, + content: TestContent::default(), + focused: false, + right_to_left: false, + shapes: Vec::new(), + time: 0.0, + }) + } + + fn start(mut driver: Self) -> Self { // Floating areas settle after their sizing pass. for _ in 0..3 { driver.step(vec![]); @@ -515,6 +531,31 @@ fn a_column_s_first_tab_dropped_on_its_own_column_edge_anchors_on_another_panel( assert_eq!(d.layout, layout); } +#[test] +fn a_window_that_never_reported_focus_drags_tabs_and_losing_focus_cancels_a_drag() { + let mut layout = docked(Side::Start, &[&[&[2]], &[&[3, 4]]]); + if let Some(Node::Split { size, .. }) = &mut layout.root { + *size = SplitSize::FixedFirst(480.0); + } + let to = pos2(484.0, 300.0); + let mut d = Driver::never_focused(layout.clone()); + let out = d.drag(d.tab(3).center(), to); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 4, placement: Placement::Column { after: true } }]); + + // Focus that arrives during the drag and goes again ends it. + let mut d = Driver::never_focused(layout.clone()); + d.hold(d.tab(3).center(), to); + d.focused = true; + d.step(vec![Event::WindowFocused(true)]); + assert!(is_dragging::(&d.ctx, Id::new(AREA))); + d.focused = false; + d.step(vec![Event::WindowFocused(false)]); + let out = d.step(vec![button(to, false)]); + assert!(out.actions.is_empty(), "{out:?}"); + assert_eq!(d.layout, layout); + assert!(!is_dragging::(&d.ctx, Id::new(AREA))); +} + #[test] fn a_dock_wider_than_the_window_shrinks_to_fit_and_resizes() { let widths = render::fit_widths(&[(600.0, Some(40.0)), (600.0, Some(40.0)), (48.0, None)], 800.0); diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 89269c66..db123d72 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -940,6 +940,19 @@ fn escape_in_a_focused_native_window_cancels_a_main_window_drag() { assert!(!super::is_dragging::(&d.ctx, d.id)); } +#[test] +fn a_main_window_without_focus_still_drags_a_tab_into_another_group() { + let mut d = driver(layout(false)); + d.host().focus = None; + d.settle(); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let over = d.hit(&Hit::Tab(3)).unwrap().center(); + d.hold(tab, over); + assert!(super::is_dragging::(&d.ctx, d.id)); + let out = d.release(over); + assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 1, .. })), "{:?}", out.actions); +} + #[test] fn focus_moving_to_a_native_window_mid_drag_keeps_the_drag() { let mut d = driver(two_windows()); diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index 3a17f307..b30e058c 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -81,8 +81,8 @@ enum Dest { pub(in crate::docking) struct Cross

{ /// Viewports are native: drags cross windows. pub on: bool, - /// One of the application's windows has focus. - pub focused: bool, + /// The application's windows lost focus this pass: one had it on the last pass, none now. + pub focus_lost: bool, /// The main window's content on the desktop, where known. pub main: Option, /// Shown native windows' desktop rects, front first. @@ -109,7 +109,7 @@ impl

Default for Cross

{ fn default() -> Self { Self { on: false, - focused: true, + focus_lost: false, main: None, windows: Vec::new(), surfaces: Vec::new(), @@ -432,7 +432,7 @@ impl Renderer<'_, P> { pointer: at, down: pointer.down, released: pointer.released, - focused: self.cross.focused, + focused: !self.cross.focus_lost, gone: false, escape: pointer.escape, command: pointer.command, diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index ceaecb95..b1a2a419 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -153,7 +153,8 @@ impl Renderer<'_, P> { && let Some(drag) = ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))) && drag.gesture == GroupGesture::Window && let Some(pointer) = ui.ctx().pointer_interact_pos() - && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) + && !ui.input(|i| i.key_pressed(Key::Escape)) + && !crate::focus::lost(ui.ctx()) { // From the press origin, so fractional pointer steps do not accumulate rounding. let moved = clamp_float(drag.source.translate(pointer - drag.origin), self.float_bounds); diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 0279e28c..952027cd 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -251,7 +251,7 @@ impl Renderer<'_, P> { let focused = i.focused || infos().any(|v| v.focused == Some(true)) || infos().all(|v| v.focused.is_none()); (focused, i.time) }); - self.cross.focused = focused; + self.cross.focus_lost = crate::focus::turned_false(ctx, self.id.with("app-focus"), focused); let key = self.id.with("native-visibility"); let (last, was) = ctx.data(|d| d.get_temp::<(f64, bool)>(key)).unwrap_or((now, true)); let last = if focused { now } else { last }; @@ -525,7 +525,7 @@ impl Renderer<'_, P> { ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(to - frame)); r.cross.moved = Some(to); }; - if pointer.escape || !self.cross.focused { + if pointer.escape || self.cross.focus_lost { if pointer.escape { move_to(self, drag.start); ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 0bf8da81..b746c8e6 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -27,6 +27,7 @@ pub(super) struct DragInput { pub pointer: Option, pub down: bool, pub released: bool, + /// The window kept its focus: false on the pass it loses it (see [`crate::focus`]). pub focused: bool, /// The pointer left the window. pub gone: bool, @@ -41,11 +42,12 @@ impl DragInput { /// This window's own input. fn of(ctx: &Context) -> Self { let command = float_only(ctx); + let focus_lost = crate::focus::lost(ctx); ctx.input(|i| Self { pointer: i.pointer.interact_pos().filter(|p| p.is_finite()), down: i.pointer.primary_down(), released: i.pointer.primary_released(), - focused: i.focused, + focused: !focus_lost, gone: !i.pointer.has_pointer(), escape: i.key_pressed(Key::Escape), command, diff --git a/crates/ui/src/focus.rs b/crates/ui/src/focus.rs new file mode 100644 index 00000000..006f8f82 --- /dev/null +++ b/crates/ui/src/focus.rs @@ -0,0 +1,64 @@ +//! Window focus as pointer gestures read it. +//! +//! A drag or resize ends when its window loses focus, not while the window merely reports no +//! focus. egui-winit starts `RawInput::focused` false and only a winit `Focused` event sets it +//! (on macOS to `has_focus()` at the time the event is read), so a window can take pointer +//! input for a long time while egui reports it unfocused, at first launch until the user +//! switches to another application and back. Gestures in such a window still run. + +use egui::{Context, Id}; + +#[derive(Clone, Copy)] +struct Seen { + pass: u64, + before: bool, + now: bool, +} + +/// Whether `now` is false and was true on the previous pass, as recorded under `key`. With no +/// record from the previous pass there is no transition, so a gesture asks on every pass it +/// runs. Asking again in the same pass gives the same answer. +pub(crate) fn turned_false(ctx: &Context, key: Id, now: bool) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + let seen = match d.get_temp::(key) { + Some(seen) if seen.pass == pass => seen, + Some(seen) if seen.pass + 1 == pass => Seen { pass, before: seen.now, now }, + _ => Seen { pass, before: now, now }, + }; + d.insert_temp(key, seen); + seen.before && !seen.now + }) +} + +/// Whether the window being drawn lost focus this pass: focused on the previous pass, not now. +pub(crate) fn lost(ctx: &Context) -> bool { + let focused = ctx.input(|i| i.focused); + turned_false(ctx, Id::new("craft-ui-window-focus").with(ctx.viewport_id()), focused) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::RawInput; + + fn pass(ctx: &Context, focused: bool) -> bool { + let mut lost_now = false; + let mut out = ctx.run_ui(RawInput { focused, ..Default::default() }, |ui| { + lost_now = lost(ui.ctx()); + assert_eq!(lost(ui.ctx()), lost_now, "asking twice in a pass"); + }); + out.textures_delta.clear(); + lost_now + } + + #[test] + fn only_a_focused_to_unfocused_pass_is_a_loss() { + let ctx = Context::default(); + assert!(!pass(&ctx, false), "a window that never had focus has not lost it"); + assert!(!pass(&ctx, false)); + assert!(!pass(&ctx, true)); + assert!(pass(&ctx, false)); + assert!(!pass(&ctx, false), "the loss is one pass"); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index f54d1bbc..d1a30342 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -7,6 +7,7 @@ pub mod buttons; pub mod docking; +mod focus; pub mod icons; pub mod layout; pub mod panes; diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index 75b20ddd..b50829f1 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -559,7 +559,7 @@ impl PaneHost { memory.drag_out = Some(DragOut { pane: s.id, canceled: false }); } let Some(mut drag) = memory.drag_out.filter(|d| d.pane == s.id) else { return }; - if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { + if ctx.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ctx) { drag.canceled = true; } let near = rect.expand(DOCK_SNAP); From 3250dd230553c5ccdca3408bf105b3e92c9676b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:50:55 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docking:=20the=20macOS=20=C3=97=20sits=208?= =?UTF-8?q?=20pt=20from=20the=20corner,=20its=20hit=20area=20reaching=20aw?= =?UTF-8?q?ay=20from=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ×'s glyph inset, size and hit area are set in one place (MACOS_CLOSE, OTHER_CLOSE in render/native.rs). On macOS the glyph sits where macOS puts its close button, and the hit area reaches 10 pt toward the leading edge and down to the bar's bottom, past AppKit's corner resize area. Elsewhere the layout is the same as before. CRAFT_DOCK_DEBUG_CLOSE=1 outlines the hit area and AppKit's resize areas. --- crates/ui/src/docking/chrome.rs | 21 +++++-- crates/ui/src/docking/native_tests.rs | 47 ++++++++------ crates/ui/src/docking/render/mod.rs | 2 +- crates/ui/src/docking/render/native.rs | 86 ++++++++++++++++++++++---- docs/ui-docking.md | 21 ++++--- 5 files changed, 133 insertions(+), 44 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 724df73f..ab80552d 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -282,7 +282,7 @@ pub(super) fn truncated(ui: &Ui, text: WidgetText, width: f32, font: FontId) -> ui.painter().layout_job(job) } -/// A title bar: an optional title, an optional ▾ menu button and an optional ×, 10 pt +/// A title bar: an optional title, an optional ▾ menu button and an optional ×, by default 10 pt /// in from the right edge, mirrored when [`ChromeStyle::right_to_left`] is set. The bar senses /// clicks and drags; double-clicks are the caller's. pub struct TitleBar<'a> { @@ -291,6 +291,8 @@ pub struct TitleBar<'a> { title: Option, menu: Option<&'a str>, close: Option<&'a str>, + /// The ×'s hit area and glyph, where the caller places them. + close_at: Option<(Rect, Rect)>, movable: bool, } @@ -306,7 +308,7 @@ impl<'a> TitleBar<'a> { /// `id` is the bar's own; the buttons use `id.with("menu")` and `id.with("close")`. /// `move_label` names the bar for accessibility. pub fn new(id: Id, move_label: &'a str) -> Self { - Self { id, move_label, title: None, menu: None, close: None, movable: true } + Self { id, move_label, title: None, menu: None, close: None, close_at: None, movable: true } } /// Text at the leading edge, cut with '…' to fit. @@ -327,6 +329,14 @@ impl<'a> TitleBar<'a> { self } + /// Place the × at `glyph`, taking clicks in `hit`: rects as drawn, already mirrored in a + /// right-to-left layout. By default the × is a square the bar's height, its center 10 pt + /// in from the trailing edge. + pub fn close_at(mut self, hit: Rect, glyph: Rect) -> Self { + self.close_at = Some((hit, glyph)); + self + } + /// Whether the bar shows the grab cursor. It senses drags either way. pub fn movable(mut self, movable: bool) -> Self { self.movable = movable; @@ -345,7 +355,10 @@ impl<'a> TitleBar<'a> { r } }; - let close_rect = self.close.map(|_| Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side))); + let close_rect = self.close.map(|_| match self.close_at { + Some((hit, _)) => mirror(hit), + None => Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side)), + }); let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0); let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom()))); let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left()); @@ -376,7 +389,7 @@ impl<'a> TitleBar<'a> { }); let close = close_rect.zip(self.close).map(|(close_rect, label)| { let (response, stroke) = button(ui, close_rect, "close", label); - let r = Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0))); + let r = self.close_at.map_or_else(|| Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0))), |(_, glyph)| glyph); painter.line_segment([r.left_top(), r.right_bottom()], stroke); painter.line_segment([r.right_top(), r.left_bottom()], stroke); response diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index db123d72..6cbeef07 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -3,7 +3,7 @@ use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, Vec2, ViewportCommand, epaint::ClippedShape, pos2, vec2}; -use super::render::{APPKIT_CORNER, DragPointer, WindowDrag, appkit_resize_areas, buttons_rect, floats_shown, resize_bands}; +use super::render::{APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands}; use super::testing::DockDriver; use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target}; use crate::layout::{SplitAxis, SplitSize}; @@ -1093,31 +1093,38 @@ fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() { } #[test] -fn on_macos_the_bars_buttons_keep_clear_of_appkits_corner_resize_areas() { +fn on_macos_the_close_glyph_sits_by_the_corner_and_its_hit_area_reaches_away_from_it() { use crate::docking::chrome::{ChromeStyle, TitleBar}; + use crate::docking::placement::BAR_HEIGHT; let ctx = egui::Context::default(); + let full = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); + let bar = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT)); for right_to_left in [false, true] { - let mut buttons = Vec::new(); - let full = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); - let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(full), ..Default::default() }, |ui| { - let style = ChromeStyle { right_to_left, ..ChromeStyle::from_ui(ui) }; - let bar = Rect::from_min_size(full.min, vec2(full.width(), crate::docking::placement::BAR_HEIGHT)); - let shown = TitleBar::new(egui::Id::new("bar"), "Move").title("Panel").menu("Menu").close("Close").show( - ui, - buttons_rect(bar, right_to_left, true), - &style, - ); - buttons.extend(shown.close.iter().chain(&shown.menu).map(|r| r.rect)); - }); - output.textures_delta.clear(); - assert_eq!(buttons.len(), 2); + let (hit, glyph) = close_rects(bar, MACOS_CLOSE, right_to_left); + // Distances from the corner the × sits by: across from the trailing edge, and down. + let across = |x: f32| if right_to_left { x - full.left() } else { full.right() - x }; + let (glyph_near, glyph_far) = (across(glyph.right()).min(across(glyph.left())), across(glyph.right()).max(across(glyph.left()))); + assert_eq!((glyph_near, glyph.top(), glyph.width(), glyph.height()), (8.0, 4.5, 9.0, 9.0), "the glyph by the corner"); + let (hit_near, hit_far) = (across(hit.right()).min(across(hit.left())), across(hit.right()).max(across(hit.left()))); + assert_eq!(hit_near, glyph_near, "the hit area reaches no closer to the corner than the glyph"); + assert_eq!(hit_far, glyph_far + 10.0, "the hit area reaches away from the corner"); + assert_eq!((hit.top(), hit.bottom()), (glyph.top(), BAR_HEIGHT), "the hit area reaches down to the bar's bottom, not below"); + // AppKit's corner area takes the presses nearest the corner; the rest reach the ×. let [top_left, top_right, ..] = appkit_resize_areas(full); let corner = if right_to_left { top_left } else { top_right }; assert_eq!(corner.width(), APPKIT_CORNER); - for button in buttons { - // The × keeps 4 pt clear across; the bar, 18 pt high, cannot keep it below. - assert!(corner.expand2(vec2(4.0, 0.0)).intersect(button).width() <= 0.0, "{button:?} reaches into {corner:?}"); - } + assert!(hit_far - APPKIT_CORNER >= 10.0, "{hit:?} has too little room outside {corner:?}"); + + let mut shown = None; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(full), ..Default::default() }, |ui| { + let style = ChromeStyle { right_to_left, ..ChromeStyle::from_ui(ui) }; + let response = TitleBar::new(egui::Id::new("bar"), "Move").title("Panel").close("Close").close_at(hit, glyph).show(ui, bar, &style); + shown = response.close.map(|close| (close.rect, response.bar.rect)); + }); + output.textures_delta.clear(); + let (close, drag) = shown.unwrap_or((Rect::NOTHING, Rect::NOTHING)); + assert_eq!(close, hit, "the bar takes clicks for the × in its hit area"); + assert!(!drag.intersect(hit).is_positive(), "the bar's drag area {drag:?} leaves the × alone"); } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 36ce1f11..2972f05b 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -17,7 +17,7 @@ mod native; #[cfg(all(test, feature = "native-windows"))] pub(in crate::docking) use { cross::DragPointer, - native::{APPKIT_CORNER, WindowDrag, appkit_resize_areas, buttons_rect, floats_shown, resize_bands}, + native::{APPKIT_CORNER, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands}, }; /// The viewport of the native window `window` of the dock area `area`. diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 952027cd..21595078 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -45,8 +45,47 @@ 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; -/// Room between AppKit's corner area and the bar's buttons. -const CORNER_MARGIN: f32 = 4.0; + +/// 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. +#[derive(Clone, Copy, Debug)] +pub(in crate::docking) struct CloseLayout { + /// From the window's top edge to the top of the glyph. + pub glyph_top: f32, + /// From the window's trailing edge to the glyph's trailing side. + pub glyph_trailing: f32, + /// Width and height of the glyph. + pub glyph_size: f32, + /// How far the hit area reaches past the glyph to the left (to the right when mirrored). + pub hit_left: f32, + /// How far the hit area reaches past the glyph to the right (to the left when mirrored). + pub hit_right: f32, + /// How far the hit area reaches above the glyph. + pub hit_up: f32, + /// How far the hit area reaches below the glyph. Past the bar's bottom it takes clicks + /// from the tab strip under the bar. + pub hit_down: f32, +} + +/// The × on macOS, where macOS puts the close button of its own windows: centered in the +/// 18 pt bar, its trailing side 8 pt in. AppKit's corner resize area ([`APPKIT_CORNER`]) +/// covers its trailing half and takes presses there, so the hit area reaches away from the +/// corner: left of the glyph, and down to the bar's bottom. +pub(in crate::docking) const MACOS_CLOSE: CloseLayout = + CloseLayout { glyph_top: 4.5, glyph_trailing: 8.0, glyph_size: 9.0, hit_left: 10.0, hit_right: 0.0, hit_up: 0.0, hit_down: 4.5 }; + +/// The × elsewhere: an 18 pt square the height of the bar, its trailing side 7 pt in so the +/// side's resize band ([`EDGE`]) and the corner's grab run down beside it, with the glyph at +/// its center. +pub(in crate::docking) const OTHER_CLOSE: CloseLayout = + CloseLayout { glyph_top: 4.5, glyph_trailing: 11.5, glyph_size: 9.0, hit_left: 4.5, hit_right: 4.5, hit_up: 4.5, hit_down: 4.5 }; + +/// Whether native windows outline their ×'s hit area, and on macOS AppKit's resize areas: +/// `CRAFT_DOCK_DEBUG_CLOSE=1`, read once. +fn debug_close() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("CRAFT_DOCK_DEBUG_CLOSE").is_ok_and(|v| v == "1")) +} /// A drag of a native window's bar, in the context's data under the area's `"window-drag"`. #[derive(Clone, Copy, Debug, PartialEq)] @@ -336,6 +375,9 @@ impl Renderer<'_, P> { let buttons = self.native_bar(ui, window); let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max); self.floating_body(ui, window, body, full); + if debug_close() { + debug_outlines(ui, full, &buttons); + } // An embedded fallback reads the main window's input: its close request, place, // keys and resizes are not this window's. if !matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) { @@ -430,7 +472,7 @@ impl Renderer<'_, P> { let style = ChromeStyle::from_dock(self.style); let rect = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT.min(full.height()))); ui.painter().rect_filled(rect, 0.0, style.title_background); - let rect = buttons_rect(rect, style.right_to_left, OS_RESIZES); + let (close_hit, close_glyph) = close_rects(rect, if OS_RESIZES { MACOS_CLOSE } else { OTHER_CLOSE }, style.right_to_left); let movable = window.panels().all(|p| self.permissions(p).movable); let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable); let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone()); @@ -439,7 +481,7 @@ impl Renderer<'_, P> { // cover its ends and top edge, and a cursor set over them flickers with AppKit's. let mut bar = TitleBar::new(self.id.with(("native-bar", window.id)), &move_label).title(title).movable(movable && !OS_RESIZES); if closable { - bar = bar.close(&close_label); + bar = bar.close(&close_label).close_at(close_hit, close_glyph); } let response = bar.show(ui, rect, &style); if response.close.as_ref().is_some_and(|close| close.clicked()) { @@ -637,14 +679,34 @@ pub(in crate::docking) fn appkit_resize_areas(rect: Rect) -> [Rect; 8] { ] } -/// The part of a native window's bar `rect` its buttons are laid out in, its trailing end -/// pulled in. Where AppKit resizes the window (`os_resizes`) the buttons keep clear of its -/// corner area, which takes presses from the content; the bar is too short to keep them below -/// it. Elsewhere they keep clear of the side's resize band, so the corner's grab runs down -/// beside them. -pub(in crate::docking) fn buttons_rect(rect: Rect, right_to_left: bool, os_resizes: bool) -> Rect { - let inset = if os_resizes { APPKIT_CORNER + CORNER_MARGIN } else { EDGE }; - if right_to_left { rect.with_min_x(rect.left() + inset) } else { rect.with_max_x(rect.right() - inset) } +/// Over a native window's content `full`: its bar's `buttons` outlined in red and, on macOS, +/// AppKit's resize areas in orange. +fn debug_outlines(ui: &Ui, full: Rect, buttons: &[Rect]) { + let stroke = |color| egui::Stroke::new(1.0, color); + for button in buttons { + ui.painter().rect_stroke(*button, 0.0, stroke(egui::Color32::RED), egui::StrokeKind::Inside); + } + if OS_RESIZES { + for area in appkit_resize_areas(full) { + ui.painter().rect_stroke(area, 0.0, stroke(egui::Color32::ORANGE), egui::StrokeKind::Inside); + } + } +} + +/// The hit area and glyph of the × in a native window's bar `bar`, laid out by `layout` from +/// the bar's top and trailing edges. +pub(in crate::docking) fn close_rects(bar: Rect, layout: CloseLayout, right_to_left: bool) -> (Rect, Rect) { + let l = layout; + let glyph = Rect::from_min_size(pos2(bar.right() - l.glyph_trailing - l.glyph_size, bar.top() + l.glyph_top), Vec2::splat(l.glyph_size)); + let hit = Rect::from_min_max(glyph.min - vec2(l.hit_left, l.hit_up), glyph.max + vec2(l.hit_right, l.hit_down)); + let mirror = |r: Rect| { + if right_to_left { + Rect::from_x_y_ranges((bar.left() + bar.right() - r.right())..=(bar.left() + bar.right() - r.left()), r.y_range()) + } else { + r + } + }; + (mirror(hit), mirror(glyph)) } /// The bands along the edges of a native window's content `rect` that resize it: an L at each diff --git a/docs/ui-docking.md b/docs/ui-docking.md index d8c33e87..1af22a93 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -351,16 +351,23 @@ that is not floatable, stays in-window. Floating windows get no taskbar or Alt-T Windows and Linux the OS frame is off. On macOS the frame stays, with its title bar and buttons hidden, for its resizing, rounded corners and standard window shadow. The dock area paints no shadow of its own around a native window. +- **The ×.** Its place is set in `render/native.rs` (`MACOS_CLOSE`, `OTHER_CLOSE`): the + glyph's inset from the top and trailing edges, its size, and how far the hit area reaches + past the glyph on each side. On macOS the 9 pt glyph sits where macOS puts the close button + of its own windows: centered in the bar, its trailing side 8 pt in. AppKit's corner resize + area covers the glyph's trailing half and takes presses there, so the hit area reaches 10 pt + further toward the leading edge and down to the bar's bottom. Elsewhere the × is an 18 pt + square, its trailing side 7 pt in. With `CRAFT_DOCK_DEBUG_CLOSE=1` in the environment, + native windows outline the ×'s hit area in red and, on macOS, AppKit's resize areas in + orange. - **Resize.** A movable window resizes from its edges and corners. On macOS AppKit does it, with its own areas and cursors. Its corner area reaches about 15 pt into the window along each edge (a 19 pt square, measured on macOS 26) and takes presses before the content sees - them, so the bar's buttons sit 19 pt in from the window's trailing edge; the 18 pt bar is too - short to keep them below it. Over AppKit's areas the window sets no cursor of its own, which - would flicker with AppKit's. Elsewhere the window resizes from bands 6 pt deep along its - edges, and each corner is an L along both edges, 16 pt long and as deep as the edges. The - bands leave out the bar's buttons, which sit 6 pt in from the window's edge so the corner's - grab runs down beside them; elsewhere the top band lies over the bar's drag area. The OS - runs the resize from a band (`ViewportCommand::BeginResize`). + them. Over AppKit's areas the window sets no cursor of its own, which would flicker with + AppKit's. Elsewhere the window resizes from bands 6 pt deep along its edges, and each corner + is an L along both edges, 16 pt long and as deep as the edges. The bands leave out the bar's + buttons, so the corner's grab runs down beside the ×; elsewhere the top band lies over the + bar's drag area. The OS runs the resize from a band (`ViewportCommand::BeginResize`). - **Close.** The bar's × and the OS close request close the window's panels, as the floating window's × does. They reopen from the app's Window menu. - **Place.** `Floating::native` is the desktop rect of the window's content (egui's