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.
This commit is contained in:
aiongg
2026-10-11 02:42:25 +00:00
parent 80b9b1d037
commit 94fa7dc2ad
3 changed files with 206 additions and 0 deletions
+89
View File
@@ -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<egui::ViewportCommand> {
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<egui::ViewportCommand> {
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<egui::ViewportCommand> {
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;
+1
View File
@@ -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;
+116
View File
@@ -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<DoubleClick> = 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<egui::ViewportCommand> {
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::<Option<f64>>(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::<Option<f64>>(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);
}
}