feat(desktop): custom in-app window title bar on Windows

Replace the native Windows title bar with an in-app one: remove it
(decorations:false, Windows only) and draw a full-width themed drag region
with minimize/maximize/close controls, above the rail and panels so their
vertical dividers meet a clean edge instead of the native caption seam.

- Rust: set_decorations(false) on Windows (main window at setup, spawned
  windows at build time). macOS/Linux keep native decorations.
- WindowTitleBar (desktop) draws the bar + controls; the core stub renders
  nothing so the browser build is unchanged (stub/shadow via @app).
- AppFrame becomes a column [title bar, rail+content]; AppLayout reserves the
  bar height via calc(100dvh - var(--titlebar-h)), set only on Windows.
- capabilities: allow window minimize / toggle-maximize / close /
  start-dragging / internal-toggle-maximize / is-maximized.

tao provides edge/corner resize for the undecorated window. The Windows 11
Snap Layout hover flyout is not yet wired (double-click and keyboard snap
still work). Alternative to the DWM caption approach in #7780.
This commit is contained in:
Connor Yoh
2026-09-02 14:39:09 +01:00
parent f6661a8f87
commit 3bd065403d
9 changed files with 234 additions and 9 deletions
@@ -6,6 +6,12 @@
"permissions": [
"core:default",
"core:window:allow-destroy",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-internal-toggle-maximize",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"http:default",
{
"identifier": "http:allow-fetch",
@@ -48,8 +48,11 @@ fn build_window(app: &AppHandle, label: &str, url: &str) -> Result<WebviewWindow
// dir (and thus IndexedDB / localStorage / cookies). macOS (WKWebView) and
// Linux (WebKitGTK) don't have this constraint, so the arg is Windows-only.
#[cfg(target_os = "windows")]
let builder =
builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature");
let builder = builder
.additional_browser_args("--enable-features=CertVerifierBuiltinFeature")
// Windows: no native title bar; the frontend draws its own (WindowTitleBar).
// macOS/Linux keep native decorations.
.decorations(false);
builder.build().map_err(|e| e.to_string())
}
+11
View File
@@ -146,6 +146,17 @@ pub fn run() {
.setup(|app| {
add_log("🚀 Tauri app setup started".to_string());
// Windows: drop the native title bar so the in-app custom title bar
// (window controls + drag region) takes over. Runtime toggle because the
// main window is defined in tauri.conf.json; spawned windows set it at
// build time in window.rs. macOS/Linux keep their native decorations.
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.set_decorations(false);
}
}
// Files passed on the command line at first launch load into the main
// window once the frontend mounts.
let args: Vec<String> = std::env::args().collect();
@@ -22,7 +22,15 @@ export function AppLayout({ children }: AppLayoutProps) {
}
`}</style>
<div
style={{ height: "100dvh", display: "flex", flexDirection: "column" }}
style={{
// Viewport height minus the desktop custom title bar. --titlebar-h is
// unset (0) on web/macOS/Linux, so this stays 100dvh there. Kept
// viewport-relative on purpose: an ancestor (ThemeProvider) is only
// min-height:100vh, so a percentage height here would collapse.
height: "calc(100dvh - var(--titlebar-h, 0px))",
display: "flex",
flexDirection: "column",
}}
>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
@@ -0,0 +1,6 @@
// Core stub: the browser build has no custom window chrome. The desktop layer
// shadows this (desktop/components/WindowTitleBar.tsx) with a real title bar on
// Windows. Rendered by AppFrame, so it must resolve in every build.
export function WindowTitleBar() {
return null;
}
@@ -1,13 +1,21 @@
/* ========== APP FRAME ========== */
/* The rail's column, then whichever app is mounted, so a switch changes only the app. */
/* A column: the optional custom title bar (desktop), then the rail + app row. */
.app-frame {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh; /* track mobile browser chrome */
overflow: hidden;
background-color: var(--c-bg);
}
/* The rail's column, then whichever app is mounted, so a switch changes only the app. */
.app-frame__body {
display: flex;
flex: 1;
min-height: 0;
}
/* min-width: 0 so the app shrinks instead of forcing the frame past the window. */
.app-frame__content {
flex: 1;
@@ -3,6 +3,7 @@ import { Outlet } from "react-router-dom";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext";
import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost";
import { WindowTitleBar } from "@app/components/WindowTitleBar";
import "@app/components/layout/AppFrame.css";
/** The rail renders once outside both apps; Suspense sits inside it, not above. */
@@ -10,11 +11,17 @@ export function AppFrame() {
return (
<QuickNavHostProvider>
<div className="app-frame">
<QuickNavRailHost />
<div className="app-frame__content">
<Suspense fallback={<LoadingFallback />}>
<Outlet />
</Suspense>
{/* Desktop (Windows) custom window chrome; null on web + macOS/Linux. It
spans the full width above the rail so the panel dividers meet a clean
edge instead of the native caption. */}
<WindowTitleBar />
<div className="app-frame__body">
<QuickNavRailHost />
<div className="app-frame__content">
<Suspense fallback={<LoadingFallback />}>
<Outlet />
</Suspense>
</div>
</div>
</div>
</QuickNavHostProvider>
@@ -0,0 +1,60 @@
/* Custom Windows title bar (see WindowTitleBar.tsx). Full-width strip above the
rail + panels; the drag region is the empty area, controls sit top-right. */
.titleBar {
display: flex;
align-items: stretch;
justify-content: flex-end;
flex: 0 0 auto;
height: var(--titlebar-h, 2rem);
background: var(--c-bg);
user-select: none;
-webkit-user-select: none;
/* Keep the window controls above app overlays so they stay usable. */
position: relative;
z-index: 1500;
}
.controls {
display: flex;
align-items: stretch;
height: 100%;
}
.button {
width: 46px;
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: var(--c-text-muted);
font-size: 15px; /* drives the inherit-sized MUI icons */
line-height: 1;
cursor: default;
transition:
background-color 0.12s ease,
color 0.12s ease;
}
/* Clicks (and Tauri's drag-region hit test) should always resolve to the
button, never its SVG glyph. */
.button svg {
pointer-events: none;
}
.button:hover {
background: var(--c-hover);
color: var(--c-text);
}
.close:hover {
background: var(--c-danger);
color: #fff;
}
.restoreIcon {
/* The overlapping-squares glyph reads large next to the others. */
font-size: 13px;
}
@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { useIsomorphicEffect } from "@mantine/hooks";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { isTauri } from "@tauri-apps/api/core";
import MinimizeIcon from "@mui/icons-material/Minimize";
import CropSquareIcon from "@mui/icons-material/CropSquare";
import FilterNoneIcon from "@mui/icons-material/FilterNone";
import CloseIcon from "@mui/icons-material/Close";
import { getDesktopOs, DesktopOs } from "@app/services/platformService";
import styles from "./WindowTitleBar.module.css";
// Seed from the UA so the bar (and its reserved height) is present on the first
// frame on Windows, avoiding a layout shift. getDesktopOs() confirms it right
// after via the Rust command.
const seedIsWindows =
typeof navigator !== "undefined" && /Windows/i.test(navigator.userAgent);
/**
* Custom in-app window title bar for the Windows desktop build. The native
* caption is removed in Rust (decorations:false), so this draws the themed drag
* region + minimize/maximize/close controls in its place, letting the app chrome
* run edge to edge. Renders nothing on macOS/Linux (native decorations kept) and
* in the browser build (core stub). tao provides edge/corner resize for the
* undecorated window, so no manual resize handles are needed here.
*/
export function WindowTitleBar() {
const [isWindows, setIsWindows] = useState(seedIsWindows);
const [maximized, setMaximized] = useState(false);
const active = isWindows && isTauri();
// Confirm the OS authoritatively (the UA seed is only a first-frame guess).
useEffect(() => {
if (!isTauri()) {
setIsWindows(false);
return;
}
let mounted = true;
void getDesktopOs().then((os) => {
if (mounted) setIsWindows(os === DesktopOs.Windows);
});
return () => {
mounted = false;
};
}, []);
// Reserve the bar's height for the rest of the app. AppLayout sizes itself as
// calc(100dvh - var(--titlebar-h)); everywhere else the var is unset (0).
// Layout effect so it lands before paint and nothing overflows for a frame.
useIsomorphicEffect(() => {
const root = document.documentElement;
if (active) {
root.style.setProperty("--titlebar-h", "2rem");
} else {
root.style.removeProperty("--titlebar-h");
}
return () => root.style.removeProperty("--titlebar-h");
}, [active]);
// Keep the maximize/restore icon in sync with the actual window state
// (double-click, snap, or the button itself all change it).
useEffect(() => {
if (!active) return;
const appWindow = getCurrentWindow();
let unlisten: (() => void) | undefined;
void appWindow.isMaximized().then(setMaximized);
void appWindow
.onResized(() => {
void appWindow.isMaximized().then(setMaximized);
})
.then((u) => {
unlisten = u;
});
return () => unlisten?.();
}, [active]);
if (!active) return null;
const appWindow = getCurrentWindow();
return (
<div className={styles.titleBar} data-tauri-drag-region>
<div className={styles.controls}>
<button
type="button"
className={styles.button}
onClick={() => void appWindow.minimize()}
aria-label="Minimize"
tabIndex={-1}
>
<MinimizeIcon fontSize="inherit" />
</button>
<button
type="button"
className={styles.button}
onClick={() => void appWindow.toggleMaximize()}
aria-label={maximized ? "Restore" : "Maximize"}
tabIndex={-1}
>
{maximized ? (
<FilterNoneIcon fontSize="inherit" className={styles.restoreIcon} />
) : (
<CropSquareIcon fontSize="inherit" />
)}
</button>
<button
type="button"
className={`${styles.button} ${styles.close}`}
onClick={() => void appWindow.close()}
aria-label="Close"
tabIndex={-1}
>
<CloseIcon fontSize="inherit" />
</button>
</div>
</div>
);
}