From cbc8af1951b55780e5cb1b9031e2951dbebb19bb Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Wed, 2 Sep 2026 21:31:45 +0000
Subject: [PATCH] feat(desktop): custom in-app window title bar on Windows
(#7781)
## Custom windows top bar
* Doesn't work on mac
* doesn't effect web
* little effort been put into mobile view
---
.../src-tauri/capabilities/default.json | 6 +
.../editor/src-tauri/src/commands/window.rs | 7 +-
frontend/editor/src-tauri/src/lib.rs | 11 ++
.../src/desktop/components/AppProviders.tsx | 3 +
.../components/WindowTitleBar.module.css | 58 ++++++
.../src/desktop/components/WindowTitleBar.tsx | 187 ++++++++++++++++++
.../src/desktop/components/windowChrome.css | 55 ++++++
7 files changed, 325 insertions(+), 2 deletions(-)
create mode 100644 frontend/editor/src/desktop/components/WindowTitleBar.module.css
create mode 100644 frontend/editor/src/desktop/components/WindowTitleBar.tsx
create mode 100644 frontend/editor/src/desktop/components/windowChrome.css
diff --git a/frontend/editor/src-tauri/capabilities/default.json b/frontend/editor/src-tauri/capabilities/default.json
index 34d333a3de..ba4e5822ca 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/desktop/components/AppProviders.tsx b/frontend/editor/src/desktop/components/AppProviders.tsx
index d7405ef83d..0285c107ae 100644
--- a/frontend/editor/src/desktop/components/AppProviders.tsx
+++ b/frontend/editor/src/desktop/components/AppProviders.tsx
@@ -1,6 +1,7 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
+import { WindowTitleBar } from "@app/components/WindowTitleBar";
import { DesktopQueryCacheReset } from "@app/components/DesktopQueryCacheReset";
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
@@ -328,6 +329,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
>
{/* Also here: the auth check below switches mode pre-authChecked. */}
+
{updatePopupModal}
@@ -354,6 +356,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
>
+
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..660c5e2c56
--- /dev/null
+++ b/frontend/editor/src/desktop/components/WindowTitleBar.module.css
@@ -0,0 +1,58 @@
+/* Custom Windows window controls (see WindowTitleBar.tsx): a fixed cluster in
+ the top-right corner, overlaying the app chrome — which reserves the corner
+ via --wincontrols-w. Kept above app overlays so the controls stay usable. */
+.titleBar {
+ position: fixed;
+ top: 0;
+ right: 0;
+ z-index: 1500;
+ display: flex;
+ height: 2rem;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.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..7c7407426d
--- /dev/null
+++ b/frontend/editor/src/desktop/components/WindowTitleBar.tsx
@@ -0,0 +1,187 @@
+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 "@app/components/WindowTitleBar.module.css";
+// Desktop-only skin that reserves the controls' corner across core layout
+// surfaces; every rule is gated by the data-window-controls flag set below.
+import "@app/components/windowChrome.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 window controls for the Windows desktop build. The native caption is
+ * removed in Rust (decorations:false), so this draws minimize/maximize/close as
+ * a fixed overlay pinned to the top-right corner — the rail and panels run all
+ * the way to the window edge, and the app chrome reserves the corner via the
+ * data-window-controls flag this sets on (consumed by windowChrome.css).
+ * Renders nothing on macOS/Linux (native decorations kept); not bundled in the
+ * browser build. 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;
+ };
+ }, []);
+
+ // Flag the custom chrome on so windowChrome.css can reserve the
+ // controls' corner across the app. Set only when active (Windows desktop);
+ // absent otherwise, so the skin is inert on macOS/Linux. Layout effect so it
+ // lands before paint.
+ useIsomorphicEffect(() => {
+ const root = document.documentElement;
+ if (active) {
+ root.setAttribute("data-window-controls", "custom");
+ } else {
+ root.removeAttribute("data-window-controls");
+ }
+ return () => {
+ root.removeAttribute("data-window-controls");
+ };
+ }, [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]);
+
+ // Let the window be dragged, and double-click-maximized, from any
+ // non-interactive spot in the top strip. data-tauri-drag-region only fires on
+ // bare container backgrounds, leaving most of a busy toolbar undraggable, so a
+ // document-level hit test covers the whole top instead.
+ //
+ // startDragging() must NOT run on mousedown: it enters the OS drag loop and
+ // swallows the browser's dblclick. So begin the drag on the first real move,
+ // and detect a double-click from the interval between mousedowns.
+ useEffect(() => {
+ if (!active) return;
+ const TOP_STRIP_PX = 48;
+ const DOUBLE_CLICK_MS = 500;
+ const DRAG_THRESHOLD_PX = 4;
+ const INTERACTIVE =
+ "button, a[href], input, textarea, select, label, summary," +
+ '[role="button"], [role="tab"], [role="menuitem"], [role="switch"],' +
+ '[role="slider"], [contenteditable="true"], [data-no-window-drag]';
+ const draggableAt = (e: MouseEvent) => {
+ if (e.button !== 0 || e.clientY > TOP_STRIP_PX) return false;
+ const el = e.target as Element | null;
+ return !!el && !el.closest(INTERACTIVE);
+ };
+ let pending: { x: number; y: number } | null = null;
+ let lastDownAt = 0;
+ const onMouseDown = (e: MouseEvent) => {
+ if (!draggableAt(e)) {
+ pending = null;
+ return;
+ }
+ const now = Date.now();
+ if (now - lastDownAt < DOUBLE_CLICK_MS) {
+ pending = null;
+ lastDownAt = 0;
+ void getCurrentWindow().toggleMaximize();
+ return;
+ }
+ lastDownAt = now;
+ pending = { x: e.clientX, y: e.clientY };
+ };
+ const onMouseMove = (e: MouseEvent) => {
+ if (!pending) return;
+ if (
+ Math.abs(e.clientX - pending.x) > DRAG_THRESHOLD_PX ||
+ Math.abs(e.clientY - pending.y) > DRAG_THRESHOLD_PX
+ ) {
+ pending = null;
+ lastDownAt = 0; // a drag is not the first half of a double-click
+ void getCurrentWindow().startDragging();
+ }
+ };
+ const onMouseUp = () => {
+ pending = null;
+ };
+ document.addEventListener("mousedown", onMouseDown);
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ return () => {
+ document.removeEventListener("mousedown", onMouseDown);
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ };
+ }, [active]);
+
+ if (!active) return null;
+
+ const appWindow = getCurrentWindow();
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/editor/src/desktop/components/windowChrome.css b/frontend/editor/src/desktop/components/windowChrome.css
new file mode 100644
index 0000000000..f507a64b2d
--- /dev/null
+++ b/frontend/editor/src/desktop/components/windowChrome.css
@@ -0,0 +1,55 @@
+/* Desktop (Windows) custom window chrome.
+ *
+ * The window controls are a fixed overlay in the top-right corner (see
+ * WindowTitleBar). These rules keep the app's own chrome clear of that corner.
+ *
+ * Loaded only in the desktop build (imported by WindowTitleBar) and gated on the
+ * data-window-controls flag the overlay sets on only when active, so it
+ * is inert on macOS/Linux (flag never set) and absent from web builds. It
+ * targets core layout class names on purpose: this is the single, contained
+ * coupling point between the desktop chrome and the core layout, kept here in
+ * the desktop layer so the core components stay unaware of window chrome. */
+
+html[data-window-controls="custom"] {
+ --wincontrols-w: 8.625rem; /* three 46px controls */
+ --wincontrols-h: 2rem; /* control height */
+}
+
+/* Files-page toolbar spans the width; reflow search + actions left of the
+ controls by shrinking the grid's right edge. */
+html[data-window-controls="custom"] .files-page-header {
+ padding-right: calc(0.75rem + var(--wincontrols-w));
+}
+
+/* Right panel, expanded: move the header (PDF Tools, or an active tool such as
+ Automate) below the controls instead of squashing its title. */
+html[data-window-controls="custom"] .tool-panel__compact-header,
+html[data-window-controls="custom"] .tool-panel .sui-panelhdr {
+ margin-top: var(--wincontrols-h);
+}
+
+/* Right panel, collapsed: the strip is narrower than the controls, so push its
+ expand toggle straight down. */
+html[data-window-controls="custom"] .tool-panel__collapsed-strip {
+ padding-top: calc(10px + var(--wincontrols-h));
+}
+
+/* Viewer top bar: when the right panel is collapsed the bar widens under the
+ controls. :has() detects the collapsed strip (no flag needed in core) and we
+ push the right cluster clear. --nav-rail-w is the collapsed strip's width, so
+ only the controls' overhang past it is reserved. */
+html[data-window-controls="custom"]
+ .app-frame__content:has(.tool-panel__collapsed-strip)
+ .workbench-bar-globals {
+ margin-right: calc(var(--wincontrols-w) - var(--nav-rail-w));
+}
+
+/* Mobile layout: the top bar spans the full width. Keep the brand top-left
+ (already clear of the top-right controls) and drop the view switcher below
+ them by top-aligning the row and nudging the switcher down. */
+html[data-window-controls="custom"] .mobile-toggle {
+ align-items: flex-start;
+}
+html[data-window-controls="custom"] .mobile-toggle-buttons {
+ margin-top: var(--wincontrols-h);
+}