Skip to content

Commit b34cbe6

Browse files
evanklemclaude
andcommitted
Add per-platform window chrome for the borderless window
The window ran with decorations: false on every platform but drew no titlebar of its own, so on Windows (and stacking Linux desktops) it had no minimize/maximize/close and could not be moved. Tiling WMs hid the problem by moving windows themselves. Windows/Linux: keep decorations off and supply our own chrome. The header is a drag region (data-tauri-drag-region) and a WindowControls component renders flush-corner minimize/maximize/close buttons, wired to the Tauri window via the global API. The maximize glyph re-syncs from the real window state on resize so OS-side snap/maximize stays consistent. macOS: tauri.macos.conf.json keeps native decorations with titleBarStyle "Overlay" + hiddenTitle, so the native traffic lights show; we render no custom controls there and inset the topbar's left edge to clear them. Controls and the mac inset are gated on the Tauri window handle being present, so they are inert in a browser and under jsdom. Adds the window permissions the chrome needs (start-dragging, minimize, toggle-maximize, close). Also removes the polypore version brand tag from the topbar. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 95d6cac commit b34cbe6

7 files changed

Lines changed: 220 additions & 4 deletions

File tree

src-tauri/tauri.conf.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646
"core:default",
4747
"core:event:default",
4848
"core:window:default",
49+
"core:window:allow-start-dragging",
50+
"core:window:allow-minimize",
51+
"core:window:allow-toggle-maximize",
52+
"core:window:allow-close",
4953
"core:webview:default",
5054
"core:webview:allow-set-webview-zoom",
5155
"updater:default"

src-tauri/tauri.macos.conf.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"$schema": "https://schema.tauri.app/config/2",
3+
"app": {
4+
"windows": [
5+
{
6+
"title": "Polypore",
7+
"width": 1400,
8+
"height": 900,
9+
"minWidth": 1100,
10+
"minHeight": 700,
11+
"decorations": true,
12+
"dragDropEnabled": false,
13+
"titleBarStyle": "Overlay",
14+
"hiddenTitle": true
15+
}
16+
]
17+
}
18+
}

src/App.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,6 @@ test('new-project wizard rejects names that do not start with a letter or number
289289
test('renders the default glassy operator workspace shell', () => {
290290
render(<App />);
291291

292-
expect(screen.getByText('polypore v0.1.0')).toBeInTheDocument();
293292
expect(screen.getByText('workspace')).toBeInTheDocument();
294293
expect(screen.getByRole('button', { name: /git branch none/i })).toBeInTheDocument();
295294
expect(screen.getByRole('button', { name: /^help$/i })).toBeInTheDocument();

src/components/topbar/TopBar.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { useEffect, useRef, useState } from 'react';
1+
import { useEffect, useMemo, useRef, useState } from 'react';
22
import type { LaunchTarget } from '../../Launcher';
33
import type { PanelType, UserWorkspacePreset, WorkspaceName } from '../../core/types';
44
import { GitMenu, type GitNotice } from './GitMenu';
5+
import { getAppWindow, IS_MAC } from './platform';
56
import { ProjectMenu } from './ProjectMenu';
7+
import { WindowControls } from './WindowControls';
68
import { WorkspaceMenu } from './WorkspaceMenu';
79
import type { ProjectStatusResult, TauriInvoke } from './types';
810

@@ -46,6 +48,11 @@ export function TopBar({
4648
const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
4749
const [gitNotice, setGitNotice] = useState<GitNotice | null>(null);
4850
const headerRef = useRef<HTMLElement>(null);
51+
/* Present only inside the Tauri desktop shell. On macOS the native traffic
52+
lights handle min/max/close, so we draw our own controls only off-mac and
53+
instead inset the left edge to clear the overlaid traffic lights. */
54+
const appWindow = useMemo(() => getAppWindow(), []);
55+
const macInset = appWindow !== null && IS_MAC;
4956
const [status, setStatus] = useState<ProjectStatusResult>({
5057
path: '',
5158
name: 'polypore',
@@ -114,7 +121,11 @@ export function TopBar({
114121
}, [openMenu]);
115122

116123
return (
117-
<header className="topbar" ref={headerRef}>
124+
<header
125+
className={`topbar${macInset ? ' topbar--mac' : ''}`}
126+
ref={headerRef}
127+
data-tauri-drag-region
128+
>
118129
<ProjectMenu
119130
status={status}
120131
onStatusChange={setStatus}
@@ -151,7 +162,7 @@ export function TopBar({
151162
render elsewhere when they're real. */}
152163
<button className="segment settings-button" title="settings" aria-label="settings" onClick={onOpenSettings}>settings</button>
153164
<button className="segment help-button" title="help" aria-label="help" onClick={onOpenHelp}>help</button>
154-
<div className="segment segment--brand">polypore v{__APP_VERSION__}</div>
165+
{appWindow && !IS_MAC && <WindowControls appWindow={appWindow} />}
155166
{gitNotice && (
156167
<div
157168
className={`git-toast git-toast--${gitNotice.tone}`}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
import type { AppWindowControls } from './platform';
3+
4+
/* Caption buttons for the borderless Windows/Linux window. The middle button's
5+
glyph flips to a "restore" pair while the window is maximized; we re-query the
6+
real state on every OS resize rather than trusting our own toggle, so an
7+
OS-driven maximize (Win+Up, snap, double-clicking the drag region) stays in
8+
sync with the glyph. Tauri calls reject if the window is gone mid-action —
9+
swallow those, there is nothing to recover. */
10+
export function WindowControls({ appWindow }: { appWindow: AppWindowControls }) {
11+
const [maximized, setMaximized] = useState(false);
12+
13+
useEffect(() => {
14+
let cancelled = false;
15+
const sync = () => {
16+
appWindow
17+
.isMaximized()
18+
.then((value) => {
19+
if (!cancelled) setMaximized(value);
20+
})
21+
.catch(() => {});
22+
};
23+
sync();
24+
window.addEventListener('resize', sync);
25+
return () => {
26+
cancelled = true;
27+
window.removeEventListener('resize', sync);
28+
};
29+
}, [appWindow]);
30+
31+
const minimize = useCallback(() => {
32+
appWindow.minimize().catch(() => {});
33+
}, [appWindow]);
34+
const toggleMaximize = useCallback(() => {
35+
appWindow.toggleMaximize().catch(() => {});
36+
}, [appWindow]);
37+
const close = useCallback(() => {
38+
appWindow.close().catch(() => {});
39+
}, [appWindow]);
40+
41+
return (
42+
<div className="window-controls" role="group" aria-label="window controls">
43+
<button
44+
type="button"
45+
className="window-control"
46+
aria-label="minimize"
47+
title="Minimize"
48+
onClick={minimize}
49+
>
50+
<svg viewBox="0 0 10 10" aria-hidden="true" focusable="false">
51+
<path d="M1 5h8" stroke="currentColor" strokeWidth="1" />
52+
</svg>
53+
</button>
54+
<button
55+
type="button"
56+
className="window-control"
57+
aria-label={maximized ? 'restore' : 'maximize'}
58+
title={maximized ? 'Restore' : 'Maximize'}
59+
onClick={toggleMaximize}
60+
>
61+
{maximized ? (
62+
<svg viewBox="0 0 10 10" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" strokeWidth="1">
63+
<path d="M2.5 3.5V1.5h6v6h-2" />
64+
<rect x="1.5" y="3.5" width="5" height="5" />
65+
</svg>
66+
) : (
67+
<svg viewBox="0 0 10 10" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" strokeWidth="1">
68+
<rect x="1.5" y="1.5" width="7" height="7" />
69+
</svg>
70+
)}
71+
</button>
72+
<button
73+
type="button"
74+
className="window-control window-control--close"
75+
aria-label="close"
76+
title="Close"
77+
onClick={close}
78+
>
79+
<svg viewBox="0 0 10 10" aria-hidden="true" focusable="false" stroke="currentColor" strokeWidth="1">
80+
<path d="M1.5 1.5l7 7M8.5 1.5l-7 7" />
81+
</svg>
82+
</button>
83+
</div>
84+
);
85+
}

src/components/topbar/platform.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/* Window chrome differs by OS. On macOS we keep the native traffic lights
2+
(tauri.macos.conf.json sets titleBarStyle: "Overlay") and render no custom
3+
controls; on Windows/Linux the native title bar is off (decorations: false)
4+
and we draw our own minimize / maximize / close.
5+
6+
Both the controls and the macOS left-inset run only inside the Tauri desktop
7+
shell. In a plain browser or under jsdom there is no native window to drive,
8+
so getAppWindow() returns null and callers no-op. */
9+
10+
export interface AppWindowControls {
11+
minimize(): Promise<void>;
12+
toggleMaximize(): Promise<void>;
13+
close(): Promise<void>;
14+
isMaximized(): Promise<boolean>;
15+
}
16+
17+
type GlobalTauri = {
18+
window?: { getCurrentWindow?: () => AppWindowControls };
19+
};
20+
21+
export const IS_MAC =
22+
typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent);
23+
24+
/* The handle exposed by Tauri's global API (withGlobalTauri). Returns null when
25+
not running inside the desktop shell. */
26+
export function getAppWindow(): AppWindowControls | null {
27+
const tauri = (window as Window & { __TAURI__?: GlobalTauri }).__TAURI__;
28+
return tauri?.window?.getCurrentWindow?.() ?? null;
29+
}

src/components/topbar/topbar.css

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,76 @@
227227
}
228228

229229

230+
/* Custom caption buttons, shown only on Windows/Linux where the native title
231+
bar is off (decorations: false). They sit flush in the top-right corner —
232+
the negative margins cancel the topbar's padding so the hit targets reach the
233+
window edges (Fitts's law). macOS never renders these; .topbar--mac instead
234+
insets the left edge to clear the native traffic lights overlaid by
235+
titleBarStyle: "Overlay". */
236+
.window-controls {
237+
display: flex;
238+
align-self: stretch;
239+
margin: -4px -8px -4px 2px;
240+
}
241+
242+
243+
.window-control {
244+
display: inline-flex;
245+
align-items: center;
246+
justify-content: center;
247+
width: 44px;
248+
padding: 0;
249+
border: none;
250+
border-radius: 0;
251+
background: transparent;
252+
color: var(--muted);
253+
cursor: default;
254+
transition: background 0.1s, color 0.1s;
255+
}
256+
257+
258+
.window-control:hover {
259+
background: var(--accent-12);
260+
color: var(--ink);
261+
}
262+
263+
264+
.window-control:active {
265+
background: var(--accent-18);
266+
}
267+
268+
269+
.window-control:focus-visible {
270+
outline: 1px solid var(--accent);
271+
outline-offset: -3px;
272+
}
273+
274+
275+
.window-control svg {
276+
width: 10px;
277+
height: 10px;
278+
}
279+
280+
281+
/* Close is the one caption button with a destructive, platform-standard red
282+
hover so an accidental hover reads clearly before the click. */
283+
.window-control--close:hover {
284+
background: #e81123;
285+
color: #fff;
286+
}
287+
288+
289+
.window-control--close:active {
290+
background: #c50f1f;
291+
color: #fff;
292+
}
293+
294+
295+
.topbar--mac {
296+
padding-left: 76px;
297+
}
298+
299+
230300
.git-branch-menu {
231301
position: relative;
232302
flex: 0 0 auto;

0 commit comments

Comments
 (0)