From 80b9b1d037f3d300ccf4b44eb1731a0a38f5b0e6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 01:58:12 +0000 Subject: [PATCH 1/2] macOS: the window buttons sit centred in the title bar On macOS the application bar is the window's title bar. AppKit centres the traffic lights in the standard 28 pt title-bar band and they cannot be moved without unsafe code, so the bar is 28 pt there: the lights sit vertically centred in it, and the bar's content starts after them with the same 8 pt gap the lights keep from the top. The Home button shrinks to 22 pt to keep a margin in the shorter bar. The bar's frame margin leaves room for the panel separator, so the content row is the full bar height and the title and controls share one centre line on every platform. Fixes #333. --- crates/ui-egui/src/chrome.rs | 60 +++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2896fcdc4..2ea3ed238 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -10,13 +10,36 @@ use crate::theme::{Tokens, semibold}; use crate::widgets::{caption, measure, number}; use crate::{DesignApp, icons}; +/// Height of the application bar (InDesign 2026), then a 7 pt lower band and a 1 pt border. The +/// band covers the panel's 1 pt separator line, so the frame's bottom margin is 1 pt short of it. +const APP_BAR: f32 = 36.0; +const APP_BAR_BAND: f32 = 7.0; +const SEPARATOR: f32 = 1.0; +/// Height of the application bar on macOS, where it is the window's title bar (#333). AppKit draws +/// the traffic lights itself, centred in the standard 28 pt title-bar band at the top of the +/// window; moving them would take AppKit calls (`unsafe`, which the workspace forbids). So the bar +/// is that band: the lights sit vertically centred in it, with the bar's controls beside them. +pub const MAC_APP_BAR: f32 = 28.0; +/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the +/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the +/// gap after them from shrinking). +const MAC_LIGHT: f32 = 12.0; +const MAC_LIGHT_PITCH: f32 = 20.0; +const MAC_LIGHTS_LEFT: f32 = 10.0; +/// Where the bar's content starts to the right of the traffic lights: the end of the three lights +/// plus the same gap the lights keep from the top of the window. +pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_APP_BAR - MAC_LIGHT) / 2.0; + pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let left = if app.integrated_titlebar { 78 } else { 8 }; - // 36 pt bar + 7 pt lower band (InDesign 2026), then a 1 pt border. + let mac = app.integrated_titlebar; + let left = if mac { MAC_CONTENT_LEFT as i8 } else { 8 }; + let bar = if mac { MAC_APP_BAR } else { APP_BAR }; + // The tallest control keeps a margin above and below it in the shorter macOS bar. + let home = if mac { 22.0 } else { 24.0 }; let resp = egui::Panel::top("app_bar") - .exact_size(43.0) - .frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 7 })) + .exact_size(bar + APP_BAR_BAND) + .frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: (APP_BAR_BAND - SEPARATOR) as i8 })) .show(ui, |ui| { let full = ui.max_rect(); let mut menus_end = full.min.x; @@ -28,7 +51,7 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { crate::widgets::overflow_scrolling(ui); egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| { ui.horizontal_centered(|ui| { - if icons::button(ui, "home", 24.0, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() { + if icons::button(ui, "home", home, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() { app.session_home(); } ui.add_space(6.0); @@ -147,11 +170,11 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let w = galley.size().x; let cx = full.center().x; if cx - w / 2.0 > menus_end + 12.0 && cx + w / 2.0 < tools_start - 12.0 { - ui.painter().galley(egui::pos2(cx - w / 2.0, full.min.y + 18.0 - galley.size().y / 2.0), galley, t.text); + ui.painter().galley(egui::pos2(cx - w / 2.0, full.center().y - galley.size().y / 2.0), galley, t.text); } }); let r = resp.response.rect; - ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(r.min.x, r.max.y - 7.0), r.max), 0.0, t.pasteboard); + ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(r.min.x, r.max.y - APP_BAR_BAND), r.max), 0.0, t.pasteboard); ui.painter().line_segment([egui::pos2(r.min.x, r.max.y), egui::pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border)); } @@ -807,6 +830,29 @@ mod tests { assert!(item.min.y > fill.max.y && (item.min.x - chip.x).abs() < 120.0, "popup item at {item:?}, control at {chip:?}"); } + #[test] + fn the_mac_app_bar_centres_and_clears_the_traffic_lights() { + use super::{MAC_APP_BAR, MAC_CONTENT_LEFT, MAC_LIGHT, MAC_LIGHT_PITCH, MAC_LIGHTS_LEFT}; + // Content keeps the same gap after the lights as the lights keep from the top. + let margin = (MAC_APP_BAR - MAC_LIGHT) / 2.0; + assert_eq!(MAC_CONTENT_LEFT - (MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT), margin); + // `egui::Margin` is in `i8`. + assert!(MAC_CONTENT_LEFT <= f32::from(i8::MAX)); + let mut app = app(); + app.integrated_titlebar = true; + let h = test_window::open(app, vec2(1400.0, 900.0)); + // The bar's controls share the lights' centre line and start after them (relative to the + // bar, which the test window insets). + let bar = test_window::panel_rect(&h, "app_bar").min.to_vec2(); + let home = rect(&h, "Home").translate(-bar); + assert_eq!(home.center().y, MAC_APP_BAR / 2.0, "{home:?}"); + assert!(home.min.y >= 2.0 && home.min.x >= MAC_CONTENT_LEFT, "{home:?}"); + for label in ["File", "Help", "Share"] { + let r = rect(&h, label).translate(-bar); + assert!((r.center().y - MAC_APP_BAR / 2.0).abs() <= 0.5 && r.max.y <= MAC_APP_BAR, "{label} at {r:?}"); + } + } + #[test] fn a_narrow_window_scrolls_the_app_bar() { let w = 640.0; From 94fa7dc2ada7344270139ed8df14c90ebafc902e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 02:42:25 +0000 Subject: [PATCH 2/2] macOS: double-clicking the title bar zooms or minimizes, and dragging it moves the window The window has a full-size content view, so every click in the title-bar band reaches the app and AppKit neither drags nor zooms the window. An empty part of the application bar now acts as the title bar: - A double-click follows System Settings > Desktop & Dock > "Double-click a window's title bar to": zoom (the default), minimize or do nothing. The setting (AppleActionOnDoubleClick) is read once per run with `defaults read` on a background thread; until then, or if reading fails, the bar zooms. - A drag moves the window (ViewportCommand::StartDrag). The empty area senses clicks only and sits behind the bar's controls, so Home, the menus, search, the workspace menu, Share and Discord keep their clicks, and a drag that starts on one of them does not move the window. --- crates/ui-egui/src/chrome.rs | 89 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/title_bar.rs | 116 ++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 crates/ui-egui/src/title_bar.rs diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2ea3ed238..41f168508 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -42,6 +42,15 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: (APP_BAR_BAND - SEPARATOR) as i8 })) .show(ui, |ui| { let full = ui.max_rect(); + if mac { + // The whole title-bar band, margins included; the controls drawn below take + // their own clicks. + let band = egui::Rect::from_min_max( + egui::pos2(full.min.x - f32::from(left), full.min.y), + egui::pos2(full.max.x + 10.0, full.min.y + MAC_APP_BAR), + ); + crate::title_bar::empty_area(ui, band); + } let mut menus_end = full.min.x; let mut tools_start = full.max.x; // The right-hand controls' width, measured last frame: in a window too narrow for them @@ -853,6 +862,86 @@ mod tests { } } + fn press(h: &egui_kittest::Harness<'static, test_window::Window>, pos: egui::Pos2, pressed: bool) { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + + /// The window commands the app sends over the next `steps` frames. + fn window_commands(h: &mut egui_kittest::Harness<'static, test_window::Window>, steps: usize) -> Vec { + let mut out = Vec::new(); + for _ in 0..steps { + h.step(); + if let Some(v) = h.output().viewport_output.get(&egui::ViewportId::ROOT) { + out.extend(v.commands.iter().cloned()); + } + } + out + } + + fn double_click(h: &mut egui_kittest::Harness<'static, test_window::Window>, pos: egui::Pos2) -> Vec { + h.hover_at(pos); + h.step(); + // One frame: the harness gives each queued event a frame of its own, longer apart than a + // double-click allows. + for pressed in [true, false, true, false] { + h.input_mut().events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + } + window_commands(h, 3) + } + + fn drag(h: &mut egui_kittest::Harness<'static, test_window::Window>, from: egui::Pos2) -> Vec { + h.hover_at(from); + h.step(); + press(h, from, true); + let mut out = window_commands(h, 1); + for i in 1..=6 { + h.hover_at(from + vec2(8.0 * i as f32, 3.0)); + out.extend(window_commands(h, 1)); + } + press(h, from + vec2(48.0, 3.0), false); + out.extend(window_commands(h, 2)); + out + } + + #[test] + fn the_empty_mac_title_bar_zooms_and_moves_the_window() { + use super::{MAC_APP_BAR, MAC_CONTENT_LEFT}; + use egui::ViewportCommand::{Maximized, Minimized, StartDrag}; + let mut app = app(); + app.integrated_titlebar = true; + let mut h = test_window::open(app, vec2(1400.0, 900.0)); + let bar = test_window::panel_rect(&h, "app_bar"); + // Under the centred title, and between the traffic lights and the Home button. + let empty = [pos2(bar.center().x, bar.min.y + MAC_APP_BAR / 2.0), pos2(bar.min.x + MAC_CONTENT_LEFT - 4.0, bar.min.y + 4.0)]; + for at in empty { + // Tests read the setting as its default, zoom. + let cmds = double_click(&mut h, at); + assert_eq!(cmds, vec![Maximized(true)], "double-click at {at:?}"); + let cmds = drag(&mut h, at); + assert_eq!(cmds, vec![StartDrag], "drag from {at:?}"); + } + // The bar's controls keep their clicks and drags. + for label in ["Home", "Share", "File"] { + let at = rect(&h, label).center(); + let cmds = double_click(&mut h, at); + assert!(!cmds.iter().any(|c| matches!(c, Maximized(_) | Minimized(_))), "double-click on {label}: {cmds:?}"); + if label == "Home" { + assert_eq!(h.state().app.ui.status, "Home", "Home took its clicks"); + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + let cmds = drag(&mut h, at); + assert!(!cmds.contains(&StartDrag), "drag from {label}: {cmds:?}"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + } + } + #[test] fn a_narrow_window_scrolls_the_app_bar() { let w = 640.0; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb81e9c34..88eeee524 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ mod rtl; pub mod story_editor; pub mod taskbar; pub mod theme; +pub mod title_bar; pub mod toolbar; pub mod widgets; diff --git a/crates/ui-egui/src/title_bar.rs b/crates/ui-egui/src/title_bar.rs new file mode 100644 index 000000000..0210ddead --- /dev/null +++ b/crates/ui-egui/src/title_bar.rs @@ -0,0 +1,116 @@ +//! The application bar as the window's title bar (macOS): an empty part of it moves the window +//! when dragged and follows the system's title-bar double-click setting. +//! +//! The window has a full-size content view, so AppKit hands every click in the title-bar band to +//! the app (that is how the bar's own controls work) and neither drags nor zooms the window +//! itself. + +use std::sync::{Once, OnceLock}; + +/// System Settings › Desktop & Dock › "Double-click a window's title bar to". +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DoubleClick { + Zoom, + Minimize, + Nothing, +} + +/// The value of the global default `AppleActionOnDoubleClick`, as `defaults read` prints it. +/// Anything other than "Minimize" or "None" zooms, the macOS default ("Maximize"; macOS 15 adds +/// "Fill", which zooming comes closest to). +pub fn parse(defaults_output: &str) -> DoubleClick { + match defaults_output.trim() { + "Minimize" => DoubleClick::Minimize, + "None" => DoubleClick::Nothing, + _ => DoubleClick::Zoom, + } +} + +static ACTION: OnceLock = OnceLock::new(); +static READ: Once = Once::new(); + +/// Starts reading the system setting on a background thread, once per run. +fn prefetch() { + READ.call_once(|| { + #[cfg(all(target_os = "macos", not(test)))] + { + // No thread, no setting: the bar zooms. + let _ = std::thread::Builder::new().name("title-bar-setting".into()).spawn(|| { + let action = match std::process::Command::new("/usr/bin/defaults").args(["read", "-g", "AppleActionOnDoubleClick"]).output() { + Ok(out) if out.status.success() => parse(&String::from_utf8_lossy(&out.stdout)), + // Absent (never changed) or unreadable: the default. + _ => DoubleClick::Zoom, + }; + let _ = ACTION.set(action); + }); + } + }); +} + +/// The system setting; zoom until it has been read. +pub fn action() -> DoubleClick { + ACTION.get().copied().unwrap_or(DoubleClick::Zoom) +} + +/// What a double-click on the empty title bar asks of the window. +pub fn double_click_command(action: DoubleClick, maximized: bool) -> Option { + match action { + DoubleClick::Zoom => Some(egui::ViewportCommand::Maximized(!maximized)), + DoubleClick::Minimize => Some(egui::ViewportCommand::Minimized(true)), + DoubleClick::Nothing => None, + } +} + +/// Makes the empty part of `rect` act as a title bar. Call it before drawing the bar's controls: +/// they sit on top and take their own clicks. +/// +/// The area senses clicks only. A drag-sensing background would also receive drags that start +/// on a click-only control (egui gives clicks and drags to different widgets), so a drag is +/// detected here as a press on the empty area that has stopped being a click. +pub fn empty_area(ui: &mut egui::Ui, rect: egui::Rect) { + prefetch(); + let resp = ui.interact(rect, ui.id().with("title_bar_empty"), egui::Sense::click()); + if resp.double_clicked() { + let maximized = ui.input(|i| i.viewport().maximized).unwrap_or(false); + if let Some(cmd) = double_click_command(action(), maximized) { + ui.ctx().send_viewport_cmd(cmd); + } + } + // egui forgets which widget a press landed on once it can no longer be a click, so the press + // is remembered (by its start time) while it still can. + let armed = resp.id.with("drag_press"); + let press = ui.input(|i| i.pointer.press_start_time()); + if resp.is_pointer_button_down_on() { + ui.data_mut(|d| d.insert_temp(armed, press)); + } + if press.is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()) && ui.data(|d| d.get_temp::>(armed)) == Some(press) { + // Once per press: AppKit's drag loop takes the mouse-up, so egui may still see the button + // down afterwards. + ui.data_mut(|d| d.remove::>(armed)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_defaults_output() { + assert_eq!(parse("Maximize\n"), DoubleClick::Zoom); + assert_eq!(parse("Minimize\n"), DoubleClick::Minimize); + assert_eq!(parse("None\n"), DoubleClick::Nothing); + for junk in ["", "Fill", "minimize", "1", "The domain/default pair of (kCFPreferencesAnyApplication, X) does not exist"] { + assert_eq!(parse(junk), DoubleClick::Zoom, "{junk:?}"); + } + } + + #[test] + fn zoom_toggles_and_minimize_minimizes() { + use egui::ViewportCommand::{Maximized, Minimized}; + assert_eq!(double_click_command(DoubleClick::Zoom, false), Some(Maximized(true))); + assert_eq!(double_click_command(DoubleClick::Zoom, true), Some(Maximized(false))); + assert_eq!(double_click_command(DoubleClick::Minimize, false), Some(Minimized(true))); + assert_eq!(double_click_command(DoubleClick::Nothing, true), None); + } +}