- {/* 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();