fix(electron): theme Windows titlebar overlay (#16843)

Co-authored-by: Brendan Allan <brendonovich@outlook.com>
This commit is contained in:
Luke Parker 2026-03-12 16:38:56 +10:00 committed by GitHub
parent 54e7baa6cf
commit d481f64bde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 56 additions and 20 deletions

View File

@ -62,6 +62,9 @@ declare global {
deepLinks?: string[] deepLinks?: string[]
wsl?: boolean wsl?: boolean
} }
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
}
} }
} }
@ -115,7 +118,11 @@ export function AppBaseProviders(props: ParentProps) {
return ( return (
<MetaProvider> <MetaProvider>
<Font /> <Font />
<ThemeProvider> <ThemeProvider
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
}}
>
<LanguageProvider> <LanguageProvider>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>

View File

@ -1,4 +1,4 @@
import { createEffect, createMemo, Show, untrack } from "solid-js" import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@ -282,7 +282,7 @@ export function Titlebar() {
> >
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" /> <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}> <Show when={windows()}>
<div class="w-6 shrink-0" /> {!tauriApi() && <div class="w-36 shrink-0" />}
<div data-tauri-decorum-tb class="flex flex-row" /> <div data-tauri-decorum-tb class="flex flex-row" />
</Show> </Show>
</div> </div>

View File

@ -2,8 +2,9 @@ import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron" import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types"
import { getStore } from "./store" import { getStore } from "./store"
import { setTitlebar } from "./windows"
type Deps = { type Deps = {
killSidecar: () => void killSidecar: () => void
@ -161,6 +162,11 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor)) ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor))
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
setTitlebar(win, theme)
})
} }
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) { export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {

View File

@ -1,7 +1,8 @@
import windowState from "electron-window-state" import windowState from "electron-window-state"
import { app, BrowserWindow, nativeImage } from "electron" import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
import { dirname, join } from "node:path" import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import type { TitlebarTheme } from "../preload/types"
type Globals = { type Globals = {
updaterEnabled: boolean updaterEnabled: boolean
@ -20,6 +21,24 @@ function iconPath() {
return join(iconsDir(), `icon.${ext}`) return join(iconsDir(), `icon.${ext}`)
} }
function tone() {
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
}
function overlay(theme: Partial<TitlebarTheme> = {}) {
const mode = theme.mode ?? tone()
return {
color: "#00000000",
symbolColor: mode === "dark" ? "white" : "black",
height: 40,
}
}
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
if (process.platform !== "win32") return
win.setTitleBarOverlay(overlay(theme))
}
export function setDockIcon() { export function setDockIcon() {
if (process.platform !== "darwin") return if (process.platform !== "darwin") return
app.dock?.setIcon(nativeImage.createFromPath(join(iconsDir(), "128x128@2x.png"))) app.dock?.setIcon(nativeImage.createFromPath(join(iconsDir(), "128x128@2x.png")))
@ -31,6 +50,7 @@ export function createMainWindow(globals: Globals) {
defaultHeight: 800, defaultHeight: 800,
}) })
const mode = tone()
const win = new BrowserWindow({ const win = new BrowserWindow({
x: state.x, x: state.x,
y: state.y, y: state.y,
@ -49,11 +69,7 @@ export function createMainWindow(globals: Globals) {
? { ? {
frame: false, frame: false,
titleBarStyle: "hidden" as const, titleBarStyle: "hidden" as const,
titleBarOverlay: { titleBarOverlay: overlay({ mode }),
color: "transparent",
symbolColor: "#999",
height: 40,
},
} }
: {}), : {}),
webPreferences: { webPreferences: {
@ -71,6 +87,7 @@ export function createMainWindow(globals: Globals) {
} }
export function createLoadingWindow(globals: Globals) { export function createLoadingWindow(globals: Globals) {
const mode = tone()
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 640, width: 640,
height: 480, height: 480,
@ -83,11 +100,7 @@ export function createLoadingWindow(globals: Globals) {
? { ? {
frame: false, frame: false,
titleBarStyle: "hidden" as const, titleBarStyle: "hidden" as const,
titleBarOverlay: { titleBarOverlay: overlay({ mode }),
color: "transparent",
symbolColor: "#999",
height: 40,
},
} }
: {}), : {}),
webPreferences: { webPreferences: {

View File

@ -57,6 +57,7 @@ const api: ElectronAPI = {
relaunch: () => ipcRenderer.send("relaunch"), relaunch: () => ipcRenderer.send("relaunch"),
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"), getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor), setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"), loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail), runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"), checkUpdate: () => ipcRenderer.invoke("check-update"),

View File

@ -10,6 +10,9 @@ export type SqliteMigrationProgress = { type: "InProgress"; value: number } | {
export type WslConfig = { enabled: boolean } export type WslConfig = { enabled: boolean }
export type LinuxDisplayBackend = "wayland" | "auto" export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
mode: "light" | "dark"
}
export type ElectronAPI = { export type ElectronAPI = {
killSidecar: () => Promise<void> killSidecar: () => Promise<void>
@ -57,6 +60,7 @@ export type ElectronAPI = {
relaunch: () => void relaunch: () => void
getZoomFactor: () => Promise<number> getZoomFactor: () => Promise<number>
setZoomFactor: (factor: number) => Promise<void> setZoomFactor: (factor: number) => Promise<void>
setTitlebar: (theme: TitlebarTheme) => Promise<void>
loadingWindowComplete: () => void loadingWindowComplete: () => void
runUpdater: (alertOnFail: boolean) => Promise<void> runUpdater: (alertOnFail: boolean) => Promise<void>
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>

View File

@ -77,7 +77,7 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme", name: "Theme",
init: (props: { defaultTheme?: string }) => { init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
themes: DEFAULT_THEMES as Record<string, DesktopTheme>, themes: DEFAULT_THEMES as Record<string, DesktopTheme>,
themeId: normalize(props.defaultTheme) ?? "oc-2", themeId: normalize(props.defaultTheme) ?? "oc-2",
@ -119,10 +119,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
} }
}) })
const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => {
applyThemeCss(theme, themeId, mode)
props.onThemeApplied?.(theme, mode)
}
createEffect(() => { createEffect(() => {
const theme = store.themes[store.themeId] const theme = store.themes[store.themeId]
if (theme) { if (theme) {
applyThemeCss(theme, store.themeId, store.mode) applyTheme(theme, store.themeId, store.mode)
} }
}) })
@ -171,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
? getSystemMode() ? getSystemMode()
: store.previewScheme : store.previewScheme
: store.mode : store.mode
applyThemeCss(theme, next, previewMode) applyTheme(theme, next, previewMode)
}, },
previewColorScheme: (scheme: ColorScheme) => { previewColorScheme: (scheme: ColorScheme) => {
setStore("previewScheme", scheme) setStore("previewScheme", scheme)
@ -179,7 +184,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const id = store.previewThemeId ?? store.themeId const id = store.previewThemeId ?? store.themeId
const theme = store.themes[id] const theme = store.themes[id]
if (theme) { if (theme) {
applyThemeCss(theme, id, previewMode) applyTheme(theme, id, previewMode)
} }
}, },
commitPreview: () => { commitPreview: () => {
@ -197,7 +202,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("previewScheme", null) setStore("previewScheme", null)
const theme = store.themes[store.themeId] const theme = store.themes[store.themeId]
if (theme) { if (theme) {
applyThemeCss(theme, store.themeId, store.mode) applyTheme(theme, store.themeId, store.mode)
} }
}, },
} }