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
Unverified
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,118 +2,103 @@
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
&__left {
flex: 1 0 200px;
.layout-content {
/* New container for the flex layout */
display: flex;
height: 100%;
width: 100%;
// max-width: 225px;
// min-width: 225px;
// padding: 16px 0 8px;
padding: 0px 0px 8px;
// position: relative;
flex-direction: column;
align-self: stretch;
box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
flex: 1; /* Take remaining height */
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%;
&__left {
flex: 1 0 200px;
display: flex;
height: 100%;
padding: 0px 20px;
width: 100%;
padding: 0 0 8px;
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;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
overflow: hidden;
border-right: 1px solid var(--divider-color);
img,
svg {
width: 100%;
.the-logo {
position: relative;
flex: 1 0 58px;
display: flex;
height: 100%;
pointer-events: none;
// fill: var(--primary-main);
padding: 0 20px;
flex-direction: column;
justify-content: center;
align-items: flex-start;
align-self: stretch;
box-sizing: border-box;
// #bg {
// fill: var(--background-color);
// }
img,
svg {
width: 100%;
height: 100%;
pointer-events: none;
}
.the-newbtn {
position: absolute;
right: 10px;
top: 15px;
border-radius: 8px;
padding: 2px 4px;
transform: scale(0.8);
}
}
.the-newbtn {
.the-menu {
flex: 1 1 80%;
overflow-y: auto;
margin-bottom: 0px;
padding-top: 4px;
}
.the-traffic {
flex: 0 0 60px;
> div {
margin: 0 auto;
padding: 0 20px;
}
}
}
&__right {
position: relative;
flex: 1 1 100%;
height: 100%;
.the-bar {
height: 36px;
display: flex;
justify-content: end;
box-sizing: border-box;
z-index: 2;
.the-dragbar {
margin-top: 5px;
app-region: drag;
}
}
.the-content {
position: absolute;
right: 10px;
top: 15px;
border-radius: 8px;
padding: 2px 4px;
transform: scale(0.8);
top: 0;
left: 0;
right: 1px;
bottom: 0px;
}
}
.the-menu {
flex: 1 1 80%;
overflow-y: auto;
margin-bottom: 0px;
padding-top: 4px;
}
.the-traffic {
flex: 0 0 60px;
> div {
margin: 0 auto;
padding: 0px 20px;
}
}
}
&__right {
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;
}
}
.the-content {
position: absolute;
top: 0;
left: 0;
right: 1px;
bottom: 0px;
}
}
}
@@ -121,11 +106,17 @@
.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>
<AppDataProvider>
<BrowserRouter>
<Layout />
</BrowserRouter>
</AppDataProvider>
<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]);
@@ -389,7 +413,7 @@ const Layout = () => {
console.log("[Layout] 开始监听启动完成事件");
} catch (err) {
console.error("[Layout] 监听启动完成事件失败:", err);
return () => {};
return () => { };
}
};
@@ -420,7 +444,7 @@ const Layout = () => {
if (!isInitialized) {
console.error("[Layout] 紧急初始化触发5秒内未完成初始化");
removeLoadingOverlay();
notifyBackend("UI就绪").catch(() => {});
notifyBackend("UI就绪").catch(() => { });
isInitialized = true;
}
}, 5000);
@@ -495,6 +519,7 @@ const Layout = () => {
}}
>
<ThemeProvider theme={theme}>
{/* 左侧底部窗口控制按钮 */}
<NoticeManager />
<div
style={{
@@ -534,62 +559,66 @@ const Layout = () => {
({ palette }) => ({ bgcolor: palette.background.paper }),
OS === "linux"
? {
borderRadius: "8px",
border: "1px solid var(--divider-color)",
width: "100vw",
height: "100vh",
}
borderRadius: "8px",
border: "1px solid var(--divider-color)",
width: "100vw",
height: "100vh",
}
: {},
]}
>
<div className="layout__left">
<div className="the-logo" data-tauri-drag-region="true">
<div
data-tauri-drag-region="true"
style={{
height: "27px",
display: "flex",
justifyContent: "space-between",
}}
>
<SvgIcon
component={isDark ? iconDark : iconLight}
{/* 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={{
height: "36px",
width: "36px",
marginTop: "-3px",
marginRight: "5px",
marginLeft: "-3px",
height: "27px",
display: "flex",
justifyContent: "space-between",
}}
inheritViewBox
/>
<LogoSvg fill={isDark ? "white" : "black"} />
</div>
<UpdateButton className="the-newbtn" />
</div>
<List className="the-menu">
{routers.map((router) => (
<LayoutItem
key={router.label}
to={router.path}
icon={router.icon}
>
{t(router.label)}
</LayoutItem>
))}
</List>
<SvgIcon
component={isDark ? iconDark : iconLight}
style={{
height: "36px",
width: "36px",
marginTop: "-3px",
marginRight: "5px",
marginLeft: "-3px",
}}
inheritViewBox
/>
<LogoSvg fill={isDark ? "white" : "black"} />
</div>
<UpdateButton className="the-newbtn" />
</div>
<div className="the-traffic">
<LayoutTraffic />
<List className="the-menu">
{routers.map((router) => (
<LayoutItem
key={router.label}
to={router.path}
icon={router.icon}
>
{t(router.label)}
</LayoutItem>
))}
</List>
<div className="the-traffic">
<LayoutTraffic />
</div>
</div>
</div>
<div className="layout__right">
<div className="the-bar"></div>
<div className="the-content">
{React.cloneElement(routersEles, { key: location.pathname })}
<div className="layout-content__right">
<div className="the-bar"></div>
<div className="the-content">
{React.cloneElement(routersEles, { key: location.pathname })}
</div>
</div>
</div>
</Paper>