docking: the macOS × sits 8 pt from the corner, its hit area reaching away from it

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.
This commit is contained in:
aiongg committed 2026-10-24 03:53:42 +00:00
1 parent ba00e9fd9e
commit 3250dd2305
5 files changed
+133 -44

No files matched your search

+17 -4
View File
@@ -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<WidgetText>,
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
+27 -20
View File
@@ -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");
}
}
+1 -1
View File
@@ -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`.
+74 -12
View File
@@ -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<bool> = 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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
+14 -7
View File
@@ -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