From 9f39d6f29a7a52e5c7f9cd0369694f8f106ec672 Mon Sep 17 00:00:00 2001 From: Alireza Bashiri Date: Thu, 23 Jul 2026 18:35:07 +0700 Subject: [PATCH] feat(desktop): draw an integrated title bar on Windows macOS runs the main window with `titleBarStyle: "Overlay"`, so `AppTopChrome` covers the top edge and the window looks like one surface. Windows had nothing equivalent. It drew the system caption bar directly above our sidebar-colored chrome, and the seam showed. No Tauri setting tints that bar, so the only way to close the gap is to stop Windows drawing it. Turn decorations off once the webview is ready and let the web chrome own the whole top edge. `set_shadow(true)` puts back the drop shadow and rounded corners a window loses when it goes undecorated. Windows has no traffic lights, so the app draws minimize, maximize/restore and close itself. They follow the Fluent 46x32 metrics, sit flush against the edge, and the chrome drops its right padding while they are showing. They are sized in px rather than rem for the same reason the macOS traffic lights are: Ctrl +/- text zoom should not resize them. Dragging already worked, since the chrome carries `data-tauri-drag-region`. The buttons opt out of it so their clicks land. Still missing: hovering maximize does not open the Windows 11 snap-layout flyout. That needs WM_NCHITTEST to answer HTMAXBUTTON, which the webview cannot reach. Win+arrow and dragging to a screen edge still snap. Signed-off-by: Alireza Bashiri --- desktop/src-tauri/src/lib.rs | 12 ++ desktop/src/app/AppTopChrome.tsx | 10 +- desktop/src/shared/lib/platform.ts | 18 +++ desktop/src/shared/ui/WindowControls.tsx | 133 +++++++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 desktop/src/shared/ui/WindowControls.tsx diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9017356ab5..8222c62d98 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -229,6 +229,18 @@ pub fn run() { }); } + // Windows draws a separate native title bar that cannot be + // tinted to match the app surface. Go undecorated and let + // the web chrome (`AppTopChrome`) own the whole top edge, + // matching the macOS Overlay style. `set_shadow` keeps the + // drop shadow and rounded corners an undecorated window + // otherwise loses. + #[cfg(target_os = "windows")] + { + let _ = window.set_decorations(false); + let _ = window.set_shadow(true); + } + #[cfg(not(target_os = "macos"))] { reveal_initial_window(&window); diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index 845c403b8c..abee17fda6 100644 --- a/desktop/src/app/AppTopChrome.tsx +++ b/desktop/src/app/AppTopChrome.tsx @@ -6,7 +6,8 @@ import { PanelLeftOpen, } from "lucide-react"; -import { isMacPlatform } from "@/shared/lib/platform"; +import { isMacPlatform, isWindowsPlatform } from "@/shared/lib/platform"; +import { WindowControls } from "@/shared/ui/WindowControls"; import { useIsFullscreen } from "@/shared/lib/useIsFullscreen"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; @@ -81,6 +82,9 @@ export function AppTopChrome({ : "pl-[80px]" : "pl-3"; const navRowAlignmentClass = macChrome ? "translate-y-[3px]" : null; + // Windows runs undecorated (see `lib.rs`), so the app draws its own caption + // buttons. They must sit flush against the window edge, hence no right pad. + const showWindowControls = isWindowsPlatform() && !isFullscreen; React.useEffect(() => { const topChrome = topChromeRef.current; @@ -99,7 +103,8 @@ export function AppTopChrome({
+ {showWindowControls ? : null} ); } diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts index 42e7f5b944..4e43e850d2 100644 --- a/desktop/src/shared/lib/platform.ts +++ b/desktop/src/shared/lib/platform.ts @@ -12,6 +12,24 @@ export function isMacPlatform(): boolean { return /mac|iphone|ipad|ipod/i.test(navigator.platform); } +/** + * Returns true on Windows desktops. + * + * WebView2 reports `navigator.platform` as `Win32` today, but the property is + * deprecated, so the user agent backs it up. This gates the app-drawn caption + * buttons on an undecorated window: a false negative would leave that window + * with no way to close, so it does not rest on one deprecated signal. + */ +export function isWindowsPlatform(): boolean { + if (typeof navigator === "undefined") { + return false; + } + + return ( + /win/i.test(navigator.platform) || /windows/i.test(navigator.userAgent) + ); +} + /** Returns true on Linux desktops (excludes Android). */ export function isLinuxPlatform(): boolean { if (typeof navigator === "undefined") { diff --git a/desktop/src/shared/ui/WindowControls.tsx b/desktop/src/shared/ui/WindowControls.tsx new file mode 100644 index 0000000000..3cfa97db29 --- /dev/null +++ b/desktop/src/shared/ui/WindowControls.tsx @@ -0,0 +1,133 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; + +/** + * Windows caption buttons for the undecorated main window. + * + * The window is created without native decorations on Windows (see + * `lib.rs`), so the app draws minimize/maximize/close itself. Sizing follows + * the Fluent caption metrics (46x32 hit target, 10px glyph) rather than the + * app's rem scale: like the macOS traffic lights these must not grow or shrink + * with Cmd +/- text zoom. Deliberate exception to the rem-first rule. + */ + +const CAPTION_BUTTON_CLASS = + "inline-flex h-[32px] w-[46px] items-center justify-center text-sidebar-foreground/75 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"; + +function MinimizeGlyph() { + return ( + + ); +} + +function MaximizeGlyph({ maximized }: { maximized: boolean }) { + if (maximized) { + return ( + + ); + } + + return ( + + ); +} + +function CloseGlyph() { + return ( + + ); +} + +export function WindowControls() { + const [maximized, setMaximized] = React.useState(false); + const windowRef = React.useRef<{ + minimize: () => Promise; + toggleMaximize: () => Promise; + close: () => Promise; + isMaximized: () => Promise; + onResized: (handler: () => void) => Promise<() => void>; + } | null>(null); + + React.useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + + void (async () => { + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + if (cancelled) { + return; + } + + const appWindow = getCurrentWindow(); + windowRef.current = appWindow; + setMaximized(await appWindow.isMaximized()); + + unlisten = await appWindow.onResized(() => { + void appWindow.isMaximized().then(setMaximized); + }); + })(); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + + return ( + // Not a drag region: caption buttons must receive clicks, and the + // surrounding chrome already carries `data-tauri-drag-region`. +
+ + + +
+ ); +}