From b7f482bf275afaa87f93f0d32ba05505f7ad0a60 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 24 Apr 2026 07:47:53 +0200 Subject: [PATCH] fix: respect macos header double click action --- src-tauri/src/commands/system.rs | 142 ++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/components/BreadcrumbBar.test.tsx | 15 +++ src/components/BreadcrumbBar.tsx | 4 + src/hooks/useDragRegion.test.tsx | 22 +++- src/hooks/useDragRegion.ts | 22 +++- 6 files changed, 201 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 388a1a29..978fc0c8 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -1,3 +1,6 @@ +#[cfg(desktop)] +use std::process::Command; + #[cfg(desktop)] use crate::menu; use crate::settings::Settings; @@ -13,6 +16,76 @@ use tauri::Window; use super::parse_build_label; +#[cfg(desktop)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TitleBarDoubleClickAction { + Fill, + Minimize, + None, +} + +#[cfg(desktop)] +fn parse_title_bar_double_click_action(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "fill" | "zoom" | "maximize" => Some(TitleBarDoubleClickAction::Fill), + "minimize" => Some(TitleBarDoubleClickAction::Minimize), + "none" | "no action" | "do nothing" => Some(TitleBarDoubleClickAction::None), + _ => None, + } +} + +#[cfg(desktop)] +fn parse_legacy_title_bar_double_click_action(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" => Some(TitleBarDoubleClickAction::Minimize), + "0" | "false" | "no" => Some(TitleBarDoubleClickAction::Fill), + _ => None, + } +} + +#[cfg(desktop)] +fn read_global_defaults_value(key: &str) -> Option { + let output = Command::new("defaults") + .args(["read", "-g", key]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let value = String::from_utf8(output.stdout).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + Some(trimmed.to_string()) +} + +#[cfg(desktop)] +fn resolve_title_bar_double_click_action( + read_value: impl Fn(&str) -> Option, +) -> TitleBarDoubleClickAction { + read_value("AppleActionOnDoubleClick") + .as_deref() + .and_then(parse_title_bar_double_click_action) + .or_else(|| { + read_value("AppleMiniaturizeOnDoubleClick") + .as_deref() + .and_then(parse_legacy_title_bar_double_click_action) + }) + .unwrap_or(TitleBarDoubleClickAction::Fill) +} + +#[cfg(desktop)] +fn toggle_window_fill(window: &Window) -> Result<(), String> { + if window.is_maximized().map_err(|e| e.to_string())? { + window.unmaximize().map_err(|e| e.to_string()) + } else { + window.maximize().map_err(|e| e.to_string()) + } +} + // ── MCP commands (desktop) ────────────────────────────────────────────────── #[cfg(desktop)] @@ -153,6 +226,16 @@ pub fn update_current_window_min_size( .map_err(|e| e.to_string()) } +#[cfg(desktop)] +#[tauri::command] +pub fn perform_current_window_titlebar_double_click(window: Window) -> Result<(), String> { + match resolve_title_bar_double_click_action(read_global_defaults_value) { + TitleBarDoubleClickAction::Fill => toggle_window_fill(&window), + TitleBarDoubleClickAction::Minimize => window.minimize().map_err(|e| e.to_string()), + TitleBarDoubleClickAction::None => Ok(()), + } +} + #[cfg(mobile)] #[tauri::command] pub fn update_current_window_min_size( @@ -164,6 +247,12 @@ pub fn update_current_window_min_size( Ok(()) } +#[cfg(mobile)] +#[tauri::command] +pub fn perform_current_window_titlebar_double_click(_window: tauri::Window) -> Result<(), String> { + Ok(()) +} + // ── Settings & config commands ────────────────────────────────────────────── #[tauri::command] @@ -242,3 +331,56 @@ pub fn load_vault_list() -> Result { pub fn save_vault_list(list: VaultList) -> Result<(), String> { vault_list::save_vault_list(&list) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_current_title_bar_actions() { + assert_eq!( + parse_title_bar_double_click_action("Fill"), + Some(TitleBarDoubleClickAction::Fill) + ); + assert_eq!( + parse_title_bar_double_click_action("zoom"), + Some(TitleBarDoubleClickAction::Fill) + ); + assert_eq!( + parse_title_bar_double_click_action("Minimize"), + Some(TitleBarDoubleClickAction::Minimize) + ); + assert_eq!( + parse_title_bar_double_click_action("No Action"), + Some(TitleBarDoubleClickAction::None) + ); + } + + #[test] + fn resolves_current_setting_before_legacy_fallback() { + let action = resolve_title_bar_double_click_action(|key| match key { + "AppleActionOnDoubleClick" => Some("No Action".to_string()), + "AppleMiniaturizeOnDoubleClick" => Some("1".to_string()), + _ => None, + }); + + assert_eq!(action, TitleBarDoubleClickAction::None); + } + + #[test] + fn falls_back_to_legacy_minimize_setting() { + let action = resolve_title_bar_double_click_action(|key| match key { + "AppleMiniaturizeOnDoubleClick" => Some("1".to_string()), + _ => None, + }); + + assert_eq!(action, TitleBarDoubleClickAction::Minimize); + } + + #[test] + fn defaults_to_fill_when_no_setting_is_available() { + let action = resolve_title_bar_double_click_action(|_| None); + + assert_eq!(action, TitleBarDoubleClickAction::Fill); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc8174b8..e72c22df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -254,6 +254,7 @@ macro_rules! app_invoke_handler { commands::update_menu_state, commands::trigger_menu_command, commands::update_current_window_min_size, + commands::perform_current_window_titlebar_double_click, commands::save_settings, commands::download_and_install_app_update, commands::load_vault_list, diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index 65920925..15106a02 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -3,6 +3,12 @@ import { describe, it, expect, vi } from 'vitest' import { BreadcrumbBar } from './BreadcrumbBar' import type { VaultEntry } from '../types' +const dragRegionMouseDown = vi.fn() + +vi.mock('../hooks/useDragRegion', () => ({ + useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }), +})) + const baseEntry: VaultEntry = { path: '/vault/note/test.md', filename: 'test.md', @@ -63,6 +69,15 @@ async function expectTooltip(trigger: HTMLElement, ...parts: string[]) { } describe('BreadcrumbBar — drag region', () => { + it('forwards mousedown events to the shared drag-region hook', () => { + const { container } = render() + const bar = container.querySelector('.breadcrumb-bar') as HTMLElement + + fireEvent.mouseDown(bar, { button: 0 }) + + expect(dragRegionMouseDown).toHaveBeenCalledOnce() + }) + it('has data-tauri-drag-region on the container', () => { const { container } = render() const bar = container.firstElementChild as HTMLElement diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index a53732e3..56e1fb74 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -19,6 +19,7 @@ import { } from '@phosphor-icons/react' import { NoteTitleIcon } from './NoteTitleIcon' import { slugify } from '../hooks/useNoteCreation' +import { useDragRegion } from '../hooks/useDragRegion' interface BreadcrumbBarProps { entry: VaultEntry @@ -501,12 +502,15 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({ onRenameFilename, ...actionProps }: BreadcrumbBarProps) { + const { onMouseDown } = useDragRegion() + return (
({ + invoke: vi.fn().mockResolvedValue(undefined), + startDragging: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke, +})) vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ startDragging }), @@ -31,6 +38,16 @@ describe('useDragRegion', () => { fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 0 }) expect(startDragging).toHaveBeenCalledOnce() + expect(invoke).not.toHaveBeenCalled() + }) + + it('runs the native title-bar action on a double-click', () => { + render() + + fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 0, detail: 2 }) + + expect(invoke).toHaveBeenCalledWith('perform_current_window_titlebar_double_click') + expect(startDragging).not.toHaveBeenCalled() }) it('does not start dragging from no-drag containers', () => { @@ -39,6 +56,7 @@ describe('useDragRegion', () => { fireEvent.mouseDown(screen.getByTestId('no-drag-card'), { button: 0 }) expect(startDragging).not.toHaveBeenCalled() + expect(invoke).not.toHaveBeenCalled() }) it('does not start dragging from interactive descendants', () => { @@ -47,6 +65,7 @@ describe('useDragRegion', () => { fireEvent.mouseDown(screen.getByRole('button', { name: 'Action' }), { button: 0 }) expect(startDragging).not.toHaveBeenCalled() + expect(invoke).not.toHaveBeenCalled() }) it('ignores non-primary mouse buttons', () => { @@ -55,5 +74,6 @@ describe('useDragRegion', () => { fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 1 }) expect(startDragging).not.toHaveBeenCalled() + expect(invoke).not.toHaveBeenCalled() }) }) diff --git a/src/hooks/useDragRegion.ts b/src/hooks/useDragRegion.ts index e32fe0eb..abfa70d3 100644 --- a/src/hooks/useDragRegion.ts +++ b/src/hooks/useDragRegion.ts @@ -1,5 +1,16 @@ -import { useCallback } from 'react' +import { invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' +import { useCallback } from 'react' + +const NO_DRAG_SELECTOR = 'button, input, select, a, [data-no-drag]' + +function isDragDisabledTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest(NO_DRAG_SELECTOR) !== null +} + +function performCurrentWindowTitlebarDoubleClick(): Promise { + return invoke('perform_current_window_titlebar_double_click') +} /** * Returns a mousedown handler that triggers Tauri window drag via startDragging(). @@ -8,10 +19,13 @@ import { getCurrentWindow } from '@tauri-apps/api/window' export function useDragRegion() { const onMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return - const target = e.target as HTMLElement - if (target.closest('button, input, select, a, [data-no-drag]')) return + if (isDragDisabledTarget(e.target)) return e.preventDefault() - getCurrentWindow().startDragging().catch(() => { /* ignore */ }) + if (e.detail === 2) { + void performCurrentWindowTitlebarDoubleClick().catch(() => {}) + return + } + void getCurrentWindow().startDragging().catch(() => {}) }, []) return { onMouseDown }