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) image ## 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); +}