fix: respect macos header double click action

This commit is contained in:
lucaronin
2026-04-24 07:47:53 +02:00
parent 33d7404d6c
commit b7f482bf27
6 changed files with 201 additions and 5 deletions

View File

@@ -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<TitleBarDoubleClickAction> {
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<TitleBarDoubleClickAction> {
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<String> {
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<String>,
) -> 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<VaultList, String> {
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);
}
}

View File

@@ -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,

View File

@@ -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(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
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(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.firstElementChild as HTMLElement

View File

@@ -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 (
<TooltipProvider>
<div
ref={barRef}
data-tauri-drag-region
data-title-hidden=""
onMouseDown={onMouseDown}
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{
height: 52,

View File

@@ -2,7 +2,14 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDragRegion } from './useDragRegion'
const startDragging = vi.fn().mockResolvedValue(undefined)
const { invoke, startDragging } = vi.hoisted(() => ({
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(<DragRegionHarness />)
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()
})
})

View File

@@ -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<void> {
return invoke<void>('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 }