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); + } +}