From 2fe4b2ba2a1da43e19add1fae28732cedafca260 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 2 Sep 2026 16:57:09 +0100 Subject: [PATCH] feat(desktop): integrate window controls instead of a title-bar strip Rework the custom Windows chrome from a dedicated full-width title bar (which wasted the top row and stopped the rail/panels reaching the window edge) into an overlay: minimize/maximize/close pinned to the top-right corner, with the app's own chrome reserving that corner. - WindowTitleBar is a fixed top-right overlay; it publishes --wincontrols-w and --wincontrols-h for the app to reserve against. - AppFrame/AppLayout revert to full height, so the rail, sidebars, workbench and search run all the way to the top again. - Reserve the controls' corner wherever app chrome reaches the window's right edge: the tool-panel header (PDF Tools + active tools like Automate), the collapsed panel's expand toggle, and the files-page header. - Drag the window (and double-click-maximize) from any non-interactive spot in the top strip via a document-level hit test, since data-tauri-drag-region only fires on bare container backgrounds. All reservations key off --wincontrols-w (0 unless the Windows custom chrome is active), so macOS/Linux/web are unaffected. --- .../editor/src/core/components/AppLayout.tsx | 10 +--- .../core/components/filesPage/FilesPage.css | 4 +- .../src/core/components/layout/AppFrame.css | 10 +--- .../src/core/components/layout/AppFrame.tsx | 18 +++---- .../src/core/components/tools/ToolPanel.css | 16 +++++- .../components/WindowTitleBar.module.css | 18 +++---- .../src/desktop/components/WindowTitleBar.tsx | 54 +++++++++++++++---- 7 files changed, 80 insertions(+), 50 deletions(-) diff --git a/frontend/editor/src/core/components/AppLayout.tsx b/frontend/editor/src/core/components/AppLayout.tsx index 896ba77624..48c4cb9149 100644 --- a/frontend/editor/src/core/components/AppLayout.tsx +++ b/frontend/editor/src/core/components/AppLayout.tsx @@ -22,15 +22,7 @@ export function AppLayout({ children }: AppLayoutProps) { } `}
{banner}
{children}
diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 6d4070edb6..c4ab11f6be 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -21,7 +21,9 @@ align-items: center; gap: 1rem; min-height: 48px; - padding: 0 0.75rem; + /* Right padding also clears the desktop window-controls overlay so the whole + grid (search + actions) reflows inside it (0 unless Windows custom chrome). */ + padding: 0 calc(0.75rem + var(--wincontrols-w, 0px)) 0 0.75rem; border-bottom: 1px solid var(--c-border-subtle); background: var(--c-bg-raised); flex-shrink: 0; diff --git a/frontend/editor/src/core/components/layout/AppFrame.css b/frontend/editor/src/core/components/layout/AppFrame.css index 10476e024f..0cc20b30c6 100644 --- a/frontend/editor/src/core/components/layout/AppFrame.css +++ b/frontend/editor/src/core/components/layout/AppFrame.css @@ -1,21 +1,13 @@ /* ========== APP FRAME ========== */ -/* A column: the optional custom title bar (desktop), then the rail + app row. */ +/* The rail's column, then whichever app is mounted, so a switch changes only the app. */ .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 0be70051c1..8b79d181e9 100644 --- a/frontend/editor/src/core/components/layout/AppFrame.tsx +++ b/frontend/editor/src/core/components/layout/AppFrame.tsx @@ -11,17 +11,15 @@ 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. */} + {/* Desktop (Windows) window controls, drawn as a fixed overlay in the + top-right corner so the rail and panels run to the window edge; null + on web + macOS/Linux. */} -
- -
- }> - - -
+ +
+ }> + +
diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index b27885ccf5..64624ef0cc 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -31,7 +31,11 @@ display: flex; flex-direction: column; align-items: stretch; - padding-top: 10px; + /* Extra top space keeps the expand toggle clear of the desktop window + controls overlay. Derive the ~control-height from --wincontrols-w (which is + 138px when the custom Windows chrome is active, 0 otherwise) via min(), so + it needs only that one variable. */ + padding-top: calc(10px + min(var(--wincontrols-w, 0px), 2rem)); height: 100%; width: 100%; min-height: 0; @@ -157,9 +161,19 @@ gap: 0.5rem; min-height: 52px; padding: 0.5rem 0.75rem; + /* Keep the collapse toggle clear of the desktop window-controls overlay + (--wincontrols-w is 0 unless the custom Windows title bar is active). */ + padding-right: calc(0.75rem + var(--wincontrols-w, 0px)); box-sizing: border-box; } +/* Active-tool header (e.g. Automate) sits at the panel's top-right too; keep its + close button clear of the window-controls overlay. Scoped to the tool panel so + the shared header elsewhere (AI chat, etc.) is untouched. */ +.tool-panel .sui-panelhdr__close { + margin-right: var(--wincontrols-w, 0px); +} + .tool-panel__compact-title { flex: 1 1 auto; min-width: 0; diff --git a/frontend/editor/src/desktop/components/WindowTitleBar.module.css b/frontend/editor/src/desktop/components/WindowTitleBar.module.css index be2e3bfed4..660c5e2c56 100644 --- a/frontend/editor/src/desktop/components/WindowTitleBar.module.css +++ b/frontend/editor/src/desktop/components/WindowTitleBar.module.css @@ -1,17 +1,15 @@ -/* 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. */ +/* 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; - align-items: stretch; - justify-content: flex-end; - flex: 0 0 auto; - height: var(--titlebar-h, 2rem); - background: var(--c-bg); + height: 2rem; user-select: none; -webkit-user-select: none; - /* Keep the window controls above app overlays so they stay usable. */ - position: relative; - z-index: 1500; } .controls { diff --git a/frontend/editor/src/desktop/components/WindowTitleBar.tsx b/frontend/editor/src/desktop/components/WindowTitleBar.tsx index 4d0a99a161..7a7d506e44 100644 --- a/frontend/editor/src/desktop/components/WindowTitleBar.tsx +++ b/frontend/editor/src/desktop/components/WindowTitleBar.tsx @@ -16,10 +16,11 @@ 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 + * 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 + * --wincontrols-w. 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. */ @@ -43,18 +44,21 @@ export function WindowTitleBar() { }; }, []); - // 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. + // The controls are a fixed overlay in the top-right corner. Publish their + // width so app chrome at the top-right (the tool panel header) can pad clear + // of them; unset (0) everywhere else. Layout effect so it lands before paint. useIsomorphicEffect(() => { const root = document.documentElement; if (active) { - root.style.setProperty("--titlebar-h", "2rem"); + root.style.setProperty("--wincontrols-w", "8.625rem"); // 3 x 46px + root.style.setProperty("--wincontrols-h", "2rem"); } else { - root.style.removeProperty("--titlebar-h"); + root.style.removeProperty("--wincontrols-w"); + root.style.removeProperty("--wincontrols-h"); } return () => { - root.style.removeProperty("--titlebar-h"); + root.style.removeProperty("--wincontrols-w"); + root.style.removeProperty("--wincontrols-h"); }; }, [active]); @@ -75,6 +79,36 @@ export function WindowTitleBar() { 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 + // when the bare container is the click target, which leaves most of a busy + // toolbar undraggable; a document-level hit test covers the whole top. + useEffect(() => { + if (!active) return; + const TOP_STRIP_PX = 48; + 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); + }; + const onMouseDown = (e: MouseEvent) => { + if (draggableAt(e)) void getCurrentWindow().startDragging(); + }; + const onDoubleClick = (e: MouseEvent) => { + if (draggableAt(e)) void getCurrentWindow().toggleMaximize(); + }; + document.addEventListener("mousedown", onMouseDown); + document.addEventListener("dblclick", onDoubleClick); + return () => { + document.removeEventListener("mousedown", onMouseDown); + document.removeEventListener("dblclick", onDoubleClick); + }; + }, [active]); + if (!active) return null; const appWindow = getCurrentWindow();