Merge mac-titlebar: macOS: the window buttons sit centred in the title bar; double-clicking the title bar zooms or minimizes, and dragging it moves the window

No conflicts. chrome.rs, lib.rs and the new title_bar.rs merged
automatically; app_bar keeps next's controls (welcome, menus, scrolling
bar) inside the branch's 28 pt macOS bar.
This commit is contained in:
aiongg
2026-10-11 02:47:43 +00:00
3 changed files with 259 additions and 7 deletions
+142 -7
View File
@@ -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<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
@@ -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;
+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);
}
}