fix: respect macos header double click action
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user