mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(tui): simplify theme palette lifecycle
This commit is contained in:
@@ -482,7 +482,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const toast = useToast()
|
||||
const updater = useUpdateNotification()
|
||||
const theme = useTheme()
|
||||
const { mode, supports, setMode, locked, lock, unlock, prepareSystem } = useThemes()
|
||||
const { mode, supports, setMode, locked, lock, unlock, afterPaint } = useThemes()
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const exit = useExit()
|
||||
@@ -490,15 +490,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const plugins = usePlugin()
|
||||
const clipboard = useClipboard()
|
||||
const terminalEnvironment = useTuiTerminalEnvironment()
|
||||
let systemThemeTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
const prepareSystemTheme = () => {
|
||||
let paletteTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const afterFrame = () => {
|
||||
// The native writer can still be flushing the frame when FRAME fires. Keep OSC probes behind visible app output.
|
||||
systemThemeTimeout = setTimeout(prepareSystem, 50)
|
||||
paletteTimer = setTimeout(afterPaint, 50)
|
||||
}
|
||||
onMount(() => renderer.once(CliRenderEvents.FRAME, prepareSystemTheme))
|
||||
onMount(() => renderer.once(CliRenderEvents.FRAME, afterFrame))
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.FRAME, prepareSystemTheme)
|
||||
if (systemThemeTimeout) clearTimeout(systemThemeTimeout)
|
||||
renderer.off(CliRenderEvents.FRAME, afterFrame)
|
||||
if (paletteTimer) clearTimeout(paletteTimer)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (client.connection.status() !== "connected") return
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useConfig } from "../config"
|
||||
@@ -95,7 +95,6 @@ export {
|
||||
const THEME_REFRESH_DELAYS = [250, 1000] as const
|
||||
|
||||
type State = {
|
||||
themes: Record<string, ThemeDocumentSource>
|
||||
mode: "dark" | "light"
|
||||
lock: "dark" | "light" | undefined
|
||||
active: string
|
||||
@@ -117,7 +116,7 @@ type Themes = {
|
||||
unlock(): void
|
||||
setMode(mode?: "dark" | "light", persist?: boolean): boolean
|
||||
set(theme: string): boolean
|
||||
prepareSystem(): void
|
||||
afterPaint(): void
|
||||
onError(handler: ThemeErrorHandler): () => void
|
||||
readonly ready: boolean
|
||||
}
|
||||
@@ -129,14 +128,14 @@ type ThemeContextValue = {
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<State>({
|
||||
themes: allThemes(),
|
||||
mode: "dark",
|
||||
lock: undefined,
|
||||
active: "opencode",
|
||||
ready: false,
|
||||
})
|
||||
const [themeSources, setThemeSources] = createSignal(allThemes())
|
||||
|
||||
subscribeThemes((themes) => setStore("themes", themes))
|
||||
subscribeThemes(setThemeSources)
|
||||
|
||||
const themeContext = createSimpleContext({
|
||||
name: "Theme",
|
||||
@@ -145,20 +144,15 @@ const themeContext = createSimpleContext({
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
const themes = props.source
|
||||
const systemPalette = useStorageOptional()?.store<{ colors?: TerminalColors }>("system-theme", { initial: {} })
|
||||
const pick = (value: unknown) => {
|
||||
if (value === "dark" || value === "light") return value
|
||||
return
|
||||
}
|
||||
const cache = useStorageOptional()?.store<{ colors?: TerminalColors }>("system-theme", { initial: {} })
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const lock = pick(config.theme?.mode)
|
||||
const mode = lock ?? pick(renderer.themeMode) ?? props.mode
|
||||
const lock = config.theme?.mode === "dark" || config.theme?.mode === "light" ? config.theme.mode : undefined
|
||||
const mode = lock ?? renderer.themeMode ?? props.mode
|
||||
draft.mode = mode
|
||||
draft.lock = lock
|
||||
const active = config.theme?.name ?? "opencode"
|
||||
draft.active = typeof active === "string" ? active : "opencode"
|
||||
draft.active = config.theme?.name ?? "opencode"
|
||||
draft.ready = false
|
||||
}),
|
||||
)
|
||||
@@ -187,78 +181,68 @@ const themeContext = createSimpleContext({
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Terminal palette queries serialize with frame output. Use the mode fallback for the first paint;
|
||||
// App asks for the system palette after that frame and updates the theme in place.
|
||||
// Terminal palette queries serialize with frame output. First paint uses the cached palette or built-in fallback.
|
||||
void syncCustomThemes().finally(() => {
|
||||
valuesV2()
|
||||
tokens()
|
||||
setStore("ready", true)
|
||||
})
|
||||
})
|
||||
|
||||
const cachedSystemPalette = systemPalette?.[0].colors
|
||||
let systemThemeSignature = cachedSystemPalette ? JSON.stringify(cachedSystemPalette) : undefined
|
||||
let systemThemeMode = cachedSystemPalette
|
||||
? (store.lock ?? terminalMode(cachedSystemPalette) ?? store.mode)
|
||||
: undefined
|
||||
let hasResolvedSystemTheme = cachedSystemPalette !== undefined
|
||||
if (cachedSystemPalette && systemThemeMode) {
|
||||
if (store.mode !== systemThemeMode) setStore("mode", systemThemeMode)
|
||||
setSystemTheme(generateSystem(cachedSystemPalette, systemThemeMode))
|
||||
const cachedPalette = cache?.[0].colors
|
||||
let palette = usablePalette(cachedPalette) ? cachedPalette : undefined
|
||||
const applyPalette = () => {
|
||||
if (palette) setSystemTheme(generateSystem(palette, store.mode))
|
||||
}
|
||||
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||
return renderer
|
||||
if (palette) {
|
||||
const mode = store.lock ?? terminalMode(palette) ?? store.mode
|
||||
if (store.mode !== mode) setStore("mode", mode)
|
||||
applyPalette()
|
||||
} else setSystemTheme(undefined)
|
||||
|
||||
let canProbe = false
|
||||
let probing = false
|
||||
let queued = false
|
||||
let disposed = false
|
||||
function refreshPalette() {
|
||||
queued = true
|
||||
if (!canProbe || probing || disposed) return
|
||||
|
||||
queued = false
|
||||
probing = true
|
||||
// Clearing does not cancel a query already owned by the renderer. Share it, then run one fresh query.
|
||||
const retry = renderer.paletteDetectionStatus === "detecting"
|
||||
renderer.clearPaletteCache()
|
||||
void renderer
|
||||
.getPalette({ size: 16 })
|
||||
.then((colors: TerminalColors) => {
|
||||
if (!colors.palette[0]) {
|
||||
if (hasResolvedSystemTheme) return
|
||||
setSystemTheme(undefined)
|
||||
if (store.active === "system") setStore("active", "opencode")
|
||||
return
|
||||
}
|
||||
const next = store.lock ?? terminalMode(colors) ?? mode
|
||||
if (store.mode !== next) setStore("mode", next)
|
||||
const signature = JSON.stringify(colors)
|
||||
hasResolvedSystemTheme = true
|
||||
if (store.themes.system && systemThemeSignature === signature && systemThemeMode === next) return
|
||||
systemThemeSignature = signature
|
||||
systemThemeMode = next
|
||||
setSystemTheme(generateSystem(colors, next))
|
||||
void systemPalette?.[1]((draft) => {
|
||||
.then((colors) => {
|
||||
if (disposed || !usablePalette(colors)) return
|
||||
palette = colors
|
||||
const mode = store.lock ?? terminalMode(colors) ?? store.mode
|
||||
if (store.mode !== mode) setStore("mode", mode)
|
||||
applyPalette()
|
||||
void cache?.[1]((draft) => {
|
||||
draft.colors = colors
|
||||
}).catch(() => {})
|
||||
})
|
||||
.catch(() => {
|
||||
if (hasResolvedSystemTheme) return
|
||||
setSystemTheme(undefined)
|
||||
if (store.active === "system") setStore("active", "opencode")
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
probing = false
|
||||
if (disposed || (!retry && !queued)) return
|
||||
refreshPalette()
|
||||
})
|
||||
}
|
||||
|
||||
let systemRefreshRunning = false
|
||||
let systemRefreshQueued = false
|
||||
let systemRefreshMode = store.mode
|
||||
function refreshSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||
systemRefreshMode = mode
|
||||
if (systemRefreshRunning) {
|
||||
systemRefreshQueued = true
|
||||
return
|
||||
}
|
||||
|
||||
systemRefreshRunning = true
|
||||
const retry = renderer.paletteDetectionStatus === "detecting"
|
||||
renderer.clearPaletteCache()
|
||||
void resolveSystemTheme(mode).finally(() => {
|
||||
systemRefreshRunning = false
|
||||
if (!retry && !systemRefreshQueued) return
|
||||
systemRefreshQueued = false
|
||||
refreshSystemTheme(systemRefreshMode)
|
||||
})
|
||||
function afterPaint() {
|
||||
canProbe = true
|
||||
refreshPalette()
|
||||
}
|
||||
|
||||
function apply(mode: "dark" | "light") {
|
||||
if (store.mode === mode) return
|
||||
setStore("mode", mode)
|
||||
refreshSystemTheme(mode)
|
||||
if (store.mode !== mode) {
|
||||
setStore("mode", mode)
|
||||
applyPalette()
|
||||
}
|
||||
refreshPalette()
|
||||
}
|
||||
|
||||
function pin(mode: "dark" | "light" = store.mode, persist = true) {
|
||||
@@ -274,7 +258,7 @@ const themeContext = createSimpleContext({
|
||||
|
||||
function free(persist = true) {
|
||||
setStore("lock", undefined)
|
||||
refreshSystemTheme(renderer.themeMode ?? store.mode)
|
||||
apply(renderer.themeMode ?? store.mode)
|
||||
if (!persist) return
|
||||
void configState
|
||||
.update((draft) => {
|
||||
@@ -283,33 +267,34 @@ const themeContext = createSimpleContext({
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const handle = (mode: "dark" | "light") => {
|
||||
const handleMode = (mode: "dark" | "light") => {
|
||||
if (store.lock) return
|
||||
apply(mode)
|
||||
}
|
||||
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
||||
renderer.on(CliRenderEvents.THEME_MODE, handleMode)
|
||||
|
||||
const handleThemeNotification = (sequence: string) => {
|
||||
if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false
|
||||
queueMicrotask(() => refreshSystemTheme())
|
||||
queueMicrotask(refreshPalette)
|
||||
return false
|
||||
}
|
||||
renderer.prependInputHandler(handleThemeNotification)
|
||||
|
||||
let themeRefreshTimeouts: ReturnType<typeof setTimeout>[] = []
|
||||
const refresh = () => {
|
||||
const refreshThemes = () => {
|
||||
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
|
||||
themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
|
||||
setTimeout(() => {
|
||||
refreshSystemTheme()
|
||||
refreshPalette()
|
||||
if (delay === THEME_REFRESH_DELAYS[THEME_REFRESH_DELAYS.length - 1]) void syncCustomThemes()
|
||||
}, delay),
|
||||
)
|
||||
}
|
||||
const unsubscribeRefresh = themes.subscribeRefresh?.(refresh)
|
||||
const unsubscribeRefresh = themes.subscribeRefresh?.(refreshThemes)
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
||||
disposed = true
|
||||
renderer.off(CliRenderEvents.THEME_MODE, handleMode)
|
||||
renderer.removeInputHandler(handleThemeNotification)
|
||||
unsubscribeRefresh?.()
|
||||
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
|
||||
@@ -318,29 +303,30 @@ const themeContext = createSimpleContext({
|
||||
|
||||
const initStarted = performance.now()
|
||||
const selected = createMemo(() => {
|
||||
const name = store.themes[store.active] ? store.active : "opencode"
|
||||
const sources = themeSources()
|
||||
const name = sources[store.active] ? store.active : "opencode"
|
||||
try {
|
||||
return loadTheme(store.themes[name], name, store.mode)
|
||||
return loadTheme(sources[name], name, store.mode)
|
||||
} catch (error) {
|
||||
if (name === "opencode") throw error
|
||||
themeErrors.emit(name, error)
|
||||
setStore("active", "opencode")
|
||||
return loadTheme(store.themes.opencode, "opencode", store.mode)
|
||||
return loadTheme(sources.opencode, "opencode", store.mode)
|
||||
}
|
||||
})
|
||||
const modes = () => selected().modes
|
||||
const mode = () => selected().mode
|
||||
const valuesV2 = () => selected().theme
|
||||
valuesV2()
|
||||
const tokens = () => selected().theme
|
||||
tokens()
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const current = createComponentTheme(valuesV2, mode)
|
||||
const current = createComponentTheme(tokens, mode)
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
|
||||
createEffect(() => renderer.setBackgroundColor(tokens().background.default))
|
||||
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(tokens(), mode()))
|
||||
const service: Themes = {
|
||||
current,
|
||||
currentTokens: valuesV2,
|
||||
currentTokens: tokens,
|
||||
currentSyntax,
|
||||
get selected() {
|
||||
return store.active
|
||||
@@ -368,7 +354,7 @@ const themeContext = createSimpleContext({
|
||||
.catch(() => {})
|
||||
return true
|
||||
},
|
||||
prepareSystem: refreshSystemTheme,
|
||||
afterPaint,
|
||||
onError: themeErrors.onError,
|
||||
get ready() {
|
||||
return store.ready
|
||||
@@ -395,6 +381,12 @@ export function useTheme(context?: ContextName) {
|
||||
}
|
||||
export const ThemeProvider = themeContext.provider
|
||||
|
||||
function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors {
|
||||
return Boolean(
|
||||
colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]),
|
||||
)
|
||||
}
|
||||
|
||||
/** Switches context without remounting children; undefined inherits the enclosing view. */
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
|
||||
const value = themeContext.use()
|
||||
|
||||
Reference in New Issue
Block a user