From bfd1274a8c2cd919d3a3ffa5cc4232751fe83f1e Mon Sep 17 00:00:00 2001 From: Tunglies Date: Wed, 8 Oct 2025 20:23:26 +0800 Subject: [PATCH] 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. --- UPDATELOG.md | 1 + src-tauri/src/utils/resolve/window.rs | 3 +- src/assets/styles/layout.scss | 205 +++++++++--------- .../controller/window-controller.tsx | 114 ++++++++++ src/components/setting/mods/layout-viewer.tsx | 29 ++- .../setting/setting-verge-advanced.tsx | 6 +- .../setting/setting-verge-basic.tsx | 4 +- src/hooks/use-window.tsx | 114 ++++++++++ src/main.tsx | 13 +- src/pages/_layout.tsx | 129 ++++++----- 10 files changed, 449 insertions(+), 169 deletions(-) create mode 100644 src/components/controller/window-controller.tsx create mode 100644 src/hooks/use-window.tsx diff --git a/UPDATELOG.md b/UPDATELOG.md index 8667746a..32efb0ca 100644 --- a/UPDATELOG.md +++ b/UPDATELOG.md @@ -6,6 +6,7 @@ - Linux 打包为 `.deb` `.rpm` 提供 pkexec 依赖项 - 支持前端修改日志(最大文件大小、最大保留数量) - 新增链式代理图形化设置功能 +- 新增系统标题栏与程序标题栏切换 (设置-页面设置-倾向系统标题栏) - 监听关机事件,自动关闭系统代理 ### 🚀 优化改进 diff --git a/src-tauri/src/utils/resolve/window.rs b/src-tauri/src/utils/resolve/window.rs index 31036f43..1041723c 100644 --- a/src-tauri/src/utils/resolve/window.rs +++ b/src-tauri/src/utils/resolve/window.rs @@ -27,7 +27,8 @@ pub fn build_new_window() -> Result { ) .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) diff --git a/src/assets/styles/layout.scss b/src/assets/styles/layout.scss index 59426ee3..d328a30d 100644 --- a/src/assets/styles/layout.scss +++ b/src/assets/styles/layout.scss @@ -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; } diff --git a/src/components/controller/window-controller.tsx b/src/components/controller/window-controller.tsx new file mode 100644 index 00000000..5e0fb638 --- /dev/null +++ b/src/components/controller/window-controller.tsx @@ -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 ( +
+ {OS === "macos" && ( + <> + {/* macOS 风格:关闭 → 最小化 → 全屏 */} + + + + + + + + {maximized ? ( + + ) : ( + + )} + + + )} + + {OS === "windows" && ( + <> + {/* Windows 风格:最小化 → 最大化 → 关闭 */} + + + + + {maximized ? ( + + ) : ( + + )} + + + + + + )} + + {OS === "linux" && ( + <> + {/* Linux 桌面常见布局(GNOME/KDE 多为:最小化 → 最大化 → 关闭) */} + + + + + {maximized ? ( + + ) : ( + + )} + + + + + + )} +
+ ); +}); diff --git a/src/components/setting/mods/layout-viewer.tsx b/src/components/setting/mods/layout-viewer.tsx index 5e0f1e25..ad28ae3d 100644 --- a/src/components/setting/mods/layout-viewer.tsx +++ b/src/components/setting/mods/layout-viewer.tsx @@ -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((props, ref) => { const [sysproxyIcon, setSysproxyIcon] = useState(""); const [tunIcon, setTunIcon] = useState(""); + const { decorated, toggleDecorations } = useWindowDecorations(); + useEffect(() => { initIconPath(); }, []); @@ -108,6 +110,21 @@ export const LayoutViewer = forwardRef((props, ref) => { onCancel={() => setOpen(false)} > + + + { + await toggleDecorations(); + }} + > + + + + Promise; + refreshDecorated: () => Promise; + minimize: () => void; + close: () => void; + toggleMaximize: () => Promise; + toggleFullscreen: () => Promise; + currentWindow: ReturnType; +} + +const WindowContext = createContext(undefined); + +export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const currentWindow = getCurrentWindow(); + const [decorated, setDecorated] = useState(null); + const [maximized, setMaximized] = useState(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 ( + + {children} + + ); +}; + +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 }; +}; diff --git a/src/main.tsx b/src/main.tsx index 91433f98..7d0c9b25 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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 () => { - - - - - + + + + + + + , diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 17985639..fe1a98df 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -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(null); + const { decorated } = useWindowDecorations(); + + const customTitlebar = useMemo(() => { + console.debug( + "[Layout] Titlebar rendering - decorated:", + decorated, + "| showing:", + !decorated, + ); + if (!decorated) { + return ( +
+ +
+ ); + } + 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 = () => { }} > + {/* 左侧底部窗口控制按钮 */}
{ ({ 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", + } : {}, ]} > -
-
-
- +
+
+
- -
- -
- - - {routers.map((router) => ( - - {t(router.label)} - - ))} - + + +
+ +
-
- + + {routers.map((router) => ( + + {t(router.label)} + + ))} + + +
+ +
-
-
-
- -
- {React.cloneElement(routersEles, { key: location.pathname })} +
+
+
+ {React.cloneElement(routersEles, { key: location.pathname })} +