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`. +
+ + + +
+ ); +}