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.
This commit is contained in:
Connor Yoh
2026-09-02 16:57:09 +01:00
parent 8b596bb1f5
commit 2fe4b2ba2a
7 changed files with 80 additions and 50 deletions
@@ -22,15 +22,7 @@ export function AppLayout({ children }: AppLayoutProps) {
}
`}</style>
<div
style={{
// Viewport height minus the desktop custom title bar. --titlebar-h is
// unset (0) on web/macOS/Linux, so this stays 100dvh there. Kept
// viewport-relative on purpose: an ancestor (ThemeProvider) is only
// min-height:100vh, so a percentage height here would collapse.
height: "calc(100dvh - var(--titlebar-h, 0px))",
display: "flex",
flexDirection: "column",
}}
style={{ height: "100dvh", display: "flex", flexDirection: "column" }}
>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
@@ -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;
@@ -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;
@@ -11,17 +11,15 @@ export function AppFrame() {
return (
<QuickNavHostProvider>
<div className="app-frame">
{/* 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. */}
<WindowTitleBar />
<div className="app-frame__body">
<QuickNavRailHost />
<div className="app-frame__content">
<Suspense fallback={<LoadingFallback />}>
<Outlet />
</Suspense>
</div>
<QuickNavRailHost />
<div className="app-frame__content">
<Suspense fallback={<LoadingFallback />}>
<Outlet />
</Suspense>
</div>
</div>
</QuickNavHostProvider>
@@ -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;
@@ -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 {
@@ -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();