fix(desktop): smooth Windows title bar overlay

This commit is contained in:
CEQ151 authored and digua committed 2026-06-05 11:12:42 +08:00
1 parent 0bd4d31dd2
commit 8f785ee0a8
10 files changed
+304 -38

No files matched your search

+5 -19
View File
@@ -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)
}
})
}
+10 -6
View File
@@ -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)
}
})
+31
View File
@@ -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,
})
})
})
+58
View File
@@ -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
}
+3
View File
@@ -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 和应用功能
+1
View File
@@ -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<Electron.OpenDialogReturnValue>
}
+24
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useColorMode } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -16,6 +17,7 @@ import { useLLMStore } from '@/stores/llm'
import { useAuthStore } from '@/stores/auth'
import { initServices } from '@/services'
import { initPreferencesSync } from '@/composables/usePreferencesSync'
import { useWindowsTitleBarOverlay } from '@/composables/useWindowsTitleBarOverlay'
import { configureHttpClient } from '@/services/utils/http'
import { IS_ELECTRON } from '@/utils/platform'
@@ -32,6 +34,10 @@ const router = useRouter()
const isLoginPage = computed(() => !IS_ELECTRON && route.name === 'login')
const initError = ref<string | null>(null)
const colorMode = useColorMode({
emitAuto: true,
initialValue: 'light',
})
const tooltip = {
delayDuration: 100,
@@ -79,6 +85,24 @@ watch(isLoginPage, (isLogin) => {
if (!isLogin) initializeApp()
})
watch(
colorMode,
(val) => {
if (!IS_ELECTRON) return
const mode = val === 'auto' ? 'system' : (val as 'light' | 'dark')
window.api?.setThemeSource(mode)
},
{ immediate: true }
)
useWindowsTitleBarOverlay([
colorMode,
() => route.fullPath,
() => layoutStore.showSettings,
() => layoutStore.showScreenCaptureModal,
() => layoutStore.showChatRecordDrawer,
])
onMounted(async () => {
window.addEventListener('keydown', handleGlobalKeydown)
const platform = navigator.platform.toLowerCase()
@@ -83,18 +83,6 @@ const toolsPanelPositionOptions = computed(() => [
{ label: t('settings.basic.toolsPanel.positionHeader'), value: 'header' },
{ label: t('settings.basic.toolsPanel.positionSide'), value: 'side' },
])
// Sync theme with main process (Electron only)
import { watch } from 'vue'
watch(
colorMode,
(val) => {
if (!IS_ELECTRON) return
const mode = val === 'auto' ? 'system' : (val as 'light' | 'dark')
usePlatformService().setThemeSource(mode)
},
{ immediate: true }
)
</script>
<template>
+7 -1
View File
@@ -71,7 +71,13 @@ watch(showSettings, async (visible) => {
</script>
<template>
<UModal v-model:open="showSettings" :ui="{ content: 'sm:max-w-[900px] z-[100]', overlay: 'backdrop-blur-sm z-[99]' }">
<UModal
v-model:open="showSettings"
:ui="{
content: 'sm:max-w-[900px] z-[100]',
overlay: 'z-[99] bg-gray-200/80 backdrop-blur-sm dark:bg-gray-950/80',
}"
>
<template #content>
<div class="flex min-h-[650px] h-[85vh] flex-col overflow-hidden">
<!-- Header -->
@@ -0,0 +1,165 @@
import { nextTick, onMounted, onUnmounted, watch, type WatchSource } from 'vue'
import { IS_ELECTRON } from '@/utils/platform'
interface RgbaColor {
r: number
g: number
b: number
a: number
}
const TITLE_BAR_SAMPLE_Y = 16
const TITLE_BAR_SAMPLE_RIGHT_OFFSET = 80
const TRANSITION_SAMPLE_MS = 420
export function useWindowsTitleBarOverlay(triggers: WatchSource<unknown>[]): void {
if (!IS_ELECTRON || typeof navigator === 'undefined' || !navigator.platform.toLowerCase().includes('win')) return
let frameId = 0
let lastColor = ''
const syncTitleBarColor = () => {
const color = sampleTitleBarBackground()
if (!color || color === lastColor) return
lastColor = color
window.api?.setTitleBarOverlayColor(color)
}
const scheduleSync = (durationMs = 0) => {
if (frameId) {
cancelAnimationFrame(frameId)
}
const startedAt = performance.now()
const tick = () => {
syncTitleBarColor()
if (performance.now() - startedAt < durationMs) {
frameId = requestAnimationFrame(tick)
} else {
frameId = 0
}
}
nextTick(() => {
frameId = requestAnimationFrame(tick)
})
}
const handleResize = () => scheduleSync()
onMounted(() => {
window.addEventListener('resize', handleResize)
scheduleSync(TRANSITION_SAMPLE_MS)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (frameId) {
cancelAnimationFrame(frameId)
}
})
for (const trigger of triggers) {
watch(trigger, () => scheduleSync(TRANSITION_SAMPLE_MS), { flush: 'post' })
}
}
// 采样 Windows 标题栏按钮区域下方的真实背景色,并用它推导原生按钮符号颜色。
// 这里需要沿 elementsFromPoint 返回的层级合成透明背景,才能覆盖设置弹窗淡入淡出时的 blur/backdrop 场景。
function sampleTitleBarBackground(): string | null {
const x = Math.max(0, window.innerWidth - TITLE_BAR_SAMPLE_RIGHT_OFFSET)
const y = TITLE_BAR_SAMPLE_Y
const elements = document.elementsFromPoint(x, y)
if (elements.length === 0) return null
let color = getPageFallbackColor()
for (const element of [...elements].reverse()) {
const style = window.getComputedStyle(element)
const background = parseCssColor(style.backgroundColor)
if (!background || background.a <= 0) continue
const opacity = Number.parseFloat(style.opacity)
const effectiveAlpha = background.a * (Number.isFinite(opacity) ? opacity : 1)
color = composite({ ...background, a: effectiveAlpha }, color)
}
return rgbaToHex(color)
}
function getPageFallbackColor(): RgbaColor {
const rootColor = parseCssColor(window.getComputedStyle(document.documentElement).backgroundColor)
if (rootColor && rootColor.a > 0) return rootColor
const bodyColor = parseCssColor(window.getComputedStyle(document.body).backgroundColor)
if (bodyColor && bodyColor.a > 0) return bodyColor
return document.documentElement.classList.contains('dark')
? { r: 17, g: 24, b: 39, a: 1 }
: { r: 255, g: 255, b: 255, a: 1 }
}
function parseCssColor(raw: string): RgbaColor | null {
if (!raw || raw === 'transparent') return null
const rgbMatch = /^rgba?\(([^)]+)\)$/.exec(raw)
if (!rgbMatch) return null
const colorBody = rgbMatch[1].trim()
const parts = colorBody.includes(',')
? colorBody.split(',').map((part) => part.trim())
: colorBody.replace('/', ' ').split(/\s+/).filter(Boolean)
if (parts.length < 3) return null
const r = parseColorChannel(parts[0])
const g = parseColorChannel(parts[1])
const b = parseColorChannel(parts[2])
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3])
if (![r, g, b, a].every(Number.isFinite)) return null
return {
r: clampColor(r),
g: clampColor(g),
b: clampColor(b),
a: Math.min(1, Math.max(0, a)),
}
}
function parseColorChannel(raw: string): number {
if (raw.endsWith('%')) {
return (Number.parseFloat(raw) / 100) * 255
}
return Number.parseFloat(raw)
}
function parseAlphaChannel(raw: string): number {
if (raw.endsWith('%')) {
return Number.parseFloat(raw) / 100
}
return Number.parseFloat(raw)
}
function composite(top: RgbaColor, bottom: RgbaColor): RgbaColor {
const alpha = top.a + bottom.a * (1 - top.a)
if (alpha <= 0) return { r: 0, g: 0, b: 0, a: 0 }
return {
r: (top.r * top.a + bottom.r * bottom.a * (1 - top.a)) / alpha,
g: (top.g * top.a + bottom.g * bottom.a * (1 - top.a)) / alpha,
b: (top.b * top.a + bottom.b * bottom.a * (1 - top.a)) / alpha,
a: alpha,
}
}
function rgbaToHex(color: RgbaColor): string {
const r = clampColor(color.r).toString(16).padStart(2, '0')
const g = clampColor(color.g).toString(16).padStart(2, '0')
const b = clampColor(color.b).toString(16).padStart(2, '0')
return `#${r}${g}${b}`
}
function clampColor(value: number): number {
return Math.min(255, Math.max(0, Math.round(value)))
}