mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 19:26:46 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user