Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions desktop/src/app/AppTopChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -99,7 +103,8 @@ export function AppTopChrome({
<div
ref={topChromeRef}
className={cn(
"relative z-45 flex shrink-0 cursor-default select-none items-center bg-sidebar pr-3 text-sidebar-foreground",
"relative z-45 flex shrink-0 cursor-default select-none items-center bg-sidebar text-sidebar-foreground",
showWindowControls ? "pr-0" : "pr-3",
topChromeBackdrop.height,
navRowPaddingClass,
)}
Expand Down Expand Up @@ -131,6 +136,7 @@ export function AppTopChrome({
<ChevronRight />
</Button>
</div>
{showWindowControls ? <WindowControls /> : null}
</div>
);
}
18 changes: 18 additions & 0 deletions desktop/src/shared/lib/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
133 changes: 133 additions & 0 deletions desktop/src/shared/ui/WindowControls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg aria-hidden="true" height="10" viewBox="0 0 10 10" width="10">
<path d="M0 5h10" fill="none" stroke="currentColor" strokeWidth="1" />
</svg>
);
}

function MaximizeGlyph({ maximized }: { maximized: boolean }) {
if (maximized) {
return (
<svg aria-hidden="true" height="10" viewBox="0 0 10 10" width="10">
<path
d="M2.5 0.5h7v7M0.5 2.5h7v7h-7z"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
);
}

return (
<svg aria-hidden="true" height="10" viewBox="0 0 10 10" width="10">
<path
d="M0.5 0.5h9v9h-9z"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
);
}

function CloseGlyph() {
return (
<svg aria-hidden="true" height="10" viewBox="0 0 10 10" width="10">
<path
d="M0 0l10 10M10 0L0 10"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
);
}

export function WindowControls() {
const [maximized, setMaximized] = React.useState(false);
const windowRef = React.useRef<{
minimize: () => Promise<void>;
toggleMaximize: () => Promise<void>;
close: () => Promise<void>;
isMaximized: () => Promise<boolean>;
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`.
<div className="ml-auto flex items-center self-stretch">
<button
aria-label="Minimize"
className={CAPTION_BUTTON_CLASS}
onClick={() => void windowRef.current?.minimize()}
type="button"
>
<MinimizeGlyph />
</button>
<button
aria-label={maximized ? "Restore" : "Maximize"}
className={CAPTION_BUTTON_CLASS}
onClick={() => void windowRef.current?.toggleMaximize()}
type="button"
>
<MaximizeGlyph maximized={maximized} />
</button>
<button
aria-label="Close"
className={cn(
CAPTION_BUTTON_CLASS,
"hover:bg-[#c42b1c] hover:text-white",
)}
onClick={() => void windowRef.current?.close()}
type="button"
>
<CloseGlyph />
</button>
</div>
);
}