diff --git a/Cargo.lock b/Cargo.lock index 5cdae71d..76cf9b86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,6 +343,7 @@ dependencies = [ "gtk", "libc", "mac-notification-sys", + "objc2-foundation", "portable-pty", "serde", "tauri", diff --git a/docs/shell-design.md b/docs/shell-design.md index 098bbf88..60b9de3a 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -62,10 +62,14 @@ lights have a reserved 104px left area before the community switcher only in the macOS desktop runtime. This inset does not move the centered tabs. Web gets no inset or imitation window controls. Other platforms retain their native decorations. Drag regions are limited to the -header background; controls remain clickable. The main-window capability grants -only titlebar dragging and the internal native maximize action used by Tauri's drag -handler, plus scoped HTTP(S) opening for [external links](channels.md#run-the-integration). -See [Tauri window customization](https://v2.tauri.app/learn/window-customization/). +header background; controls remain clickable. On macOS, double-clicking that +background follows the current system title-bar preference (Fill/Zoom, Minimize, +or no action); changing the preference does not require restarting Buzz. Other +platforms retain Tauri's native drag-region behavior. The main-window capability +grants only titlebar dragging and the internal maximize action used by that +handler, plus scoped HTTP(S) opening for +[external links](channels.md#run-the-integration). See +[Tauri window customization](https://v2.tauri.app/learn/window-customization/). The top-right group contains enabled plugin launchers (Bestie supplies the snake), a page finder, and the local avatar. `ProfileButton.tsx` subscribes to the community @@ -103,7 +107,8 @@ behind, never over, opaque cards; it makes no relay request at runtime. Run `just iterate` for UI changes and `just scan` for the broader review checks. Check Home, Messages, and Settings; toggle a bundled plugin off/on and confirm its navigation entry follows; inspect a narrow viewport. On macOS, verify titlebar -alignment, dragging, double-click zoom, and Settings access in a built app. +alignment, dragging, each macOS title-bar double-click preference, and Settings +access in a built app. ## Messages diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 95ce2f33..3b9dc61c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ libc = "0.2" [target.'cfg(target_os = "macos")'.dependencies] mac-notification-sys = "=0.6.15" +objc2-foundation = { version = "0.3", default-features = false, features = ["NSString", "NSUserDefaults"] } [target.'cfg(target_os = "windows")'.dependencies] tauri-winrt-notification = "=0.7.3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a2a84f1c..c467939b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,52 @@ use tauri_plugin_dialog::DialogExt; #[derive(Clone, Default)] struct Imports(Arc>>); +#[derive(Debug, PartialEq, Eq)] +enum TitleBarDoubleClickAction { + Maximize, + Minimize, + None, +} + +fn title_bar_double_click_action(preference: Option<&str>) -> TitleBarDoubleClickAction { + match preference { + Some("Maximize" | "Zoom" | "Fill") => TitleBarDoubleClickAction::Maximize, + Some("Minimize") => TitleBarDoubleClickAction::Minimize, + _ => TitleBarDoubleClickAction::None, + } +} + +#[tauri::command] +fn title_bar_double_click(window: tauri::Window) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + use objc2_foundation::{ns_string, NSUserDefaults}; + + let preference = NSUserDefaults::standardUserDefaults() + .stringForKey(ns_string!("AppleActionOnDoubleClick")) + .map(|value| value.to_string()); + match title_bar_double_click_action(preference.as_deref()) { + TitleBarDoubleClickAction::Maximize => { + if window.is_maximized().map_err(|error| error.to_string())? { + window.unmaximize() + } else { + window.maximize() + } + .map_err(|error| error.to_string())?; + } + TitleBarDoubleClickAction::Minimize => { + window.minimize().map_err(|error| error.to_string())?; + } + TitleBarDoubleClickAction::None => {} + } + } + + #[cfg(not(target_os = "macos"))] + let _ = window; + + Ok(()) +} + async fn prepare_import( imports: Imports, operation: impl FnOnce() -> Result, String> + Send + 'static, @@ -163,6 +209,7 @@ pub fn run() { .manage(Notifications::default()) .manage(PluginManager(Manager::from_env())) .invoke_handler(tauri::generate_handler![ + title_bar_double_click, notification_show, terminal_create_owner, terminal_spawn, @@ -190,3 +237,32 @@ pub fn run() { } }); } + +#[cfg(test)] +mod tests { + use super::{title_bar_double_click_action, TitleBarDoubleClickAction}; + + #[test] + fn title_bar_double_click_preferences_map_to_native_actions() { + assert_eq!( + title_bar_double_click_action(Some("Maximize")), + TitleBarDoubleClickAction::Maximize + ); + assert_eq!( + title_bar_double_click_action(Some("Fill")), + TitleBarDoubleClickAction::Maximize + ); + assert_eq!( + title_bar_double_click_action(Some("Minimize")), + TitleBarDoubleClickAction::Minimize + ); + assert_eq!( + title_bar_double_click_action(Some("None")), + TitleBarDoubleClickAction::None + ); + assert_eq!( + title_bar_double_click_action(None), + TitleBarDoubleClickAction::None + ); + } +} diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index 5091fced..00a34214 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -8,8 +8,10 @@ import { ProfileButton } from "./ProfileButton"; import { PageSearch } from "./PageSearch"; import { orderPages, pagePresentation } from "./presentation"; import { PanelFrame } from "../../features/panels/PanelFrame"; +import { macTitleBarDragHandlers } from "./title-bar"; const macDesktop = isTauri() && /Mac/i.test(navigator.platform); +const titleBarDragProps = macDesktop ? macTitleBarDragHandlers : {}; export function AppShell({ pages, @@ -54,10 +56,15 @@ export function AppShell({ Skip to content
-
+
{navigationControls} -
+
{launchers} = {}, +) { + const currentTarget = {}; + return { + button: overrides.button ?? 0, + clientX: overrides.clientX ?? 40, + clientY: overrides.clientY ?? 12, + currentTarget, + detail, + preventDefault: vi.fn(), + target: overrides.sameTarget === false ? {} : currentTarget, + } as unknown as Parameters< + ReturnType["onMouseDown"] + >[0]; +} + +describe("macOS title bar dragging", () => { + it("starts dragging on a primary single click of the region itself", () => { + const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; + const handlers = createTitleBarDragHandlers(actions); + const event = mouseEvent(1); + + handlers.onMouseDown(event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(actions.startDragging).toHaveBeenCalledOnce(); + expect(actions.doubleClick).not.toHaveBeenCalled(); + }); + + it("runs the native preference action after an unmoved double click", () => { + const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; + const handlers = createTitleBarDragHandlers(actions); + const down = mouseEvent(2); + const up = mouseEvent(2); + + handlers.onMouseDown(down); + handlers.onMouseUp(up); + + expect(up.preventDefault).toHaveBeenCalledOnce(); + expect(actions.doubleClick).toHaveBeenCalledOnce(); + expect(actions.startDragging).not.toHaveBeenCalled(); + }); + + it("cancels the double-click action after movement", () => { + const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; + const handlers = createTitleBarDragHandlers(actions); + + handlers.onMouseDown(mouseEvent(2)); + handlers.onMouseUp(mouseEvent(2, { clientX: 41 })); + + expect(actions.doubleClick).not.toHaveBeenCalled(); + }); + + it("ignores interactive descendants and non-primary clicks", () => { + const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; + const handlers = createTitleBarDragHandlers(actions); + + handlers.onMouseDown(mouseEvent(1, { sameTarget: false })); + handlers.onMouseDown(mouseEvent(1, { button: 1 })); + handlers.onMouseDown(mouseEvent(2, { sameTarget: false })); + handlers.onMouseUp(mouseEvent(2, { sameTarget: false })); + + expect(actions.startDragging).not.toHaveBeenCalled(); + expect(actions.doubleClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/shell/title-bar.ts b/src/app/shell/title-bar.ts new file mode 100644 index 00000000..17abcf58 --- /dev/null +++ b/src/app/shell/title-bar.ts @@ -0,0 +1,64 @@ +import type { MouseEventHandler } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; + +type TitleBarActions = Readonly<{ + startDragging: () => void; + doubleClick: () => void; +}>; + +export type TitleBarDragHandlers = Readonly<{ + onMouseDown: MouseEventHandler; + onMouseUp: MouseEventHandler; +}>; + +export function createTitleBarDragHandlers( + actions: TitleBarActions, +): TitleBarDragHandlers { + let doubleClickStart: Readonly<{ x: number; y: number }> | undefined; + + return { + onMouseDown(event) { + if ( + event.button !== 0 || + event.target !== event.currentTarget || + (event.detail !== 1 && event.detail !== 2) + ) { + return; + } + + if (event.detail === 2) { + doubleClickStart = { x: event.clientX, y: event.clientY }; + return; + } + + event.preventDefault(); + actions.startDragging(); + }, + onMouseUp(event) { + const start = doubleClickStart; + doubleClickStart = undefined; + if ( + event.button !== 0 || + event.detail !== 2 || + event.target !== event.currentTarget || + start?.x !== event.clientX || + start.y !== event.clientY + ) { + return; + } + + event.preventDefault(); + actions.doubleClick(); + }, + }; +} + +export const macTitleBarDragHandlers = createTitleBarDragHandlers({ + startDragging: () => { + void getCurrentWindow().startDragging(); + }, + doubleClick: () => { + void invoke("title_bar_double_click"); + }, +});