diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 2933ffd0..ab80552d 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; @@ -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 @@ -409,7 +422,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 24975f0d..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::{DragPointer, WindowDrag, floats_shown, resize_bands, resized}; +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}; @@ -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()); @@ -1000,10 +1013,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 +1030,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 +1088,44 @@ 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_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 (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); + 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"); + } } /// [`layout`] built anew, its window with id 7 when `with_id`. 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/mod.rs b/crates/ui/src/docking/render/mod.rs index 862117f1..2972f05b 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, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, 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..21595078 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -26,14 +26,66 @@ 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; + +/// 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)] @@ -51,21 +103,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 +200,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; @@ -257,7 +290,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 }; @@ -317,16 +350,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) { @@ -342,14 +375,20 @@ 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) { 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,16 +472,16 @@ 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 (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()); 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); + 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()) { @@ -528,7 +567,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)); @@ -559,62 +598,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 +643,72 @@ 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), + ] +} + +/// 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 /// 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 +758,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/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); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 88f9b190..1af22a93 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -348,17 +348,26 @@ 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. +- **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. 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