Compare commits

...
Author SHA1 Message Date
ConnorYoh cbc8af1951 feat(desktop): custom in-app window title bar on Windows (#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
2026-09-02 21:31:45 +00:00
7 changed files with 325 additions and 2 deletions
@@ -6,6 +6,12 @@
"permissions": [
"core:default",
"core:window:allow-destroy",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-internal-toggle-maximize",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"http:default",
{
"identifier": "http:allow-fetch",
@@ -48,8 +48,11 @@ fn build_window(app: &AppHandle, label: &str, url: &str) -> Result<WebviewWindow
// dir (and thus IndexedDB / localStorage / cookies). macOS (WKWebView) and
// Linux (WebKitGTK) don't have this constraint, so the arg is Windows-only.
#[cfg(target_os = "windows")]
let builder =
builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature");
let builder = builder
.additional_browser_args("--enable-features=CertVerifierBuiltinFeature")
// Windows: no native title bar; the frontend draws its own (WindowTitleBar).
// macOS/Linux keep native decorations.
.decorations(false);
builder.build().map_err(|e| e.to_string())
}
+11
View File
@@ -146,6 +146,17 @@ pub fn run() {
.setup(|app| {
add_log("🚀 Tauri app setup started".to_string());
// Windows: drop the native title bar so the in-app custom title bar
// (window controls + drag region) takes over. Runtime toggle because the
// main window is defined in tauri.conf.json; spawned windows set it at
// build time in window.rs. macOS/Linux keep their native decorations.
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.set_decorations(false);
}
}
// Files passed on the command line at first launch load into the main
// window once the frontend mounts.
let args: Vec<String> = std::env::args().collect();
@@ -1,6 +1,7 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
import { WindowTitleBar } from "@app/components/WindowTitleBar";
import { DesktopQueryCacheReset } from "@app/components/DesktopQueryCacheReset";
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
@@ -328,6 +329,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
>
{/* Also here: the auth check below switches mode pre-authChecked. */}
<DesktopQueryCacheReset />
<WindowTitleBar />
<div style={{ minHeight: "100vh" }} />
{updatePopupModal}
</ProprietaryAppProviders>
@@ -354,6 +356,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
>
<DesktopQueryCacheReset />
<WindowTitleBar />
<SaaSTeamProvider key={appKey}>
<DesktopConfigSync />
<DesktopBannerInitializer />
@@ -0,0 +1,58 @@
/* 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;
height: 2rem;
user-select: none;
-webkit-user-select: none;
}
.controls {
display: flex;
align-items: stretch;
height: 100%;
}
.button {
width: 46px;
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: var(--c-text-muted);
font-size: 15px; /* drives the inherit-sized MUI icons */
line-height: 1;
cursor: default;
transition:
background-color 0.12s ease,
color 0.12s ease;
}
/* Clicks (and Tauri's drag-region hit test) should always resolve to the
button, never its SVG glyph. */
.button svg {
pointer-events: none;
}
.button:hover {
background: var(--c-hover);
color: var(--c-text);
}
.close:hover {
background: var(--c-danger);
color: #fff;
}
.restoreIcon {
/* The overlapping-squares glyph reads large next to the others. */
font-size: 13px;
}
@@ -0,0 +1,187 @@
import { useEffect, useState } from "react";
import { useIsomorphicEffect } from "@mantine/hooks";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { isTauri } from "@tauri-apps/api/core";
import MinimizeIcon from "@mui/icons-material/Minimize";
import CropSquareIcon from "@mui/icons-material/CropSquare";
import FilterNoneIcon from "@mui/icons-material/FilterNone";
import CloseIcon from "@mui/icons-material/Close";
import { getDesktopOs, DesktopOs } from "@app/services/platformService";
import styles from "@app/components/WindowTitleBar.module.css";
// Desktop-only skin that reserves the controls' corner across core layout
// surfaces; every rule is gated by the data-window-controls flag set below.
import "@app/components/windowChrome.css";
// Seed from the UA so the bar (and its reserved height) is present on the first
// frame on Windows, avoiding a layout shift. getDesktopOs() confirms it right
// after via the Rust command.
const seedIsWindows =
typeof navigator !== "undefined" && /Windows/i.test(navigator.userAgent);
/**
* 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 the
* data-window-controls flag this sets on <html> (consumed by windowChrome.css).
* Renders nothing on macOS/Linux (native decorations kept); not bundled in the
* browser build. tao provides edge/corner resize for the undecorated window, so
* no manual resize handles are needed here.
*/
export function WindowTitleBar() {
const [isWindows, setIsWindows] = useState(seedIsWindows);
const [maximized, setMaximized] = useState(false);
const active = isWindows && isTauri();
// Confirm the OS authoritatively (the UA seed is only a first-frame guess).
useEffect(() => {
if (!isTauri()) {
setIsWindows(false);
return;
}
let mounted = true;
void getDesktopOs().then((os) => {
if (mounted) setIsWindows(os === DesktopOs.Windows);
});
return () => {
mounted = false;
};
}, []);
// Flag the custom chrome on <html> so windowChrome.css can reserve the
// controls' corner across the app. Set only when active (Windows desktop);
// absent otherwise, so the skin is inert on macOS/Linux. Layout effect so it
// lands before paint.
useIsomorphicEffect(() => {
const root = document.documentElement;
if (active) {
root.setAttribute("data-window-controls", "custom");
} else {
root.removeAttribute("data-window-controls");
}
return () => {
root.removeAttribute("data-window-controls");
};
}, [active]);
// Keep the maximize/restore icon in sync with the actual window state
// (double-click, snap, or the button itself all change it).
useEffect(() => {
if (!active) return;
const appWindow = getCurrentWindow();
let unlisten: (() => void) | undefined;
void appWindow.isMaximized().then(setMaximized);
void appWindow
.onResized(() => {
void appWindow.isMaximized().then(setMaximized);
})
.then((u) => {
unlisten = u;
});
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 on
// bare container backgrounds, leaving most of a busy toolbar undraggable, so a
// document-level hit test covers the whole top instead.
//
// startDragging() must NOT run on mousedown: it enters the OS drag loop and
// swallows the browser's dblclick. So begin the drag on the first real move,
// and detect a double-click from the interval between mousedowns.
useEffect(() => {
if (!active) return;
const TOP_STRIP_PX = 48;
const DOUBLE_CLICK_MS = 500;
const DRAG_THRESHOLD_PX = 4;
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);
};
let pending: { x: number; y: number } | null = null;
let lastDownAt = 0;
const onMouseDown = (e: MouseEvent) => {
if (!draggableAt(e)) {
pending = null;
return;
}
const now = Date.now();
if (now - lastDownAt < DOUBLE_CLICK_MS) {
pending = null;
lastDownAt = 0;
void getCurrentWindow().toggleMaximize();
return;
}
lastDownAt = now;
pending = { x: e.clientX, y: e.clientY };
};
const onMouseMove = (e: MouseEvent) => {
if (!pending) return;
if (
Math.abs(e.clientX - pending.x) > DRAG_THRESHOLD_PX ||
Math.abs(e.clientY - pending.y) > DRAG_THRESHOLD_PX
) {
pending = null;
lastDownAt = 0; // a drag is not the first half of a double-click
void getCurrentWindow().startDragging();
}
};
const onMouseUp = () => {
pending = null;
};
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
}, [active]);
if (!active) return null;
const appWindow = getCurrentWindow();
return (
<div className={styles.titleBar}>
<div className={styles.controls}>
<button
type="button"
className={styles.button}
onClick={() => void appWindow.minimize()}
aria-label="Minimize"
tabIndex={-1}
>
<MinimizeIcon fontSize="inherit" />
</button>
<button
type="button"
className={styles.button}
onClick={() => void appWindow.toggleMaximize()}
aria-label={maximized ? "Restore" : "Maximize"}
tabIndex={-1}
>
{maximized ? (
<FilterNoneIcon fontSize="inherit" className={styles.restoreIcon} />
) : (
<CropSquareIcon fontSize="inherit" />
)}
</button>
<button
type="button"
className={`${styles.button} ${styles.close}`}
onClick={() => void appWindow.close()}
aria-label="Close"
tabIndex={-1}
>
<CloseIcon fontSize="inherit" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,55 @@
/* Desktop (Windows) custom window chrome.
*
* The window controls are a fixed overlay in the top-right corner (see
* WindowTitleBar). These rules keep the app's own chrome clear of that corner.
*
* Loaded only in the desktop build (imported by WindowTitleBar) and gated on the
* data-window-controls flag the overlay sets on <html> only when active, so it
* is inert on macOS/Linux (flag never set) and absent from web builds. It
* targets core layout class names on purpose: this is the single, contained
* coupling point between the desktop chrome and the core layout, kept here in
* the desktop layer so the core components stay unaware of window chrome. */
html[data-window-controls="custom"] {
--wincontrols-w: 8.625rem; /* three 46px controls */
--wincontrols-h: 2rem; /* control height */
}
/* Files-page toolbar spans the width; reflow search + actions left of the
controls by shrinking the grid's right edge. */
html[data-window-controls="custom"] .files-page-header {
padding-right: calc(0.75rem + var(--wincontrols-w));
}
/* Right panel, expanded: move the header (PDF Tools, or an active tool such as
Automate) below the controls instead of squashing its title. */
html[data-window-controls="custom"] .tool-panel__compact-header,
html[data-window-controls="custom"] .tool-panel .sui-panelhdr {
margin-top: var(--wincontrols-h);
}
/* Right panel, collapsed: the strip is narrower than the controls, so push its
expand toggle straight down. */
html[data-window-controls="custom"] .tool-panel__collapsed-strip {
padding-top: calc(10px + var(--wincontrols-h));
}
/* Viewer top bar: when the right panel is collapsed the bar widens under the
controls. :has() detects the collapsed strip (no flag needed in core) and we
push the right cluster clear. --nav-rail-w is the collapsed strip's width, so
only the controls' overhang past it is reserved. */
html[data-window-controls="custom"]
.app-frame__content:has(.tool-panel__collapsed-strip)
.workbench-bar-globals {
margin-right: calc(var(--wincontrols-w) - var(--nav-rail-w));
}
/* Mobile layout: the top bar spans the full width. Keep the brand top-left
(already clear of the top-right controls) and drop the view switcher below
them by top-aligning the row and nudging the switcher down. */
html[data-window-controls="custom"] .mobile-toggle {
align-items: flex-start;
}
html[data-window-controls="custom"] .mobile-toggle-buttons {
margin-top: var(--wincontrols-h);
}