theme store

actual-tui-plugins
Sebastian Herrlinger 2026-03-06 21:57:55 +01:00
parent f98ad6e078
commit 3f9603e7a6
2 changed files with 89 additions and 63 deletions

View File

@ -1,6 +1,6 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createEffect, createMemo, onMount } from "solid-js"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
@ -138,44 +138,6 @@ type ThemeJson = {
}
}
type ThemeRegistry = {
themes: Record<string, ThemeJson>
listeners: Set<(themes: Record<string, ThemeJson>) => void>
}
const registry: ThemeRegistry = {
themes: {},
listeners: new Set(),
}
export function registerThemes(themes: Record<string, unknown>) {
const entries = Object.entries(themes).filter((entry): entry is [string, ThemeJson] => {
const theme = entry[1]
if (!theme || typeof theme !== "object") return false
if (!("theme" in theme)) return false
return true
})
if (entries.length === 0) return
for (const [name, theme] of entries) {
registry.themes[name] = theme
}
const payload = Object.fromEntries(entries)
for (const handler of registry.listeners) {
handler(payload)
}
}
function registeredThemes() {
return registry.themes
}
function onThemes(handler: (themes: Record<string, ThemeJson>) => void) {
registry.listeners.add(handler)
return () => registry.listeners.delete(handler)
}
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
aura,
ayu,
@ -212,6 +174,46 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
carbonfox,
}
type State = {
themes: Record<string, ThemeJson>
mode: "dark" | "light"
active: string
ready: boolean
}
const [store, setStore] = createStore<State>({
themes: DEFAULT_THEMES,
mode: "dark",
active: "opencode",
ready: false,
})
function mergeThemes(themes: Record<string, ThemeJson>) {
setStore(
produce((draft) => {
for (const [name, theme] of Object.entries(themes)) {
if (draft.themes[name]) continue
draft.themes[name] = theme
}
}),
)
}
export function allThemes() {
return store.themes
}
export function registerThemes(themes: Record<string, unknown>) {
const list = Object.entries(themes).filter((entry): entry is [string, ThemeJson] => {
const theme = entry[1]
if (!theme || typeof theme !== "object") return false
if (!("theme" in theme)) return false
return true
})
if (!list.length) return
mergeThemes(Object.fromEntries(list))
}
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
@ -320,12 +322,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
init: (props: { mode: "dark" | "light" }) => {
const config = useTuiConfig()
const kv = useKV()
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
setStore(
produce((draft) => {
draft.mode = kv.get("theme_mode", props.mode)
draft.active = (config.theme ?? kv.get("theme", "opencode")) as string
draft.ready = false
}),
)
createEffect(() => {
const theme = config.theme
@ -334,7 +338,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
function init() {
resolveSystemTheme()
mergeThemes(registeredThemes())
getCustomThemes()
.then((custom) => {
setStore(
@ -354,22 +357,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
onMount(init)
onCleanup(
onThemes((themes) => {
mergeThemes(themes)
}),
)
function mergeThemes(themes: Record<string, ThemeJson>) {
setStore(
produce((draft) => {
for (const [name, theme] of Object.entries(themes)) {
if (draft.themes[name]) continue
draft.themes[name] = theme
}
}),
)
}
function resolveSystemTheme() {
console.log("resolveSystemTheme")
@ -425,7 +412,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return store.active
},
all() {
return store.themes
return allThemes()
},
syntax,
subtleSyntax,

View File

@ -0,0 +1,39 @@
import { expect, mock, test } from "bun:test"
mock.module("@opentui/solid/jsx-runtime", () => ({
Fragment: Symbol.for("Fragment"),
jsx: () => null,
jsxs: () => null,
jsxDEV: () => null,
}))
const { DEFAULT_THEMES, allThemes, registerThemes } = await import("../../../src/cli/cmd/tui/context/theme")
test("registerThemes writes into module theme store", () => {
const name = `plugin-theme-${Date.now()}`
registerThemes({
[name]: DEFAULT_THEMES.opencode,
})
expect(allThemes()[name]).toBeDefined()
})
test("registerThemes keeps first theme for duplicate names", () => {
const name = `plugin-theme-keep-${Date.now()}`
const one = structuredClone(DEFAULT_THEMES.opencode)
const two = structuredClone(DEFAULT_THEMES.opencode)
;(one.theme as Record<string, unknown>).primary = "#101010"
;(two.theme as Record<string, unknown>).primary = "#fefefe"
registerThemes({ [name]: one })
registerThemes({ [name]: two })
expect(allThemes()[name]).toBeDefined()
expect(allThemes()[name]!.theme.primary).toBe("#101010")
})
test("registerThemes ignores entries without a theme object", () => {
const name = `plugin-theme-invalid-${Date.now()}`
registerThemes({ [name]: { defs: { a: "#ffffff" } } })
expect(allThemes()[name]).toBeUndefined()
})