From 8f785ee0a8254222926dc43e8914781f6508470c Mon Sep 17 00:00:00 2001 From: CEQ151 Date: Thu, 4 Jun 2026 17:19:17 +0800 Subject: [PATCH] fix(desktop): smooth Windows title bar overlay --- apps/desktop/main/index.ts | 24 +-- apps/desktop/main/ipc/window.ts | 16 +- apps/desktop/main/window-titlebar.test.ts | 31 ++++ apps/desktop/main/window-titlebar.ts | 58 ++++++ apps/desktop/preload/apis/core.ts | 3 + apps/desktop/preload/index.d.ts | 1 + src/App.vue | 24 +++ .../common/Settings/BasicSettingsTab.vue | 12 -- src/components/common/SettingsModal.vue | 8 +- src/composables/useWindowsTitleBarOverlay.ts | 165 ++++++++++++++++++ 10 files changed, 304 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/main/window-titlebar.test.ts create mode 100644 apps/desktop/main/window-titlebar.ts create mode 100644 src/composables/useWindowsTitleBarOverlay.ts diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index 257f7e25..e55b9d66 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -22,6 +22,7 @@ import { import { migrateAllDatabases, checkMigrationNeeded } from './database/core' import { initLocale } from './i18n' import { MigrationRunner, ALL_MIGRATIONS } from '@openchatlab/config' +import { applyCurrentTitleBarOverlay, getTitleBarOverlayOptions } from './window-titlebar' type AppWithQuitFlag = typeof app & { isQuiting?: boolean } // 统一通过扩展类型访问退出标记,避免使用 @ts-ignore。 @@ -223,13 +224,8 @@ class MainProcess { windowOptions.titleBarStyle = 'hidden' // 获取当前主题状态 const isDark = nativeTheme.shouldUseDarkColors - windowOptions.titleBarOverlay = { - // 背景色与应用背景保持一致 - color: isDark ? '#111827' : '#ffffff', // dark: gray-900, light: white - // 图标颜色适配主题 - symbolColor: isDark ? '#a1a1aa' : '#52525b', // dark: zinc-400, light: zinc-600 - height: 32, - } + windowOptions.titleBarOverlay = getTitleBarOverlayOptions(isDark) + windowOptions.backgroundColor = isDark ? '#111827' : '#f9fafb' } else { // Linux 继续使用无边框 + 自定义按钮 windowOptions.frame = false @@ -242,22 +238,12 @@ class MainProcess { // Windows 上根据当前主题设置 titleBarOverlay 颜色 if (platform.isWindows) { - const isDark = nativeTheme.shouldUseDarkColors - this.mainWindow?.setTitleBarOverlay({ - color: isDark ? '#111827' : '#ffffff', // dark: gray-900, light: white - symbolColor: isDark ? '#a1a1aa' : '#52525b', // dark: zinc-400, light: zinc-600 - height: 32, - }) + applyCurrentTitleBarOverlay(this.mainWindow, nativeTheme.shouldUseDarkColors) // 监听主题变化,动态更新颜色 nativeTheme.on('updated', () => { if (this.mainWindow && platform.isWindows) { - const isDark = nativeTheme.shouldUseDarkColors - this.mainWindow.setTitleBarOverlay({ - color: isDark ? '#111827' : '#ffffff', // dark: gray-900, light: white - symbolColor: isDark ? '#a1a1aa' : '#52525b', // dark: zinc-400, light: zinc-600 - height: 32, - }) + applyCurrentTitleBarOverlay(this.mainWindow, nativeTheme.shouldUseDarkColors) } }) } diff --git a/apps/desktop/main/ipc/window.ts b/apps/desktop/main/ipc/window.ts index 8b6363d3..9ed2e4c4 100644 --- a/apps/desktop/main/ipc/window.ts +++ b/apps/desktop/main/ipc/window.ts @@ -7,6 +7,7 @@ import * as fs from 'fs/promises' import type { IpcContext } from './types' import { simulateUpdateDialog, manualCheckForUpdates } from '../update' import { t } from '../i18n' +import { applyCurrentTitleBarOverlay, applyTitleBarOverlayColor } from '../window-titlebar' type AppWithQuitFlag = typeof app & { isQuiting?: boolean } // 通过类型扩展记录应用退出意图,避免使用 @ts-ignore。 @@ -87,12 +88,15 @@ export function registerWindowHandlers(ctx: IpcContext): void { // Windows 上动态更新 overlay 颜色以匹配主题 if (process.platform === 'win32' && win) { - const isDark = nativeTheme.shouldUseDarkColors - win.setTitleBarOverlay({ - color: isDark ? '#111827' : '#f9fafb', // dark: gray-900, light: gray-50 - symbolColor: isDark ? '#a1a1aa' : '#52525b', // dark: zinc-400, light: zinc-600 - height: 32, - }) + applyCurrentTitleBarOverlay(win, nativeTheme.shouldUseDarkColors) + } + }) + + ipcMain.on('window:setTitleBarOverlayColor', (_, color: string) => { + if (!/^#[0-9a-f]{6}$/i.test(color)) return + + if (process.platform === 'win32' && win) { + applyTitleBarOverlayColor(win, color) } }) diff --git a/apps/desktop/main/window-titlebar.test.ts b/apps/desktop/main/window-titlebar.test.ts new file mode 100644 index 00000000..4f873fab --- /dev/null +++ b/apps/desktop/main/window-titlebar.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { getTitleBarOverlayOptionsForColor, getTitleBarOverlayOptions } from './window-titlebar' + +describe('Windows title bar overlay options', () => { + it('keeps the native overlay background transparent in normal mode', () => { + assert.deepEqual(getTitleBarOverlayOptions(false), { + color: 'rgba(0, 0, 0, 0)', + symbolColor: '#52525b', + height: 32, + }) + assert.deepEqual(getTitleBarOverlayOptions(true), { + color: 'rgba(0, 0, 0, 0)', + symbolColor: '#d4d4d8', + height: 32, + }) + }) + + it('uses readable symbols for sampled custom colors', () => { + assert.deepEqual(getTitleBarOverlayOptionsForColor('#ffffff'), { + color: 'rgba(0, 0, 0, 0)', + symbolColor: '#3f3f46', + height: 32, + }) + assert.deepEqual(getTitleBarOverlayOptionsForColor('#111827'), { + color: 'rgba(0, 0, 0, 0)', + symbolColor: '#e4e4e7', + height: 32, + }) + }) +}) diff --git a/apps/desktop/main/window-titlebar.ts b/apps/desktop/main/window-titlebar.ts new file mode 100644 index 00000000..1c499505 --- /dev/null +++ b/apps/desktop/main/window-titlebar.ts @@ -0,0 +1,58 @@ +import type { BrowserWindow, TitleBarOverlayOptions } from 'electron' + +const TITLE_BAR_OVERLAY_HEIGHT = 32 +const TITLE_BAR_OVERLAY_COLOR = 'rgba(0, 0, 0, 0)' +let currentTitleBarOverlayColor: string | null = null + +const TITLE_BAR_OVERLAY_PALETTE = { + light: { symbolColor: '#52525b' }, + dark: { symbolColor: '#d4d4d8' }, +} as const + +export function getTitleBarOverlayOptions(isDark: boolean): TitleBarOverlayOptions { + const mode = isDark ? 'dark' : 'light' + return { + color: TITLE_BAR_OVERLAY_COLOR, + symbolColor: TITLE_BAR_OVERLAY_PALETTE[mode].symbolColor, + height: TITLE_BAR_OVERLAY_HEIGHT, + } +} + +export function getTitleBarOverlayOptionsForColor(color: string): TitleBarOverlayOptions { + return { + color: TITLE_BAR_OVERLAY_COLOR, + symbolColor: getReadableSymbolColor(color), + height: TITLE_BAR_OVERLAY_HEIGHT, + } +} + +export function applyCurrentTitleBarOverlay(win: BrowserWindow | null | undefined, isDark: boolean): void { + if (currentTitleBarOverlayColor) { + win?.setTitleBarOverlay(getTitleBarOverlayOptionsForColor(currentTitleBarOverlayColor)) + return + } + + win?.setTitleBarOverlay(getTitleBarOverlayOptions(isDark)) +} + +export function applyTitleBarOverlayColor(win: BrowserWindow | null | undefined, color: string): void { + currentTitleBarOverlayColor = color + win?.setTitleBarOverlay(getTitleBarOverlayOptionsForColor(color)) +} + +function getReadableSymbolColor(hexColor: string): string { + const match = /^#([0-9a-f]{6})$/i.exec(hexColor) + if (!match) return '#52525b' + + const value = match[1] + const r = Number.parseInt(value.slice(0, 2), 16) / 255 + const g = Number.parseInt(value.slice(2, 4), 16) / 255 + const b = Number.parseInt(value.slice(4, 6), 16) / 255 + const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b) + + return luminance < 0.45 ? '#e4e4e7' : '#3f3f46' +} + +function toLinear(value: number): number { + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 +} diff --git a/apps/desktop/preload/apis/core.ts b/apps/desktop/preload/apis/core.ts index 82a3e282..2d547c6d 100644 --- a/apps/desktop/preload/apis/core.ts +++ b/apps/desktop/preload/apis/core.ts @@ -33,6 +33,9 @@ export const api = { setThemeSource: (mode: 'system' | 'light' | 'dark') => { ipcRenderer.send('window:setThemeSource', mode) }, + setTitleBarOverlayColor: (color: string) => { + ipcRenderer.send('window:setTitleBarOverlayColor', color) + }, } // 扩展 api,添加 dialog、clipboard 和应用功能 diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index 02ae557e..4c6e7e2a 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -90,6 +90,7 @@ interface Api { receive: (channel: string, func: (...args: unknown[]) => void) => void removeListener: (channel: string, func: (...args: unknown[]) => void) => void setThemeSource: (mode: 'system' | 'light' | 'dark') => void + setTitleBarOverlayColor: (color: string) => void dialog: { showOpenDialog: (options: Electron.OpenDialogOptions) => Promise } diff --git a/src/App.vue b/src/App.vue index 34d1d5af..d7491edb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,6 @@