From 3bd065403d6abc53651a66059fc23e121363241c Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 2 Sep 2026 14:39:09 +0100 Subject: [PATCH] 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. --- .../src-tauri/capabilities/default.json | 6 + .../editor/src-tauri/src/commands/window.rs | 7 +- frontend/editor/src-tauri/src/lib.rs | 11 ++ .../editor/src/core/components/AppLayout.tsx | 10 +- .../src/core/components/WindowTitleBar.tsx | 6 + .../src/core/components/layout/AppFrame.css | 10 +- .../src/core/components/layout/AppFrame.tsx | 17 ++- .../components/WindowTitleBar.module.css | 60 +++++++++ .../src/desktop/components/WindowTitleBar.tsx | 116 ++++++++++++++++++ 9 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 frontend/editor/src/core/components/WindowTitleBar.tsx create mode 100644 frontend/editor/src/desktop/components/WindowTitleBar.module.css create mode 100644 frontend/editor/src/desktop/components/WindowTitleBar.tsx diff --git a/frontend/editor/src-tauri/capabilities/default.json b/frontend/editor/src-tauri/capabilities/default.json index 4147b69ce0..03ce3e7864 100644 --- a/frontend/editor/src-tauri/capabilities/default.json +++ b/frontend/editor/src-tauri/capabilities/default.json @@ -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", diff --git a/frontend/editor/src-tauri/src/commands/window.rs b/frontend/editor/src-tauri/src/commands/window.rs index 761d47aeea..e1275db717 100644 --- a/frontend/editor/src-tauri/src/commands/window.rs +++ b/frontend/editor/src-tauri/src/commands/window.rs @@ -48,8 +48,11 @@ fn build_window(app: &AppHandle, label: &str, url: &str) -> Result = std::env::args().collect(); diff --git a/frontend/editor/src/core/components/AppLayout.tsx b/frontend/editor/src/core/components/AppLayout.tsx index 48c4cb9149..896ba77624 100644 --- a/frontend/editor/src/core/components/AppLayout.tsx +++ b/frontend/editor/src/core/components/AppLayout.tsx @@ -22,7 +22,15 @@ export function AppLayout({ children }: AppLayoutProps) { } `}
{banner}
{children}
diff --git a/frontend/editor/src/core/components/WindowTitleBar.tsx b/frontend/editor/src/core/components/WindowTitleBar.tsx new file mode 100644 index 0000000000..bfedf785ea --- /dev/null +++ b/frontend/editor/src/core/components/WindowTitleBar.tsx @@ -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; +} diff --git a/frontend/editor/src/core/components/layout/AppFrame.css b/frontend/editor/src/core/components/layout/AppFrame.css index 0cc20b30c6..10476e024f 100644 --- a/frontend/editor/src/core/components/layout/AppFrame.css +++ b/frontend/editor/src/core/components/layout/AppFrame.css @@ -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; diff --git a/frontend/editor/src/core/components/layout/AppFrame.tsx b/frontend/editor/src/core/components/layout/AppFrame.tsx index 38fa92ecba..0be70051c1 100644 --- a/frontend/editor/src/core/components/layout/AppFrame.tsx +++ b/frontend/editor/src/core/components/layout/AppFrame.tsx @@ -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 (
- -
- }> - - + {/* 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. */} + +
+ +
+ }> + + +
diff --git a/frontend/editor/src/desktop/components/WindowTitleBar.module.css b/frontend/editor/src/desktop/components/WindowTitleBar.module.css new file mode 100644 index 0000000000..be2e3bfed4 --- /dev/null +++ b/frontend/editor/src/desktop/components/WindowTitleBar.module.css @@ -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; +} diff --git a/frontend/editor/src/desktop/components/WindowTitleBar.tsx b/frontend/editor/src/desktop/components/WindowTitleBar.tsx new file mode 100644 index 0000000000..62e6eede82 --- /dev/null +++ b/frontend/editor/src/desktop/components/WindowTitleBar.tsx @@ -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 ( +
+
+ + + +
+
+ ); +}