import { createEffect, createMemo, onMount, onCleanup } from "solid-js"; import { createStore, produce } from "solid-js/store"; import { useRenderer } from "@opentui/solid"; import type { ThemeName } from "../types/settings"; import type { ThemeJson } from "../types/theme-schema"; import { useAppStore } from "../stores/app"; import { THEME_JSON } from "../constants/themes"; import { generateSyntax, generateSubtleSyntax, } from "../utils/syntax-highlighter"; import { resolveTerminalTheme, loadThemes } from "../utils/theme"; import { createSimpleContext } from "./helper"; import { setupThemeSignalHandler, emitThemeChanged, emitThemeModeChanged, } from "../utils/theme-observer"; import { createTerminalPalette, type RGBA, type TerminalColors, } from "@opentui/core"; export type ThemeResolved = { primary: RGBA; secondary: RGBA; accent: RGBA; error: RGBA; warning: RGBA; success: RGBA; info: RGBA; text: RGBA; textMuted: RGBA; textPrimary: RGBA; textSecondary: RGBA; textTertiary: RGBA; textSelectedPrimary: RGBA; textSelectedSecondary: RGBA; textSelectedTertiary: RGBA; background: RGBA; backgroundPanel: RGBA; backgroundElement: RGBA; backgroundMenu: RGBA; border: RGBA; borderActive: RGBA; borderSubtle: RGBA; diffAdded: RGBA; diffRemoved: RGBA; diffContext: RGBA; diffHunkHeader: RGBA; diffHighlightAdded: RGBA; diffHighlightRemoved: RGBA; diffAddedBg: RGBA; diffRemovedBg: RGBA; diffContextBg: RGBA; diffLineNumber: RGBA; diffAddedLineNumberBg: RGBA; diffRemovedLineNumberBg: RGBA; markdownText: RGBA; markdownHeading: RGBA; markdownLink: RGBA; markdownLinkText: RGBA; markdownCode: RGBA; markdownBlockQuote: RGBA; markdownEmph: RGBA; markdownStrong: RGBA; markdownHorizontalRule: RGBA; markdownListItem: RGBA; markdownListEnumeration: RGBA; markdownImage: RGBA; markdownImageText: RGBA; markdownCodeBlock: RGBA; syntaxComment: RGBA; syntaxKeyword: RGBA; syntaxFunction: RGBA; syntaxVariable: RGBA; syntaxString: RGBA; syntaxNumber: RGBA; syntaxType: RGBA; syntaxOperator: RGBA; syntaxPunctuation: RGBA; muted?: RGBA; surface?: RGBA; selectedListItemText?: RGBA; layerBackgrounds?: { layer0: RGBA; layer1: RGBA; layer2: RGBA; layer3: RGBA; }; _hasSelectedListItemText?: boolean; thinkingOpacity?: number; }; /** * Theme context using the createSimpleContext pattern. * * This ensures children are NOT rendered until the theme is ready, * preventing "useTheme must be used within a ThemeProvider" errors. * */ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { mode: "dark" | "light" }) => { const appStore = useAppStore(); const renderer = useRenderer(); const [store, setStore] = createStore({ themes: THEME_JSON as Record, mode: props.mode, active: appStore.state().settings.theme as string, system: undefined as undefined | TerminalColors, ready: false, }); function init() { resolveSystemTheme(); loadThemes() .then((custom) => { setStore( produce((draft) => { Object.assign(draft.themes, custom); }), ); }) .catch(() => { setStore("active", "catppuccin"); }) .finally(() => { // Only set ready if not waiting for system theme if (store.active !== "system") { setStore("ready", true); } }); } async function waitForCapabilities(timeoutMs = 300) { if (renderer.capabilities) return; await new Promise((resolve) => { let done = false; const onCaps = () => { if (done) return; done = true; renderer.off("capabilities", onCaps); clearTimeout(timer); resolve(); }; const timer = setTimeout(() => { if (done) return; done = true; renderer.off("capabilities", onCaps); resolve(); }, timeoutMs); renderer.on("capabilities", onCaps); }); } async function resolveSystemTheme() { if (process.env.TMUX) { await waitForCapabilities(); } let colors: TerminalColors | null = null; try { colors = await renderer.getPalette({ size: 16 }); } catch { colors = null; } if (!colors?.palette?.[0] && process.env.TMUX) { const writeOut = ( renderer as unknown as { writeOut?: (data: string | Buffer) => boolean; } ).writeOut; const writeFn = typeof writeOut === "function" ? writeOut.bind(renderer) : process.stdout.write.bind(process.stdout); const detector = createTerminalPalette( process.stdin, process.stdout, writeFn, true, ); try { const tmuxColors = await detector.detect({ size: 16, timeout: 1200 }); if (tmuxColors?.palette?.[0]) { colors = tmuxColors; } } finally { detector.cleanup(); } } const hasPalette = Boolean( colors?.palette?.some((value) => Boolean(value)), ); const hasDefaultColors = Boolean( colors?.defaultBackground || colors?.defaultForeground, ); if (!hasPalette && !hasDefaultColors) { // No system colors available, fall back to default // This happens when the terminal doesn't support OSC palette queries // (e.g., running inside tmux, or on unsupported terminals) if (store.active === "system") { setStore( produce((draft) => { draft.active = "catppuccin"; draft.ready = true; }), ); } return; } if (colors) { setStore( produce((draft) => { draft.system = colors; if (store.active === "system") { draft.ready = true; } }), ); } } onMount(init); // Setup SIGUSR2 signal handler for dynamic theme reload // This allows external tools to trigger a theme refresh by sending: // `kill -USR2 ` const cleanupSignalHandler = setupThemeSignalHandler(() => { renderer.clearPaletteCache(); init(); }); onCleanup(cleanupSignalHandler); // Sync active theme with app store settings createEffect(() => { const theme = appStore.state().settings.theme; if (theme) setStore("active", theme); }); // Emit theme change events for observers createEffect(() => { const theme = store.active; const mode = store.mode; if (store.ready) { emitThemeChanged(theme, mode); } }); const values = createMemo(() => { return resolveTerminalTheme( store.themes, store.active, store.mode, store.system, ); }); const syntax = createMemo(() => generateSyntax(values() as unknown as Record), ); const subtleSyntax = createMemo(() => generateSubtleSyntax( values() as unknown as Record & { thinkingOpacity?: number; }, ), ); return { theme: new Proxy(values(), { get(_target, prop) { // @ts-expect-error - dynamic property access return values()[prop]; }, }) as ThemeResolved, get selected() { return store.active; }, all() { return store.themes; }, syntax, subtleSyntax, mode() { return store.mode; }, setMode(mode: "dark" | "light") { setStore("mode", mode); emitThemeModeChanged(mode); }, set(theme: string) { appStore.setTheme(theme as ThemeName); }, get ready() { return store.ready; }, }; }, });