From d4e2035fdf8a1cd3403886eea94a49133aed6470 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 15 Nov 2026 15:59:49 +0300 Subject: [PATCH] feat(ui): OS-native window controls and macOS Overlay titlebar Use system traffic lights on macOS via Overlay chrome, and Fluent SVG caption buttons on Windows/Linux instead of Unicode glyphs. Closes #1986 --- apps/tauri/README.md | 7 ++ apps/tauri/src-tauri/src/lib.rs | 26 +++++ apps/tauri/src-tauri/tauri.conf.json | 10 +- apps/tauri/src/styles/tokens.css | 2 + apps/tauri/src/ui/TitleBar.vue | 164 +++++++++++++-------------- apps/tauri/src/ui/platform.test.ts | 30 +++++ apps/tauri/src/ui/platform.ts | 9 ++ 7 files changed, 165 insertions(+), 83 deletions(-) create mode 100644 apps/tauri/src/ui/platform.test.ts diff --git a/apps/tauri/README.md b/apps/tauri/README.md index 2abc8da5..5460c456 100644 --- a/apps/tauri/README.md +++ b/apps/tauri/README.md @@ -77,6 +77,13 @@ cargo check --locked cargo test --locked ``` +## Window chrome + +- **Windows / Linux:** undecorated; HTML title bar draws Fluent-style SVG caption buttons (min / max-restore / close). +- **macOS:** `decorations: true` + `titleBarStyle: Overlay` / `hiddenTitle` / `trafficLightPosition` so **system traffic lights** sit in the custom bar. Frontend reserves `--q-mac-traffic-inset` (no fake CSS lights; same idea as Flutter `useNativeWindowChrome`). + +Main window: `"create": false` in `tauri.conf.json`, opened from Rust (`open_main_window`) so macOS can enable decorations without affecting Win/Linux. + ## Layout ``` diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 9da0fd80..16eb0eb9 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -39,6 +39,29 @@ fn host_platform() -> String { } } +/// Build the main window from `tauri.conf.json`, applying OS chrome rules. +/// +/// - **macOS:** native traffic lights via `titleBarStyle: Overlay` + `hiddenTitle` +/// (decorations stay on). Custom HTML title bar sits under the system controls. +/// - **Windows / Linux:** undecorated; frontend draws Fluent-style caption buttons. +fn open_main_window(app: &tauri::App) -> tauri::Result<()> { + use tauri::WebviewWindowBuilder; + + let mut cfg = app + .config() + .app + .windows + .first() + .cloned() + .ok_or_else(|| tauri::Error::WindowNotFound)?; + + // Overlay + hiddenTitle + trafficLightPosition stay in tauri.conf.json (macOS). + cfg.decorations = cfg!(target_os = "macos"); + + WebviewWindowBuilder::from_config(app.handle(), &cfg)?.build()?; + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -52,6 +75,9 @@ pub fn run() { std::io::Error::other(format!("open profile store: {e}")) })?; app.manage(store); + open_main_window(app).map_err(|e| { + std::io::Error::other(format!("open main window: {e}")) + })?; Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/apps/tauri/src-tauri/tauri.conf.json b/apps/tauri/src-tauri/tauri.conf.json index 8b6e0f41..ece27d33 100644 --- a/apps/tauri/src-tauri/tauri.conf.json +++ b/apps/tauri/src-tauri/tauri.conf.json @@ -12,6 +12,8 @@ "app": { "windows": [ { + "label": "main", + "create": false, "title": "Querya", "width": 1280, "height": 800, @@ -19,7 +21,13 @@ "minHeight": 600, "decorations": false, "resizable": true, - "fullscreen": false + "fullscreen": false, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "trafficLightPosition": { + "x": 16, + "y": 13 + } } ], "security": { diff --git a/apps/tauri/src/styles/tokens.css b/apps/tauri/src/styles/tokens.css index 8d01acdc..81856b61 100644 --- a/apps/tauri/src/styles/tokens.css +++ b/apps/tauri/src/styles/tokens.css @@ -18,6 +18,8 @@ --q-control-h-md: 32px; --q-control-h-lg: 36px; --q-titlebar-h: 40px; + /* Space reserved for macOS system traffic lights under Overlay titlebar. */ + --q-mac-traffic-inset: 78px; --q-statusbar-h: 26px; --q-split-handle: 6px; --q-sidebar-min: 180px; diff --git a/apps/tauri/src/ui/TitleBar.vue b/apps/tauri/src/ui/TitleBar.vue index dde34fa4..166a7d7d 100644 --- a/apps/tauri/src/ui/TitleBar.vue +++ b/apps/tauri/src/ui/TitleBar.vue @@ -4,6 +4,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { detectPlatformFromUa, resolvePlatform, + usesNativeTrafficLights, windowControlsSide, type QueryaPlatform, } from "./platform"; @@ -33,15 +34,23 @@ const shell = useShellStore(); const platform = ref(detectPlatformFromUa()); const maximized = ref(false); -/** macOS: left traffic lights. Win/Linux: right caption buttons. */ +/** macOS: left (native). Win/Linux: right caption buttons. */ const controlsSide = computed(() => windowControlsSide(platform.value)); -const controlsOnLeft = computed(() => controlsSide.value === "left"); +const nativeTrafficLights = computed(() => + usesNativeTrafficLights(platform.value), +); const barStyle = computed(() => ({ transitionDuration: `${motion.duration("fast")}ms`, transitionTimingFunction: motion.easing("standard"), })); +function isChromeInteractive(target: EventTarget | null): boolean { + return Boolean( + (target as HTMLElement | null)?.closest("button, a, input, [data-no-drag]"), + ); +} + async function refreshMaximized() { try { maximized.value = await getCurrentWindow().isMaximized(); @@ -64,9 +73,7 @@ async function onClose() { } async function onDrag(e: MouseEvent) { - if (e.button !== 0) return; - const target = e.target as HTMLElement | null; - if (target?.closest("button, a, input, [data-no-drag]")) return; + if (e.button !== 0 || isChromeInteractive(e.target)) return; try { await getCurrentWindow().startDragging(); } catch { @@ -75,8 +82,7 @@ async function onDrag(e: MouseEvent) { } function onDblClick(e: MouseEvent) { - const target = e.target as HTMLElement | null; - if (target?.closest("button, a, input, [data-no-drag]")) return; + if (isChromeInteractive(e.target)) return; void onToggleMaximize(); } @@ -105,32 +111,19 @@ onUnmounted(() => { data-testid="title-bar" :data-platform="platform" :data-controls="controlsSide" + :data-native-traffic="String(nativeTrafficLights)" :style="barStyle" @mousedown="onDrag" @dblclick="onDblClick" > - +
-
-
+ +
- - @@ -288,45 +305,17 @@ onUnmounted(() => { flex: 0 0 auto; } -/* macOS traffic lights — close / minimize / maximize */ -.controls--traffic { - gap: 8px; - padding: 0 4px 0 6px; - min-width: 52px; -} - -.traffic { - width: 12px; - height: 12px; - border-radius: 999px; - border: 0; - padding: 0; - cursor: default; - background: var(--q-accent); -} - -.traffic.close { - background: #ff5f57; -} -.traffic.minimize { - background: #febc2e; -} -.traffic.maximize { - background: #28c840; -} - -.traffic:hover { - filter: brightness(1.06); -} - -.mac-balance { - /* traffic cluster (~52) + sidebar toggle (~28) + gaps ≈ start cluster */ - width: 5.5rem; +/* Reserved width for system traffic lights (Overlay titlebar on macOS). + Native lights use window coords; counter-scale so UI zoom does not shrink the gap. */ +.mac-inset { + display: inline-block; + width: calc(var(--q-mac-traffic-inset) / var(--q-ui-scale, 1)); height: 1px; + flex: 0 0 auto; pointer-events: none; } -/* Windows / Linux caption buttons — min / max / close on the right */ +/* Windows / Linux — Fluent-style caption buttons (min / max / close). */ .controls--caption { gap: 0; height: 100%; @@ -352,17 +341,28 @@ onUnmounted(() => { } .caption.close:hover { - background: var(--q-destructive); - color: var(--q-destructive-foreground); + background: #e81123; + color: #ffffff; } .glyph { - font-size: 11px; - line-height: 1; + display: block; + fill: none; + stroke: currentColor; + stroke-width: 1; + stroke-linecap: round; +} + +.glyph--join { + stroke-linejoin: round; +} + +.glyph--close { + stroke-width: 1.1; } -/* Extra inset on macOS so lights clear the rounded corner / notch feel */ -.titlebar[data-controls="left"] .region--start { - padding-left: 12px; +/* macOS Overlay: content starts after the system lights. */ +.titlebar[data-native-traffic="true"] .region--start { + padding-left: 0; } diff --git a/apps/tauri/src/ui/platform.test.ts b/apps/tauri/src/ui/platform.test.ts new file mode 100644 index 00000000..783d649c --- /dev/null +++ b/apps/tauri/src/ui/platform.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + platformFromOsString, + usesNativeTrafficLights, + windowControlsSide, +} from "./platform"; + +describe("window chrome platform helpers", () => { + it("maps OS strings to platforms", () => { + expect(platformFromOsString("macos")).toBe("macos"); + expect(platformFromOsString("darwin")).toBe("macos"); + expect(platformFromOsString("windows")).toBe("windows"); + expect(platformFromOsString("linux")).toBe("linux"); + expect(platformFromOsString("freebsd")).toBe("unknown"); + }); + + it("places controls left only on macOS", () => { + expect(windowControlsSide("macos")).toBe("left"); + expect(windowControlsSide("windows")).toBe("right"); + expect(windowControlsSide("linux")).toBe("right"); + expect(windowControlsSide("unknown")).toBe("right"); + }); + + it("uses native traffic lights only on macOS", () => { + expect(usesNativeTrafficLights("macos")).toBe(true); + expect(usesNativeTrafficLights("windows")).toBe(false); + expect(usesNativeTrafficLights("linux")).toBe(false); + expect(usesNativeTrafficLights("unknown")).toBe(false); + }); +}); diff --git a/apps/tauri/src/ui/platform.ts b/apps/tauri/src/ui/platform.ts index 87adefa9..2573973f 100644 --- a/apps/tauri/src/ui/platform.ts +++ b/apps/tauri/src/ui/platform.ts @@ -48,10 +48,19 @@ export function windowControlsSide(platform: QueryaPlatform): WindowControlsSide return platform === "macos" ? "left" : "right"; } +/** + * macOS uses system traffic lights (`titleBarStyle: Overlay`). + * Win/Linux draw custom caption buttons in the HTML title bar. + */ +export function usesNativeTrafficLights(platform: QueryaPlatform): boolean { + return platform === "macos"; +} + export function applyPlatformDataset(platform: QueryaPlatform): void { const root = document.documentElement; root.dataset.platform = platform; root.dataset.windowControls = windowControlsSide(platform); + root.dataset.nativeTrafficLights = String(usesNativeTrafficLights(platform)); } /** Resolve OS via Rust, fall back to UA when not in Tauri. */