mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(desktop): smooth Windows title bar overlay
This commit is contained in:
1 parent
0bd4d31dd2
commit
8f785ee0a8
10 files changed
+304
-38
No files matched your search
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 和应用功能
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
Reference in new issue
Block a user