diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a766e8cbe..9de56d2e8 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -10,15 +10,47 @@ 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(); + 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 @@ -28,7 +60,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 +179,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)); } @@ -630,6 +662,109 @@ 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:?}"); + } + } + + 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 e4ac5fab3..e0e293f12 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,6 +25,7 @@ mod section_options; 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); + } +}