feat: Implement custom window controls and titlebar management (#4919)

- Added WindowControls component for managing window actions (minimize, maximize, close) based on the operating system.
- Integrated window decoration toggle functionality to allow users to prefer system titlebar.
- Updated layout styles to accommodate new titlebar and window controls.
- Refactored layout components to utilize new window management hooks.
- Enhanced layout viewer to include a switch for enabling/disabling window decorations.
- Improved overall window management by introducing useWindow and useWindowDecorations hooks for better state handling.
This commit is contained in:
Tunglies
2025-10-08 20:23:26 +08:00
committed by GitHub
parent f195b3bccf
commit bfd1274a8c
10 changed files with 449 additions and 169 deletions

View File

@@ -6,6 +6,7 @@
- Linux 打包为 `.deb` `.rpm` 提供 pkexec 依赖项
- 支持前端修改日志(最大文件大小、最大保留数量)
- 新增链式代理图形化设置功能
- 新增系统标题栏与程序标题栏切换 (设置-页面设置-倾向系统标题栏)
- 监听关机事件,自动关闭系统代理
### 🚀 优化改进

View File

@@ -27,7 +27,8 @@ pub fn build_new_window() -> Result<WebviewWindow, String> {
)
.title("Clash Verge")
.center()
.decorations(true)
// Using WindowManager::prefer_system_titlebar to control if show system built-in titlebar
// .decorations(true)
.fullscreen(false)
.inner_size(DEFAULT_WIDTH, DEFAULT_HEIGHT)
.min_inner_size(MINIMAL_WIDTH, MINIMAL_HEIGHT)

View File

@@ -2,6 +2,13 @@
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
.layout-content {
/* New container for the flex layout */
display: flex;
flex: 1; /* Take remaining height */
overflow: hidden;
&__left {
@@ -9,11 +16,7 @@
display: flex;
height: 100%;
width: 100%;
// max-width: 225px;
// min-width: 225px;
// padding: 16px 0 8px;
padding: 0px 0px 8px;
// position: relative;
padding: 0 0 8px;
flex-direction: column;
align-self: stretch;
box-sizing: border-box;
@@ -23,27 +26,17 @@
-ms-user-select: none;
overflow: hidden;
border-right: 1px solid var(--divider-color);
// background-color: var(--background-color-alpha);
// $maxLogo: 100px;
.the-logo {
position: relative;
flex: 1 0 58px;
// width: 100%;
display: flex;
height: 100%;
padding: 0px 20px;
padding: 0 20px;
flex-direction: column;
justify-content: center;
align-items: flex-start;
align-self: stretch;
// border-bottom: 1px solid var(--divider-color);
// max-width: $maxLogo + 32px;
// max-height: $maxLogo;
// margin: 0 auto;
// padding: 0 auto;
// text-align: center;
box-sizing: border-box;
img,
@@ -51,11 +44,6 @@
width: 100%;
height: 100%;
pointer-events: none;
// fill: var(--primary-main);
// #bg {
// fill: var(--background-color);
// }
}
.the-newbtn {
@@ -80,7 +68,7 @@
> div {
margin: 0 auto;
padding: 0px 20px;
padding: 0 20px;
}
}
}
@@ -89,18 +77,14 @@
position: relative;
flex: 1 1 100%;
height: 100%;
// background-color: var(--background-color-alpha);
.the-bar {
// position: absolute;
// top: 0px;
// right: 0px;
height: 36px;
display: flex;
// align-items: center;
justify-content: end;
box-sizing: border-box;
z-index: 2;
.the-dragbar {
margin-top: 5px;
app-region: drag;
@@ -116,16 +100,23 @@
}
}
}
}
.linux,
.windows,
.unknown {
&.layout {
//.layout__left {
// padding-top: 24px;
//}
.the_titlebar {
width: 100%;
display: flex;
justify-content: flex-end;
padding: 10px;
box-sizing: border-box;
height: 36px;
border-bottom: 1px solid var(--divider-color);
}
.layout__left .the-logo {
.layout-content__left .the-logo {
flex: 1 0 58px;
margin-top: 10px;
margin-left: 10px;
@@ -135,7 +126,7 @@
padding-bottom: 16px;
}
.layout__right .the-content {
.layout-content__right .the-content {
top: 5px;
}
}
@@ -143,15 +134,25 @@
.macos {
&.layout {
.layout__left {
.the_titlebar {
width: 100%;
display: flex;
justify-content: flex-start;
padding: 10px;
box-sizing: border-box;
height: 36px;
border-bottom: 1px solid var(--divider-color);
}
.layout-content__left {
padding-top: 5px;
}
.layout__right .the-content {
.layout-content__right .the-content {
top: 5px;
}
.layout__left .the-newbtn {
.layout-content__left .the-newbtn {
right: 9px;
top: 2px;
}

View File

@@ -0,0 +1,114 @@
import { Close, CropSquare, FilterNone, Minimize } from "@mui/icons-material";
import { IconButton } from "@mui/material";
import { forwardRef, useImperativeHandle } from "react";
import { useWindowControls } from "@/hooks/use-window";
import getSystem from "@/utils/get-system";
export const WindowControls = forwardRef(function WindowControls(props, ref) {
const OS = getSystem();
const {
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
} = useWindowControls();
useImperativeHandle(
ref,
() => ({
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
}),
[
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
],
);
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准
return (
<div style={{ display: "flex", gap: 4 }}>
{OS === "macos" && (
<>
{/* macOS 风格:关闭 → 最小化 → 全屏 */}
<IconButton size="small" sx={{ fontSize: 14 }} onClick={close}>
<Close fontSize="inherit" color="inherit" />
</IconButton>
<IconButton size="small" sx={{ fontSize: 14 }} onClick={minimize}>
<Minimize fontSize="inherit" color="inherit" />
</IconButton>
<IconButton
size="small"
sx={{ fontSize: 14 }}
onClick={toggleMaximize}
>
{maximized ? (
<FilterNone fontSize="inherit" color="inherit" />
) : (
<CropSquare fontSize="inherit" color="inherit" />
)}
</IconButton>
</>
)}
{OS === "windows" && (
<>
{/* Windows 风格:最小化 → 最大化 → 关闭 */}
<IconButton size="small" sx={{ fontSize: 14 }} onClick={minimize}>
<Minimize fontSize="small" color="inherit" />
</IconButton>
<IconButton
size="small"
sx={{ fontSize: 14 }}
onClick={toggleMaximize}
>
{maximized ? (
<FilterNone fontSize="small" color="inherit" />
) : (
<CropSquare fontSize="small" color="inherit" />
)}
</IconButton>
<IconButton size="small" sx={{ fontSize: 14 }} onClick={close}>
<Close fontSize="small" color="inherit" />
</IconButton>
</>
)}
{OS === "linux" && (
<>
{/* Linux 桌面常见布局GNOME/KDE 多为:最小化 → 最大化 → 关闭) */}
<IconButton size="small" sx={{ fontSize: 14 }} onClick={minimize}>
<Minimize fontSize="small" color="inherit" />
</IconButton>
<IconButton
size="small"
sx={{ fontSize: 14 }}
onClick={toggleMaximize}
>
{maximized ? (
<FilterNone fontSize="small" color="inherit" />
) : (
<CropSquare fontSize="small" color="inherit" />
)}
</IconButton>
<IconButton size="small" sx={{ fontSize: 14 }} onClick={close}>
<Close fontSize="small" color="inherit" />
</IconButton>
</>
)}
</div>
);
});

View File

@@ -1,12 +1,12 @@
import {
List,
Box,
Button,
Select,
MenuItem,
styled,
List,
ListItem,
ListItemText,
Box,
MenuItem,
Select,
styled,
} from "@mui/material";
import { convertFileSrc } from "@tauri-apps/api/core";
import { join } from "@tauri-apps/api/path";
@@ -18,10 +18,10 @@ import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { useVerge } from "@/hooks/use-verge";
import { useWindowDecorations } from "@/hooks/use-window";
import { copyIconFile, getAppDir } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
import { GuardState } from "./guard-state";
const OS = getSystem();
@@ -47,6 +47,8 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
const [sysproxyIcon, setSysproxyIcon] = useState("");
const [tunIcon, setTunIcon] = useState("");
const { decorated, toggleDecorations } = useWindowDecorations();
useEffect(() => {
initIconPath();
}, []);
@@ -108,6 +110,21 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
onCancel={() => setOpen(false)}
>
<List>
<Item>
<ListItemText primary={t("Prefer System Titlebar")} />
<GuardState
value={decorated}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={async (e) => {
await toggleDecorations();
}}
>
<Switch edge="end" />
</GuardState>
</Item>
<Item>
<ListItemText primary={t("Traffic Graph")} />
<GuardState

View File

@@ -8,11 +8,11 @@ import { DialogRef } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import {
exitApp,
exportDiagnosticInfo,
openAppDir,
openCoreDir,
openLogsDir,
openDevTools,
exportDiagnosticInfo,
openLogsDir,
} from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import { version } from "@root/package.json";
@@ -23,7 +23,7 @@ import { HotkeyViewer } from "./mods/hotkey-viewer";
import { LayoutViewer } from "./mods/layout-viewer";
import { LiteModeViewer } from "./mods/lite-mode-viewer";
import { MiscViewer } from "./mods/misc-viewer";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { SettingItem, SettingList } from "./mods/setting-comp";
import { ThemeViewer } from "./mods/theme-viewer";
import { UpdateViewer } from "./mods/update-viewer";

View File

@@ -1,5 +1,5 @@
import { ContentCopyRounded } from "@mui/icons-material";
import { Button, MenuItem, Select, Input } from "@mui/material";
import { Button, Input, MenuItem, Select } from "@mui/material";
import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -19,7 +19,7 @@ import { GuardState } from "./mods/guard-state";
import { HotkeyViewer } from "./mods/hotkey-viewer";
import { LayoutViewer } from "./mods/layout-viewer";
import { MiscViewer } from "./mods/misc-viewer";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { SettingItem, SettingList } from "./mods/setting-comp";
import { ThemeModeSwitch } from "./mods/theme-mode-switch";
import { ThemeViewer } from "./mods/theme-viewer";
import { UpdateViewer } from "./mods/update-viewer";

114
src/hooks/use-window.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import React, {
createContext,
useCallback,
use,
useEffect,
useState,
} from "react";
interface WindowContextType {
decorated: boolean | null;
maximized: boolean | null;
toggleDecorations: () => Promise<void>;
refreshDecorated: () => Promise<boolean>;
minimize: () => void;
close: () => void;
toggleMaximize: () => Promise<void>;
toggleFullscreen: () => Promise<void>;
currentWindow: ReturnType<typeof getCurrentWindow>;
}
const WindowContext = createContext<WindowContextType | undefined>(undefined);
export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const currentWindow = getCurrentWindow();
const [decorated, setDecorated] = useState<boolean | null>(null);
const [maximized, setMaximized] = useState<boolean | null>(null);
const close = useCallback(() => currentWindow.close(), [currentWindow]);
const minimize = useCallback(() => currentWindow.minimize(), [currentWindow]);
const toggleMaximize = useCallback(async () => {
if (await currentWindow.isMaximized()) {
await currentWindow.unmaximize();
setMaximized(false);
} else {
await currentWindow.maximize();
setMaximized(true);
}
}, [currentWindow]);
const toggleFullscreen = useCallback(async () => {
await currentWindow.setFullscreen(!(await currentWindow.isFullscreen()));
}, [currentWindow]);
const refreshDecorated = useCallback(async () => {
const val = await currentWindow.isDecorated();
setDecorated(val);
return val;
}, [currentWindow]);
const toggleDecorations = useCallback(async () => {
const currentVal = await currentWindow.isDecorated();
await currentWindow.setDecorations(!currentVal);
setDecorated(!currentVal);
}, [currentWindow]);
useEffect(() => {
refreshDecorated();
currentWindow.setMinimizable?.(true);
}, [currentWindow, refreshDecorated]);
return (
<WindowContext
value={{
decorated,
maximized,
toggleDecorations,
refreshDecorated,
minimize,
close,
toggleMaximize,
toggleFullscreen,
currentWindow,
}}
>
{children}
</WindowContext>
);
};
export const useWindow = () => {
const context = use(WindowContext);
if (context === undefined) {
throw new Error("useWindow must be used within WindowProvider");
}
return context;
};
export const useWindowControls = () => {
const {
maximized,
minimize,
toggleMaximize,
close,
toggleFullscreen,
currentWindow,
} = useWindow();
return {
maximized,
minimize,
toggleMaximize,
close,
toggleFullscreen,
currentWindow,
};
};
export const useWindowDecorations = () => {
const { decorated, toggleDecorations, refreshDecorated } = useWindow();
return { decorated, toggleDecorations, refreshDecorated };
};

View File

@@ -10,6 +10,7 @@ import { BrowserRouter } from "react-router-dom";
import { MihomoWebSocket } from "tauri-plugin-mihomo-api";
import { BaseErrorBoundary } from "./components/base";
import { WindowProvider } from "./hooks/use-window";
import Layout from "./pages/_layout";
import { AppDataProvider } from "./providers/app-data-provider";
import { initializeLanguage } from "./services/i18n";
@@ -61,11 +62,13 @@ const initializeApp = async () => {
<React.StrictMode>
<ComposeContextProvider contexts={contexts}>
<BaseErrorBoundary>
<WindowProvider>
<AppDataProvider>
<BrowserRouter>
<Layout />
</BrowserRouter>
</AppDataProvider>
</WindowProvider>
</BaseErrorBoundary>
</ComposeContextProvider>
</React.StrictMode>,

View File

@@ -4,7 +4,7 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate, useRoutes } from "react-router-dom";
import { SWRConfig, mutate } from "swr";
@@ -25,6 +25,7 @@ import { useLogData } from "@/hooks/use-log-data-new";
import { useMemoryData } from "@/hooks/use-memory-data";
import { useTrafficData } from "@/hooks/use-traffic-data";
import { useVerge } from "@/hooks/use-verge";
import { useWindowDecorations } from "@/hooks/use-window";
import { getAxios } from "@/services/api";
import { showNotice } from "@/services/noticeService";
import { useClashLog, useThemeMode } from "@/services/states";
@@ -35,6 +36,9 @@ import { routers } from "./_routers";
import "dayjs/locale/ru";
import "dayjs/locale/zh-cn";
import { WindowControls } from "@/components/controller/window-controller";
// 删除重复导入
const appWindow = getCurrentWebviewWindow();
export const portableFlag = false;
@@ -174,6 +178,26 @@ const Layout = () => {
const initRef = useRef(false);
const [themeReady, setThemeReady] = useState(false);
const windowControls = useRef<any>(null);
const { decorated } = useWindowDecorations();
const customTitlebar = useMemo(() => {
console.debug(
"[Layout] Titlebar rendering - decorated:",
decorated,
"| showing:",
!decorated,
);
if (!decorated) {
return (
<div className="the_titlebar" data-tauri-drag-region="true">
<WindowControls ref={windowControls} />
</div>
);
}
return null;
}, [decorated]);
useEffect(() => {
setThemeReady(true);
}, [theme]);
@@ -495,6 +519,7 @@ const Layout = () => {
}}
>
<ThemeProvider theme={theme}>
{/* 左侧底部窗口控制按钮 */}
<NoticeManager />
<div
style={{
@@ -542,8 +567,12 @@ const Layout = () => {
: {},
]}
>
<div className="layout__left">
<div className="the-logo" data-tauri-drag-region="true">
{/* Custom titlebar - rendered only when decorated is false, memoized for performance */}
{customTitlebar}
<div className="layout-content">
<div className="layout-content__left">
<div className="the-logo" data-tauri-drag-region="false">
<div
data-tauri-drag-region="true"
style={{
@@ -585,13 +614,13 @@ const Layout = () => {
</div>
</div>
<div className="layout__right">
<div className="layout-content__right">
<div className="the-bar"></div>
<div className="the-content">
{React.cloneElement(routersEles, { key: location.pathname })}
</div>
</div>
</div>
</Paper>
</ThemeProvider>
</SWRConfig>