Skip to content

Commit cbc8af1

Browse files
authored
feat(desktop): custom in-app window title bar on Windows (Stirling-Tools#7781)
<img width="1750" height="1223" alt="image" src="https://github.com/user-attachments/assets/c115a30c-be62-4eaa-8de3-26a93a605919" /> ## Custom windows top bar * Doesn't work on mac * doesn't effect web * little effort been put into mobile view
1 parent aca0e40 commit cbc8af1

7 files changed

Lines changed: 325 additions & 2 deletions

File tree

frontend/editor/src-tauri/capabilities/default.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
"permissions": [
77
"core:default",
88
"core:window:allow-destroy",
9+
"core:window:allow-minimize",
10+
"core:window:allow-toggle-maximize",
11+
"core:window:allow-internal-toggle-maximize",
12+
"core:window:allow-close",
13+
"core:window:allow-is-maximized",
14+
"core:window:allow-start-dragging",
915
"http:default",
1016
{
1117
"identifier": "http:allow-fetch",

frontend/editor/src-tauri/src/commands/window.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@ fn build_window(app: &AppHandle, label: &str, url: &str) -> Result<WebviewWindow
4848
// dir (and thus IndexedDB / localStorage / cookies). macOS (WKWebView) and
4949
// Linux (WebKitGTK) don't have this constraint, so the arg is Windows-only.
5050
#[cfg(target_os = "windows")]
51-
let builder =
52-
builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature");
51+
let builder = builder
52+
.additional_browser_args("--enable-features=CertVerifierBuiltinFeature")
53+
// Windows: no native title bar; the frontend draws its own (WindowTitleBar).
54+
// macOS/Linux keep native decorations.
55+
.decorations(false);
5356

5457
builder.build().map_err(|e| e.to_string())
5558
}

frontend/editor/src-tauri/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,17 @@ pub fn run() {
146146
.setup(|app| {
147147
add_log("🚀 Tauri app setup started".to_string());
148148

149+
// Windows: drop the native title bar so the in-app custom title bar
150+
// (window controls + drag region) takes over. Runtime toggle because the
151+
// main window is defined in tauri.conf.json; spawned windows set it at
152+
// build time in window.rs. macOS/Linux keep their native decorations.
153+
#[cfg(target_os = "windows")]
154+
{
155+
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
156+
let _ = window.set_decorations(false);
157+
}
158+
}
159+
149160
// Files passed on the command line at first launch load into the main
150161
// window once the frontend mounts.
151162
let args: Vec<String> = std::env::args().collect();

frontend/editor/src/desktop/components/AppProviders.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ReactNode, useEffect, useRef, useState } from "react";
22
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
33
import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
4+
import { WindowTitleBar } from "@app/components/WindowTitleBar";
45
import { DesktopQueryCacheReset } from "@app/components/DesktopQueryCacheReset";
56
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
67
import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
@@ -328,6 +329,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
328329
>
329330
{/* Also here: the auth check below switches mode pre-authChecked. */}
330331
<DesktopQueryCacheReset />
332+
<WindowTitleBar />
331333
<div style={{ minHeight: "100vh" }} />
332334
{updatePopupModal}
333335
</ProprietaryAppProviders>
@@ -354,6 +356,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
354356
}}
355357
>
356358
<DesktopQueryCacheReset />
359+
<WindowTitleBar />
357360
<SaaSTeamProvider key={appKey}>
358361
<DesktopConfigSync />
359362
<DesktopBannerInitializer />
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/* Custom Windows window controls (see WindowTitleBar.tsx): a fixed cluster in
2+
the top-right corner, overlaying the app chrome — which reserves the corner
3+
via --wincontrols-w. Kept above app overlays so the controls stay usable. */
4+
.titleBar {
5+
position: fixed;
6+
top: 0;
7+
right: 0;
8+
z-index: 1500;
9+
display: flex;
10+
height: 2rem;
11+
user-select: none;
12+
-webkit-user-select: none;
13+
}
14+
15+
.controls {
16+
display: flex;
17+
align-items: stretch;
18+
height: 100%;
19+
}
20+
21+
.button {
22+
width: 46px;
23+
height: 100%;
24+
display: inline-flex;
25+
align-items: center;
26+
justify-content: center;
27+
padding: 0;
28+
border: none;
29+
background: transparent;
30+
color: var(--c-text-muted);
31+
font-size: 15px; /* drives the inherit-sized MUI icons */
32+
line-height: 1;
33+
cursor: default;
34+
transition:
35+
background-color 0.12s ease,
36+
color 0.12s ease;
37+
}
38+
39+
/* Clicks (and Tauri's drag-region hit test) should always resolve to the
40+
button, never its SVG glyph. */
41+
.button svg {
42+
pointer-events: none;
43+
}
44+
45+
.button:hover {
46+
background: var(--c-hover);
47+
color: var(--c-text);
48+
}
49+
50+
.close:hover {
51+
background: var(--c-danger);
52+
color: #fff;
53+
}
54+
55+
.restoreIcon {
56+
/* The overlapping-squares glyph reads large next to the others. */
57+
font-size: 13px;
58+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { useEffect, useState } from "react";
2+
import { useIsomorphicEffect } from "@mantine/hooks";
3+
import { getCurrentWindow } from "@tauri-apps/api/window";
4+
import { isTauri } from "@tauri-apps/api/core";
5+
import MinimizeIcon from "@mui/icons-material/Minimize";
6+
import CropSquareIcon from "@mui/icons-material/CropSquare";
7+
import FilterNoneIcon from "@mui/icons-material/FilterNone";
8+
import CloseIcon from "@mui/icons-material/Close";
9+
import { getDesktopOs, DesktopOs } from "@app/services/platformService";
10+
import styles from "@app/components/WindowTitleBar.module.css";
11+
// Desktop-only skin that reserves the controls' corner across core layout
12+
// surfaces; every rule is gated by the data-window-controls flag set below.
13+
import "@app/components/windowChrome.css";
14+
15+
// Seed from the UA so the bar (and its reserved height) is present on the first
16+
// frame on Windows, avoiding a layout shift. getDesktopOs() confirms it right
17+
// after via the Rust command.
18+
const seedIsWindows =
19+
typeof navigator !== "undefined" && /Windows/i.test(navigator.userAgent);
20+
21+
/**
22+
* Custom window controls for the Windows desktop build. The native caption is
23+
* removed in Rust (decorations:false), so this draws minimize/maximize/close as
24+
* a fixed overlay pinned to the top-right corner — the rail and panels run all
25+
* the way to the window edge, and the app chrome reserves the corner via the
26+
* data-window-controls flag this sets on <html> (consumed by windowChrome.css).
27+
* Renders nothing on macOS/Linux (native decorations kept); not bundled in the
28+
* browser build. tao provides edge/corner resize for the undecorated window, so
29+
* no manual resize handles are needed here.
30+
*/
31+
export function WindowTitleBar() {
32+
const [isWindows, setIsWindows] = useState(seedIsWindows);
33+
const [maximized, setMaximized] = useState(false);
34+
const active = isWindows && isTauri();
35+
36+
// Confirm the OS authoritatively (the UA seed is only a first-frame guess).
37+
useEffect(() => {
38+
if (!isTauri()) {
39+
setIsWindows(false);
40+
return;
41+
}
42+
let mounted = true;
43+
void getDesktopOs().then((os) => {
44+
if (mounted) setIsWindows(os === DesktopOs.Windows);
45+
});
46+
return () => {
47+
mounted = false;
48+
};
49+
}, []);
50+
51+
// Flag the custom chrome on <html> so windowChrome.css can reserve the
52+
// controls' corner across the app. Set only when active (Windows desktop);
53+
// absent otherwise, so the skin is inert on macOS/Linux. Layout effect so it
54+
// lands before paint.
55+
useIsomorphicEffect(() => {
56+
const root = document.documentElement;
57+
if (active) {
58+
root.setAttribute("data-window-controls", "custom");
59+
} else {
60+
root.removeAttribute("data-window-controls");
61+
}
62+
return () => {
63+
root.removeAttribute("data-window-controls");
64+
};
65+
}, [active]);
66+
67+
// Keep the maximize/restore icon in sync with the actual window state
68+
// (double-click, snap, or the button itself all change it).
69+
useEffect(() => {
70+
if (!active) return;
71+
const appWindow = getCurrentWindow();
72+
let unlisten: (() => void) | undefined;
73+
void appWindow.isMaximized().then(setMaximized);
74+
void appWindow
75+
.onResized(() => {
76+
void appWindow.isMaximized().then(setMaximized);
77+
})
78+
.then((u) => {
79+
unlisten = u;
80+
});
81+
return () => unlisten?.();
82+
}, [active]);
83+
84+
// Let the window be dragged, and double-click-maximized, from any
85+
// non-interactive spot in the top strip. data-tauri-drag-region only fires on
86+
// bare container backgrounds, leaving most of a busy toolbar undraggable, so a
87+
// document-level hit test covers the whole top instead.
88+
//
89+
// startDragging() must NOT run on mousedown: it enters the OS drag loop and
90+
// swallows the browser's dblclick. So begin the drag on the first real move,
91+
// and detect a double-click from the interval between mousedowns.
92+
useEffect(() => {
93+
if (!active) return;
94+
const TOP_STRIP_PX = 48;
95+
const DOUBLE_CLICK_MS = 500;
96+
const DRAG_THRESHOLD_PX = 4;
97+
const INTERACTIVE =
98+
"button, a[href], input, textarea, select, label, summary," +
99+
'[role="button"], [role="tab"], [role="menuitem"], [role="switch"],' +
100+
'[role="slider"], [contenteditable="true"], [data-no-window-drag]';
101+
const draggableAt = (e: MouseEvent) => {
102+
if (e.button !== 0 || e.clientY > TOP_STRIP_PX) return false;
103+
const el = e.target as Element | null;
104+
return !!el && !el.closest(INTERACTIVE);
105+
};
106+
let pending: { x: number; y: number } | null = null;
107+
let lastDownAt = 0;
108+
const onMouseDown = (e: MouseEvent) => {
109+
if (!draggableAt(e)) {
110+
pending = null;
111+
return;
112+
}
113+
const now = Date.now();
114+
if (now - lastDownAt < DOUBLE_CLICK_MS) {
115+
pending = null;
116+
lastDownAt = 0;
117+
void getCurrentWindow().toggleMaximize();
118+
return;
119+
}
120+
lastDownAt = now;
121+
pending = { x: e.clientX, y: e.clientY };
122+
};
123+
const onMouseMove = (e: MouseEvent) => {
124+
if (!pending) return;
125+
if (
126+
Math.abs(e.clientX - pending.x) > DRAG_THRESHOLD_PX ||
127+
Math.abs(e.clientY - pending.y) > DRAG_THRESHOLD_PX
128+
) {
129+
pending = null;
130+
lastDownAt = 0; // a drag is not the first half of a double-click
131+
void getCurrentWindow().startDragging();
132+
}
133+
};
134+
const onMouseUp = () => {
135+
pending = null;
136+
};
137+
document.addEventListener("mousedown", onMouseDown);
138+
document.addEventListener("mousemove", onMouseMove);
139+
document.addEventListener("mouseup", onMouseUp);
140+
return () => {
141+
document.removeEventListener("mousedown", onMouseDown);
142+
document.removeEventListener("mousemove", onMouseMove);
143+
document.removeEventListener("mouseup", onMouseUp);
144+
};
145+
}, [active]);
146+
147+
if (!active) return null;
148+
149+
const appWindow = getCurrentWindow();
150+
return (
151+
<div className={styles.titleBar}>
152+
<div className={styles.controls}>
153+
<button
154+
type="button"
155+
className={styles.button}
156+
onClick={() => void appWindow.minimize()}
157+
aria-label="Minimize"
158+
tabIndex={-1}
159+
>
160+
<MinimizeIcon fontSize="inherit" />
161+
</button>
162+
<button
163+
type="button"
164+
className={styles.button}
165+
onClick={() => void appWindow.toggleMaximize()}
166+
aria-label={maximized ? "Restore" : "Maximize"}
167+
tabIndex={-1}
168+
>
169+
{maximized ? (
170+
<FilterNoneIcon fontSize="inherit" className={styles.restoreIcon} />
171+
) : (
172+
<CropSquareIcon fontSize="inherit" />
173+
)}
174+
</button>
175+
<button
176+
type="button"
177+
className={`${styles.button} ${styles.close}`}
178+
onClick={() => void appWindow.close()}
179+
aria-label="Close"
180+
tabIndex={-1}
181+
>
182+
<CloseIcon fontSize="inherit" />
183+
</button>
184+
</div>
185+
</div>
186+
);
187+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/* Desktop (Windows) custom window chrome.
2+
*
3+
* The window controls are a fixed overlay in the top-right corner (see
4+
* WindowTitleBar). These rules keep the app's own chrome clear of that corner.
5+
*
6+
* Loaded only in the desktop build (imported by WindowTitleBar) and gated on the
7+
* data-window-controls flag the overlay sets on <html> only when active, so it
8+
* is inert on macOS/Linux (flag never set) and absent from web builds. It
9+
* targets core layout class names on purpose: this is the single, contained
10+
* coupling point between the desktop chrome and the core layout, kept here in
11+
* the desktop layer so the core components stay unaware of window chrome. */
12+
13+
html[data-window-controls="custom"] {
14+
--wincontrols-w: 8.625rem; /* three 46px controls */
15+
--wincontrols-h: 2rem; /* control height */
16+
}
17+
18+
/* Files-page toolbar spans the width; reflow search + actions left of the
19+
controls by shrinking the grid's right edge. */
20+
html[data-window-controls="custom"] .files-page-header {
21+
padding-right: calc(0.75rem + var(--wincontrols-w));
22+
}
23+
24+
/* Right panel, expanded: move the header (PDF Tools, or an active tool such as
25+
Automate) below the controls instead of squashing its title. */
26+
html[data-window-controls="custom"] .tool-panel__compact-header,
27+
html[data-window-controls="custom"] .tool-panel .sui-panelhdr {
28+
margin-top: var(--wincontrols-h);
29+
}
30+
31+
/* Right panel, collapsed: the strip is narrower than the controls, so push its
32+
expand toggle straight down. */
33+
html[data-window-controls="custom"] .tool-panel__collapsed-strip {
34+
padding-top: calc(10px + var(--wincontrols-h));
35+
}
36+
37+
/* Viewer top bar: when the right panel is collapsed the bar widens under the
38+
controls. :has() detects the collapsed strip (no flag needed in core) and we
39+
push the right cluster clear. --nav-rail-w is the collapsed strip's width, so
40+
only the controls' overhang past it is reserved. */
41+
html[data-window-controls="custom"]
42+
.app-frame__content:has(.tool-panel__collapsed-strip)
43+
.workbench-bar-globals {
44+
margin-right: calc(var(--wincontrols-w) - var(--nav-rail-w));
45+
}
46+
47+
/* Mobile layout: the top bar spans the full width. Keep the brand top-left
48+
(already clear of the top-right controls) and drop the view switcher below
49+
them by top-aligning the row and nudging the switcher down. */
50+
html[data-window-controls="custom"] .mobile-toggle {
51+
align-items: flex-start;
52+
}
53+
html[data-window-controls="custom"] .mobile-toggle-buttons {
54+
margin-top: var(--wincontrols-h);
55+
}

0 commit comments

Comments
 (0)