mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
feat(desktop): custom in-app window title bar on Windows
Replace the native Windows title bar with an in-app one: remove it (decorations:false, Windows only) and draw a full-width themed drag region with minimize/maximize/close controls, above the rail and panels so their vertical dividers meet a clean edge instead of the native caption seam. - Rust: set_decorations(false) on Windows (main window at setup, spawned windows at build time). macOS/Linux keep native decorations. - WindowTitleBar (desktop) draws the bar + controls; the core stub renders nothing so the browser build is unchanged (stub/shadow via @app). - AppFrame becomes a column [title bar, rail+content]; AppLayout reserves the bar height via calc(100dvh - var(--titlebar-h)), set only on Windows. - capabilities: allow window minimize / toggle-maximize / close / start-dragging / internal-toggle-maximize / is-maximized. tao provides edge/corner resize for the undecorated window. The Windows 11 Snap Layout hover flyout is not yet wired (double-click and keyboard snap still work). Alternative to the DWM caption approach in #7780.
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -22,7 +22,15 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{ height: "100dvh", display: "flex", flexDirection: "column" }}
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{banner}
|
||||
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Core stub: the browser build has no custom window chrome. The desktop layer
|
||||
// shadows this (desktop/components/WindowTitleBar.tsx) with a real title bar on
|
||||
// Windows. Rendered by AppFrame, so it must resolve in every build.
|
||||
export function WindowTitleBar() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
/* ========== APP FRAME ========== */
|
||||
/* The rail's column, then whichever app is mounted, so a switch changes only the app. */
|
||||
/* A column: the optional custom title bar (desktop), then the rail + app row. */
|
||||
.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;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Outlet } from "react-router-dom";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext";
|
||||
import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost";
|
||||
import { WindowTitleBar } from "@app/components/WindowTitleBar";
|
||||
import "@app/components/layout/AppFrame.css";
|
||||
|
||||
/** The rail renders once outside both apps; Suspense sits inside it, not above. */
|
||||
@@ -10,11 +11,17 @@ export function AppFrame() {
|
||||
return (
|
||||
<QuickNavHostProvider>
|
||||
<div className="app-frame">
|
||||
<QuickNavRailHost />
|
||||
<div className="app-frame__content">
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
{/* 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. */}
|
||||
<WindowTitleBar />
|
||||
<div className="app-frame__body">
|
||||
<QuickNavRailHost />
|
||||
<div className="app-frame__content">
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</QuickNavHostProvider>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/* 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. */
|
||||
.titleBar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
height: var(--titlebar-h, 2rem);
|
||||
background: var(--c-bg);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* Keep the window controls above app overlays so they stay usable. */
|
||||
position: relative;
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
.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,116 @@
|
||||
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 "./WindowTitleBar.module.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 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
|
||||
* in the browser build (core stub). 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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
useIsomorphicEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (active) {
|
||||
root.style.setProperty("--titlebar-h", "2rem");
|
||||
} else {
|
||||
root.style.removeProperty("--titlebar-h");
|
||||
}
|
||||
return () => root.style.removeProperty("--titlebar-h");
|
||||
}, [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]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
return (
|
||||
<div className={styles.titleBar} data-tauri-drag-region>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user