diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 8fc8c5b..8fcc0f0 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,5 +1,7 @@ import type { JSX } from "solid-js" -import type { ThemeColors, LayerBackgrounds } from "../types/settings" +import type { ThemeColors } from "../types/settings" +import type { ColorValue } from "../types/theme-schema" +import { resolveColorReference } from "../utils/theme-css" import { LayerIndicator } from "./LayerIndicator" type LayerConfig = { @@ -17,6 +19,7 @@ type LayoutProps = { export function Layout(props: LayoutProps) { const theme = props.theme + const toColor = (value?: ColorValue) => (value ? resolveColorReference(value) : undefined) // Get layer configuration based on depth const getLayerConfig = (depth: number): LayerConfig => { @@ -26,10 +29,10 @@ export function Layout(props: LayoutProps) { const backgrounds = theme.layerBackgrounds const depthMap: Record = { - 0: { depth: 0, background: backgrounds.layer0 }, - 1: { depth: 1, background: backgrounds.layer1 }, - 2: { depth: 2, background: backgrounds.layer2 }, - 3: { depth: 3, background: backgrounds.layer3 }, + 0: { depth: 0, background: resolveColorReference(backgrounds.layer0) }, + 1: { depth: 1, background: resolveColorReference(backgrounds.layer1) }, + 2: { depth: 2, background: resolveColorReference(backgrounds.layer2) }, + 3: { depth: 3, background: resolveColorReference(backgrounds.layer3) }, } return depthMap[depth] || { depth: 0, background: "transparent" } @@ -43,14 +46,14 @@ export function Layout(props: LayoutProps) { flexDirection="column" width="100%" height="100%" - backgroundColor={theme?.background} + backgroundColor={toColor(theme?.background)} > {/* Header */} {props.header ? ( @@ -80,7 +83,7 @@ export function Layout(props: LayoutProps) { @@ -96,7 +99,7 @@ export function Layout(props: LayoutProps) { diff --git a/src/constants/themes.ts b/src/constants/themes.ts index 896402b..7ef773a 100644 --- a/src/constants/themes.ts +++ b/src/constants/themes.ts @@ -1,16 +1,20 @@ -import type { ThemeColors, ThemeName } from "../types/settings" -import { BASE_THEME_COLORS, BASE_LAYER_BACKGROUND, THEMES_DESKTOP } from "../types/desktop-theme" +import type { ThemeColors, ThemeDefinition, ThemeName } from "../types/settings" +import { BASE_THEME_COLORS, BASE_LAYER_BACKGROUND } from "../types/desktop-theme" +import catppuccin from "../themes/catppuccin.json" with { type: "json" } +import gruvbox from "../themes/gruvbox.json" with { type: "json" } +import tokyo from "../themes/tokyo.json" with { type: "json" } +import nord from "../themes/nord.json" with { type: "json" } +import opencode from "../themes/opencode.json" with { type: "json" } export const DEFAULT_THEME: ThemeColors = { ...BASE_THEME_COLORS, layerBackgrounds: BASE_LAYER_BACKGROUND, } -export const THEMES: Record = { - system: DEFAULT_THEME, - catppuccin: THEMES_DESKTOP.variants.find((v) => v.name === "catppuccin")!.colors, - gruvbox: THEMES_DESKTOP.variants.find((v) => v.name === "gruvbox")!.colors, - tokyo: THEMES_DESKTOP.variants.find((v) => v.name === "tokyo")!.colors, - nord: THEMES_DESKTOP.variants.find((v) => v.name === "nord")!.colors, - custom: DEFAULT_THEME, +export const THEME_JSON: Record = { + opencode: opencode as ThemeDefinition, + catppuccin: catppuccin as ThemeDefinition, + gruvbox: gruvbox as ThemeDefinition, + tokyo: tokyo as ThemeDefinition, + nord: nord as ThemeDefinition, } diff --git a/src/context/ThemeContext.test.ts b/src/context/ThemeContext.test.ts new file mode 100644 index 0000000..b87f850 --- /dev/null +++ b/src/context/ThemeContext.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "bun:test" +import { ThemeProvider } from "./ThemeContext" + +describe("ThemeContext", () => { + it("exports provider", () => { + expect(typeof ThemeProvider).toBe("function") + }) +}) diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 5b2484a..7c31111 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -1,67 +1,101 @@ -import { createContext, useContext, createSignal, createEffect, onCleanup } from "solid-js" -import type { ThemeColors, ThemeName } from "../types/settings" +import { createContext, createEffect, createMemo, createSignal, useContext } 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 { applyTheme, setThemeAttribute, getSystemThemeMode } from "../utils/theme" +import { THEME_JSON } from "../constants/themes" +import { resolveTheme } from "../utils/theme-resolver" +import { generateSyntax, generateSubtleSyntax } from "../utils/syntax-highlighter" +import { generateSystemTheme } from "../utils/system-theme" +import { getCustomThemes } from "../utils/custom-themes" +import { setThemeAttribute } from "../utils/theme" +import type { RGBA } from "@opentui/core" -type ThemeContextType = { - themeName: () => ThemeName - setThemeName: (theme: ThemeName) => void - resolvedTheme: () => ThemeColors - isSystemTheme: () => boolean - currentMode: () => "dark" | "light" +type ThemeContextValue = { + theme: Record + selected: () => string + all: () => Record + syntax: () => unknown + subtleSyntax: () => unknown + mode: () => "dark" | "light" + setMode: (mode: "dark" | "light") => void + set: (theme: string) => void + ready: () => boolean } -const ThemeContext = createContext() +const ThemeContext = createContext() export function ThemeProvider({ children }: { children: any }) { const appStore = useAppStore() - const [themeName, setThemeName] = createSignal(appStore.state().settings.theme) - const [resolvedTheme, setResolvedTheme] = createSignal(appStore.resolveTheme()) - const [currentMode, setCurrentMode] = createSignal<"dark" | "light">(getSystemThemeMode()) - - const isSystemTheme = () => themeName() === "system" - - // Update theme when appStore theme changes - createEffect(() => { - const currentTheme = appStore.state().settings.theme - setThemeName(currentTheme) - setResolvedTheme(appStore.resolveTheme()) - - // Apply theme to CSS variables - if (currentTheme === "system") { - const mode = getSystemThemeMode() - setCurrentMode(mode) - applyTheme(resolvedTheme()) - } else { - setCurrentMode("dark") // All themes are dark by default - } - - setThemeAttribute(currentTheme === "system" ? "system" : currentTheme) + const renderer = useRenderer() + const [ready, setReady] = createSignal(false) + const [store, setStore] = createStore({ + themes: { ...THEME_JSON } as Record, + mode: "dark" as "dark" | "light", + active: appStore.state().settings.theme as ThemeName, }) - // Handle system theme changes - createEffect(() => { - if (isSystemTheme()) { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - const handler = () => { - const newMode = getSystemThemeMode() - setCurrentMode(newMode) - setResolvedTheme(appStore.resolveTheme()) - } - - mediaQuery.addEventListener("change", handler) - - onCleanup(() => { - mediaQuery.removeEventListener("change", handler) + const init = () => { + getCustomThemes() + .then((custom) => { + setStore( + produce((draft) => { + Object.assign(draft.themes, custom) + }) + ) }) - } + .finally(() => setReady(true)) + } + + init() + + createEffect(() => { + setStore("active", appStore.state().settings.theme) + setThemeAttribute(appStore.state().settings.theme) }) - return ( - - {children} - + createEffect(() => { + if (store.active !== "system") return + renderer + .getPalette({ size: 16 }) + .then((colors) => { + setStore( + produce((draft) => { + draft.themes.system = generateSystemTheme(colors, store.mode) + }) + ) + }) + .catch(() => {}) + }) + + const values = createMemo(() => { + const theme = store.themes[store.active] ?? store.themes.opencode + return resolveTheme(theme, store.mode) + }) + + const syntax = createMemo(() => generateSyntax(values() as unknown as Record)) + const subtleSyntax = createMemo(() => + generateSubtleSyntax(values() as unknown as Record & { thinkingOpacity?: number }) ) + + const context: ThemeContextValue = { + theme: new Proxy(values(), { + get(_target, prop) { + return values()[prop as keyof typeof values] + }, + }), + selected: () => store.active, + all: () => store.themes, + syntax, + subtleSyntax, + mode: () => store.mode, + setMode: (mode) => setStore("mode", mode), + set: (theme) => appStore.setTheme(theme as ThemeName), + ready, + } + + return {children} } export function useTheme() { diff --git a/src/stores/app.ts b/src/stores/app.ts index dc26c0f..923d404 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -1,6 +1,8 @@ import { createSignal } from "solid-js" -import { DEFAULT_THEME, THEMES } from "../constants/themes" -import type { AppSettings, AppState, ThemeColors, ThemeName, UserPreferences } from "../types/settings" +import { DEFAULT_THEME, THEME_JSON } from "../constants/themes" +import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences } from "../types/settings" +import { resolveTheme } from "../utils/theme-resolver" +import type { ThemeJson } from "../types/theme-schema" const STORAGE_KEY = "podtui_app_state" @@ -83,10 +85,13 @@ export function createAppStore() { updateSettings({ theme }) } - const resolveTheme = (): ThemeColors => { + const resolveThemeColors = (): ThemeColors => { const theme = state().settings.theme if (theme === "custom") return state().customTheme - return THEMES[theme] ?? DEFAULT_THEME + if (theme === "system") return DEFAULT_THEME + const json = THEME_JSON[theme] + if (!json) return DEFAULT_THEME + return resolveTheme(json as ThemeJson, "dark" as ThemeMode) as unknown as ThemeColors } return { @@ -95,7 +100,7 @@ export function createAppStore() { updatePreferences, updateCustomTheme, setTheme, - resolveTheme, + resolveTheme: resolveThemeColors, } } diff --git a/src/themes/catppuccin.json b/src/themes/catppuccin.json new file mode 100644 index 0000000..54717d9 --- /dev/null +++ b/src/themes/catppuccin.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "transparent", + "surface": "#1e1e2e", + "primary": "#89b4fa", + "secondary": "#cba6f7", + "accent": "#f9e2af", + "text": "#cdd6f4", + "muted": "#7f849c", + "warning": "#fab387", + "error": "#f38ba8", + "success": "#a6e3a1", + "layer0": "transparent", + "layer1": "#181825", + "layer2": "#11111b", + "layer3": "#0a0a0f" + }, + "theme": { + "primary": "primary", + "secondary": "secondary", + "accent": "accent", + "error": "error", + "warning": "warning", + "success": "success", + "info": "secondary", + "text": "text", + "textMuted": "muted", + "selectedListItemText": "background", + "background": "background", + "backgroundPanel": "surface", + "backgroundElement": "layer1", + "backgroundMenu": "layer1", + "border": "muted", + "borderActive": "primary", + "borderSubtle": "muted", + "diffAdded": "success", + "diffRemoved": "error", + "diffContext": "muted", + "diffHunkHeader": "muted", + "diffHighlightAdded": "success", + "diffHighlightRemoved": "error", + "diffAddedBg": "layer2", + "diffRemovedBg": "layer3", + "diffContextBg": "layer1", + "diffLineNumber": "muted", + "diffAddedLineNumberBg": "layer2", + "diffRemovedLineNumberBg": "layer3", + "markdownText": "text", + "markdownHeading": "accent", + "markdownLink": "primary", + "markdownLinkText": "secondary", + "markdownCode": "success", + "markdownBlockQuote": "warning", + "markdownEmph": "warning", + "markdownStrong": "accent", + "markdownHorizontalRule": "muted", + "markdownListItem": "primary", + "markdownListEnumeration": "secondary", + "markdownImage": "primary", + "markdownImageText": "secondary", + "markdownCodeBlock": "text", + "syntaxComment": "muted", + "syntaxKeyword": "accent", + "syntaxFunction": "primary", + "syntaxVariable": "secondary", + "syntaxString": "success", + "syntaxNumber": "warning", + "syntaxType": "accent", + "syntaxOperator": "secondary", + "syntaxPunctuation": "text", + "thinkingOpacity": 0.6 + } +} diff --git a/src/themes/gruvbox.json b/src/themes/gruvbox.json new file mode 100644 index 0000000..bb9091f --- /dev/null +++ b/src/themes/gruvbox.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "transparent", + "surface": "#282828", + "primary": "#fabd2f", + "secondary": "#83a598", + "accent": "#fe8019", + "text": "#ebdbb2", + "muted": "#928374", + "warning": "#fabd2f", + "error": "#fb4934", + "success": "#b8bb26", + "layer0": "transparent", + "layer1": "#32302a", + "layer2": "#1d2021", + "layer3": "#0d0c0c" + }, + "theme": { + "primary": "primary", + "secondary": "secondary", + "accent": "accent", + "error": "error", + "warning": "warning", + "success": "success", + "info": "secondary", + "text": "text", + "textMuted": "muted", + "selectedListItemText": "background", + "background": "background", + "backgroundPanel": "surface", + "backgroundElement": "layer1", + "backgroundMenu": "layer1", + "border": "muted", + "borderActive": "primary", + "borderSubtle": "muted", + "diffAdded": "success", + "diffRemoved": "error", + "diffContext": "muted", + "diffHunkHeader": "muted", + "diffHighlightAdded": "success", + "diffHighlightRemoved": "error", + "diffAddedBg": "layer2", + "diffRemovedBg": "layer3", + "diffContextBg": "layer1", + "diffLineNumber": "muted", + "diffAddedLineNumberBg": "layer2", + "diffRemovedLineNumberBg": "layer3", + "markdownText": "text", + "markdownHeading": "accent", + "markdownLink": "primary", + "markdownLinkText": "secondary", + "markdownCode": "success", + "markdownBlockQuote": "warning", + "markdownEmph": "warning", + "markdownStrong": "accent", + "markdownHorizontalRule": "muted", + "markdownListItem": "primary", + "markdownListEnumeration": "secondary", + "markdownImage": "primary", + "markdownImageText": "secondary", + "markdownCodeBlock": "text", + "syntaxComment": "muted", + "syntaxKeyword": "accent", + "syntaxFunction": "primary", + "syntaxVariable": "secondary", + "syntaxString": "success", + "syntaxNumber": "warning", + "syntaxType": "accent", + "syntaxOperator": "secondary", + "syntaxPunctuation": "text", + "thinkingOpacity": 0.6 + } +} diff --git a/src/themes/nord.json b/src/themes/nord.json new file mode 100644 index 0000000..6fcae04 --- /dev/null +++ b/src/themes/nord.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "transparent", + "surface": "#2e3440", + "primary": "#88c0d0", + "secondary": "#81a1c1", + "accent": "#ebcb8b", + "text": "#eceff4", + "muted": "#4c566a", + "warning": "#ebcb8b", + "error": "#bf616a", + "success": "#a3be8c", + "layer0": "transparent", + "layer1": "#3b4252", + "layer2": "#242933", + "layer3": "#1a1c23" + }, + "theme": { + "primary": "primary", + "secondary": "secondary", + "accent": "accent", + "error": "error", + "warning": "warning", + "success": "success", + "info": "secondary", + "text": "text", + "textMuted": "muted", + "selectedListItemText": "background", + "background": "background", + "backgroundPanel": "surface", + "backgroundElement": "layer1", + "backgroundMenu": "layer1", + "border": "muted", + "borderActive": "primary", + "borderSubtle": "muted", + "diffAdded": "success", + "diffRemoved": "error", + "diffContext": "muted", + "diffHunkHeader": "muted", + "diffHighlightAdded": "success", + "diffHighlightRemoved": "error", + "diffAddedBg": "layer2", + "diffRemovedBg": "layer3", + "diffContextBg": "layer1", + "diffLineNumber": "muted", + "diffAddedLineNumberBg": "layer2", + "diffRemovedLineNumberBg": "layer3", + "markdownText": "text", + "markdownHeading": "accent", + "markdownLink": "primary", + "markdownLinkText": "secondary", + "markdownCode": "success", + "markdownBlockQuote": "warning", + "markdownEmph": "warning", + "markdownStrong": "accent", + "markdownHorizontalRule": "muted", + "markdownListItem": "primary", + "markdownListEnumeration": "secondary", + "markdownImage": "primary", + "markdownImageText": "secondary", + "markdownCodeBlock": "text", + "syntaxComment": "muted", + "syntaxKeyword": "accent", + "syntaxFunction": "primary", + "syntaxVariable": "secondary", + "syntaxString": "success", + "syntaxNumber": "warning", + "syntaxType": "accent", + "syntaxOperator": "secondary", + "syntaxPunctuation": "text", + "thinkingOpacity": 0.6 + } +} diff --git a/src/themes/opencode.json b/src/themes/opencode.json new file mode 100644 index 0000000..8f585a4 --- /dev/null +++ b/src/themes/opencode.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#0a0a0a", + "darkStep2": "#141414", + "darkStep3": "#1e1e1e", + "darkStep4": "#282828", + "darkStep5": "#323232", + "darkStep6": "#3c3c3c", + "darkStep7": "#484848", + "darkStep8": "#606060", + "darkStep9": "#fab283", + "darkStep10": "#ffc09f", + "darkStep11": "#808080", + "darkStep12": "#eeeeee", + "darkSecondary": "#5c9cf5", + "darkAccent": "#9d7cd8", + "darkRed": "#e06c75", + "darkOrange": "#f5a742", + "darkGreen": "#7fd88f", + "darkCyan": "#56b6c2", + "darkYellow": "#e5c07b", + "lightStep1": "#ffffff", + "lightStep2": "#fafafa", + "lightStep3": "#f5f5f5", + "lightStep4": "#ebebeb", + "lightStep5": "#e1e1e1", + "lightStep6": "#d4d4d4", + "lightStep7": "#b8b8b8", + "lightStep8": "#a0a0a0", + "lightStep9": "#3b7dd8", + "lightStep10": "#2968c3", + "lightStep11": "#8a8a8a", + "lightStep12": "#1a1a1a", + "lightSecondary": "#7b5bb6", + "lightAccent": "#d68c27", + "lightRed": "#d1383d", + "lightOrange": "#d68c27", + "lightGreen": "#3d9a57", + "lightCyan": "#318795", + "lightYellow": "#b0851f" + }, + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "darkSecondary", + "light": "lightSecondary" + }, + "accent": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "darkStep7", + "light": "lightStep7" + }, + "borderActive": { + "dark": "darkStep8", + "light": "lightStep8" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#4fd6be", + "light": "#1e725c" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#b8db87", + "light": "#4db380" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#20303b", + "light": "#d5e5d5" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#1b2b34", + "light": "#c5d5c5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "syntaxFunction": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/themes/schema.json b/src/themes/schema.json new file mode 100644 index 0000000..acecc2e --- /dev/null +++ b/src/themes/schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": {}, + "theme": { + "primary": "#000000", + "secondary": "#000000", + "accent": "#000000", + "error": "#000000", + "warning": "#000000", + "success": "#000000", + "info": "#000000", + "text": "#000000", + "textMuted": "#000000", + "selectedListItemText": "#000000", + "background": "#000000", + "backgroundPanel": "#000000", + "backgroundElement": "#000000", + "backgroundMenu": "#000000", + "border": "#000000", + "borderActive": "#000000", + "borderSubtle": "#000000", + "diffAdded": "#000000", + "diffRemoved": "#000000", + "diffContext": "#000000", + "diffHunkHeader": "#000000", + "diffHighlightAdded": "#000000", + "diffHighlightRemoved": "#000000", + "diffAddedBg": "#000000", + "diffRemovedBg": "#000000", + "diffContextBg": "#000000", + "diffLineNumber": "#000000", + "diffAddedLineNumberBg": "#000000", + "diffRemovedLineNumberBg": "#000000", + "markdownText": "#000000", + "markdownHeading": "#000000", + "markdownLink": "#000000", + "markdownLinkText": "#000000", + "markdownCode": "#000000", + "markdownBlockQuote": "#000000", + "markdownEmph": "#000000", + "markdownStrong": "#000000", + "markdownHorizontalRule": "#000000", + "markdownListItem": "#000000", + "markdownListEnumeration": "#000000", + "markdownImage": "#000000", + "markdownImageText": "#000000", + "markdownCodeBlock": "#000000", + "syntaxComment": "#000000", + "syntaxKeyword": "#000000", + "syntaxFunction": "#000000", + "syntaxVariable": "#000000", + "syntaxString": "#000000", + "syntaxNumber": "#000000", + "syntaxType": "#000000", + "syntaxOperator": "#000000", + "syntaxPunctuation": "#000000", + "thinkingOpacity": 0.6 + } +} diff --git a/src/themes/tokyo.json b/src/themes/tokyo.json new file mode 100644 index 0000000..0cf70a7 --- /dev/null +++ b/src/themes/tokyo.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "transparent", + "surface": "#1a1b26", + "primary": "#7aa2f7", + "secondary": "#bb9af7", + "accent": "#e0af68", + "text": "#c0caf5", + "muted": "#565f89", + "warning": "#e0af68", + "error": "#f7768e", + "success": "#9ece6a", + "layer0": "transparent", + "layer1": "#16161e", + "layer2": "#0f0f15", + "layer3": "#08080b" + }, + "theme": { + "primary": "primary", + "secondary": "secondary", + "accent": "accent", + "error": "error", + "warning": "warning", + "success": "success", + "info": "secondary", + "text": "text", + "textMuted": "muted", + "selectedListItemText": "background", + "background": "background", + "backgroundPanel": "surface", + "backgroundElement": "layer1", + "backgroundMenu": "layer1", + "border": "muted", + "borderActive": "primary", + "borderSubtle": "muted", + "diffAdded": "success", + "diffRemoved": "error", + "diffContext": "muted", + "diffHunkHeader": "muted", + "diffHighlightAdded": "success", + "diffHighlightRemoved": "error", + "diffAddedBg": "layer2", + "diffRemovedBg": "layer3", + "diffContextBg": "layer1", + "diffLineNumber": "muted", + "diffAddedLineNumberBg": "layer2", + "diffRemovedLineNumberBg": "layer3", + "markdownText": "text", + "markdownHeading": "accent", + "markdownLink": "primary", + "markdownLinkText": "secondary", + "markdownCode": "success", + "markdownBlockQuote": "warning", + "markdownEmph": "warning", + "markdownStrong": "accent", + "markdownHorizontalRule": "muted", + "markdownListItem": "primary", + "markdownListEnumeration": "secondary", + "markdownImage": "primary", + "markdownImageText": "secondary", + "markdownCodeBlock": "text", + "syntaxComment": "muted", + "syntaxKeyword": "accent", + "syntaxFunction": "primary", + "syntaxVariable": "secondary", + "syntaxString": "success", + "syntaxNumber": "warning", + "syntaxType": "accent", + "syntaxOperator": "secondary", + "syntaxPunctuation": "text", + "thinkingOpacity": 0.6 + } +} diff --git a/src/types/desktop-theme.ts b/src/types/desktop-theme.ts index e3068bd..71f992d 100644 --- a/src/types/desktop-theme.ts +++ b/src/types/desktop-theme.ts @@ -1,10 +1,12 @@ import type { DesktopTheme, ThemeColors, + ThemeDefinition, ThemeName, ThemeToken, ThemeVariant, } from "../types/settings" +import type { ColorValue } from "./theme-schema" // Base theme colors export const BASE_THEME_COLORS: ThemeColors = { @@ -63,12 +65,12 @@ export const THEMES_DESKTOP: DesktopTheme = { warning: "#fab387", error: "#f38ba8", success: "#a6e3a1", - layerBackgrounds: { - layer0: "transparent", - layer1: "#181825", - layer2: "#11111b", - layer3: "#0a0a0f", - }, + layerBackgrounds: { + layer0: "transparent", + layer1: "#181825", + layer2: "#11111b", + layer3: "#0a0a0f", + }, }, }, { @@ -150,3 +152,9 @@ export function getDefaultTheme(): ThemeVariant { (variant) => variant.name === THEMES_DESKTOP.defaultVariant )! } + +export type ThemeJsonFile = ThemeDefinition + +export function isColorReference(value: ColorValue): value is string { + return typeof value === "string" && !value.startsWith("#") +} diff --git a/src/types/settings.ts b/src/types/settings.ts index 786dd10..b546686 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,23 +1,26 @@ +import type { RGBA } from "@opentui/core" +import type { ColorValue, ThemeJson, Variant } from "./theme-schema" + export type ThemeName = "system" | "catppuccin" | "gruvbox" | "tokyo" | "nord" | "custom" export type LayerBackgrounds = { - layer0: string - layer1: string - layer2: string - layer3: string + layer0: ColorValue + layer1: ColorValue + layer2: ColorValue + layer3: ColorValue } export type ThemeColors = { - background: string - surface: string - primary: string - secondary: string - accent: string - text: string - muted: string - warning: string - error: string - success: string + background: ColorValue + surface: ColorValue + primary: ColorValue + secondary: ColorValue + accent: ColorValue + text: ColorValue + muted: ColorValue + warning: ColorValue + error: ColorValue + success: ColorValue layerBackgrounds?: LayerBackgrounds } @@ -30,8 +33,10 @@ export type ThemeToken = { [key: string]: string } -export type ResolvedTheme = ThemeColors & { - layerBackgrounds: LayerBackgrounds +export type ResolvedTheme = Record & { + layerBackgrounds: Record + _hasSelectedListItemText: boolean + thinkingOpacity: number } export type DesktopTheme = { @@ -58,3 +63,7 @@ export type AppState = { preferences: UserPreferences customTheme: ThemeColors } + +export type ThemeMode = "dark" | "light" +export type ThemeVariantValue = Variant +export type ThemeDefinition = ThemeJson diff --git a/src/types/theme-schema.ts b/src/types/theme-schema.ts new file mode 100644 index 0000000..65fb6ae --- /dev/null +++ b/src/types/theme-schema.ts @@ -0,0 +1,26 @@ +import type { RGBA } from "@opentui/core" + +export type HexColor = `#${string}` +export type RefName = string + +export type Variant = { + dark: HexColor | RefName + light: HexColor | RefName +} + +export type ColorValue = HexColor | RefName | Variant | RGBA | number + +export type ThemeJson = { + $schema?: string + defs?: Record + theme: Record & { + selectedListItemText?: ColorValue + backgroundMenu?: ColorValue + thinkingOpacity?: number + } +} + +export type ThemeColors = Record & { + _hasSelectedListItemText: boolean + thinkingOpacity: number +} diff --git a/src/utils/ansi-to-rgba.ts b/src/utils/ansi-to-rgba.ts new file mode 100644 index 0000000..b07d35d --- /dev/null +++ b/src/utils/ansi-to-rgba.ts @@ -0,0 +1,41 @@ +import { RGBA } from "@opentui/core" + +export function ansiToRgba(code: number) { + if (code < 16) { + const ansi = [ + "#000000", + "#800000", + "#008000", + "#808000", + "#000080", + "#800080", + "#008080", + "#c0c0c0", + "#808080", + "#ff0000", + "#00ff00", + "#ffff00", + "#0000ff", + "#ff00ff", + "#00ffff", + "#ffffff", + ] + return RGBA.fromHex(ansi[code] ?? "#000000") + } + + if (code < 232) { + const index = code - 16 + const b = index % 6 + const g = Math.floor(index / 6) % 6 + const r = Math.floor(index / 36) + const value = (x: number) => (x === 0 ? 0 : x * 40 + 55) + return RGBA.fromInts(value(r), value(g), value(b)) + } + + if (code < 256) { + const gray = (code - 232) * 10 + 8 + return RGBA.fromInts(gray, gray, gray) + } + + return RGBA.fromInts(0, 0, 0) +} diff --git a/src/utils/color-generation.ts b/src/utils/color-generation.ts new file mode 100644 index 0000000..885d72b --- /dev/null +++ b/src/utils/color-generation.ts @@ -0,0 +1,67 @@ +import { RGBA } from "@opentui/core" + +export function tint(base: RGBA, overlay: RGBA, alpha: number) { + const r = base.r + (overlay.r - base.r) * alpha + const g = base.g + (overlay.g - base.g) * alpha + const b = base.b + (overlay.b - base.b) * alpha + return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)) +} + +export function generateGrayScale(bg: RGBA, isDark: boolean) { + const grays: Record = {} + const bgR = bg.r * 255 + const bgG = bg.g * 255 + const bgB = bg.b * 255 + const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB + + for (let i = 1; i <= 12; i++) { + const factor = i / 12.0 + + if (isDark) { + if (luminance < 10) { + const gray = Math.floor(factor * 0.4 * 255) + grays[i] = RGBA.fromInts(gray, gray, gray) + } else { + const newLum = luminance + (255 - luminance) * factor * 0.4 + const ratio = newLum / luminance + grays[i] = RGBA.fromInts( + Math.min(bgR * ratio, 255), + Math.min(bgG * ratio, 255), + Math.min(bgB * ratio, 255) + ) + } + } else { + if (luminance > 245) { + const gray = Math.floor(255 - factor * 0.4 * 255) + grays[i] = RGBA.fromInts(gray, gray, gray) + } else { + const newLum = luminance * (1 - factor * 0.4) + const ratio = newLum / luminance + grays[i] = RGBA.fromInts( + Math.max(bgR * ratio, 0), + Math.max(bgG * ratio, 0), + Math.max(bgB * ratio, 0) + ) + } + } + } + + return grays +} + +export function generateMutedTextColor(bg: RGBA, isDark: boolean) { + const bgR = bg.r * 255 + const bgG = bg.g * 255 + const bgB = bg.b * 255 + const bgLum = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB + + if (isDark) { + if (bgLum < 10) return RGBA.fromInts(180, 180, 180) + const gray = Math.min(Math.floor(160 + bgLum * 0.3), 200) + return RGBA.fromInts(gray, gray, gray) + } + + if (bgLum > 245) return RGBA.fromInts(75, 75, 75) + const gray = Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60) + return RGBA.fromInts(gray, gray, gray) +} diff --git a/src/utils/custom-themes.ts b/src/utils/custom-themes.ts new file mode 100644 index 0000000..ee67acd --- /dev/null +++ b/src/utils/custom-themes.ts @@ -0,0 +1,23 @@ +import path from "path" +import type { ThemeJson } from "../types/theme-schema" +import { validateTheme } from "./theme-loader" + +export async function getCustomThemes() { + const dirs = [ + path.join(process.env.HOME ?? "", ".config/podtui/themes"), + path.resolve(process.cwd(), ".podtui/themes"), + path.resolve(process.cwd(), "themes"), + ] + + const result: Record = {} + for (const dir of dirs) { + const glob = new Bun.Glob("*.json") + for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) { + const name = path.basename(item, ".json") + const json = (await Bun.file(item).json()) as ThemeJson + validateTheme(json, item) + result[name] = json + } + } + return result +} diff --git a/src/utils/syntax-highlighter.ts b/src/utils/syntax-highlighter.ts new file mode 100644 index 0000000..fe15a43 --- /dev/null +++ b/src/utils/syntax-highlighter.ts @@ -0,0 +1,29 @@ +import { RGBA, SyntaxStyle } from "@opentui/core" +import { getSyntaxRules } from "./syntax-rules" + +export function generateSyntax(theme: Record) { + return SyntaxStyle.fromTheme(getSyntaxRules(theme)) +} + +export function generateSubtleSyntax(theme: Record & { thinkingOpacity?: number }) { + const rules = getSyntaxRules(theme) + const opacity = theme.thinkingOpacity ?? 0.6 + return SyntaxStyle.fromTheme( + rules.map((rule) => { + if (!rule.style.foreground) return rule + const fg = rule.style.foreground + return { + ...rule, + style: { + ...rule.style, + foreground: RGBA.fromInts( + Math.round(fg.r * 255), + Math.round(fg.g * 255), + Math.round(fg.b * 255), + Math.round(opacity * 255) + ), + }, + } + }) + ) +} diff --git a/src/utils/syntax-rules.ts b/src/utils/syntax-rules.ts new file mode 100644 index 0000000..6f94e75 --- /dev/null +++ b/src/utils/syntax-rules.ts @@ -0,0 +1,40 @@ +import type { RGBA } from "@opentui/core" + +export type SyntaxRule = { + scope: string[] + style: { + foreground?: RGBA + background?: RGBA + bold?: boolean + italic?: boolean + underline?: boolean + } +} + +export function getSyntaxRules(theme: Record): SyntaxRule[] { + return [ + { scope: ["default"], style: { foreground: theme.text } }, + { scope: ["comment", "comment.documentation"], style: { foreground: theme.syntaxComment, italic: true } }, + { scope: ["string", "symbol", "character.special"], style: { foreground: theme.syntaxString } }, + { scope: ["number", "boolean", "constant"], style: { foreground: theme.syntaxNumber } }, + { scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], style: { foreground: theme.syntaxKeyword, italic: true } }, + { scope: ["keyword.type", "type.definition", "class"], style: { foreground: theme.syntaxType, bold: true } }, + { scope: ["keyword.function", "function", "function.method"], style: { foreground: theme.syntaxFunction } }, + { scope: ["keyword", "keyword.import", "keyword.export"], style: { foreground: theme.syntaxKeyword, italic: true } }, + { scope: ["operator", "keyword.operator", "punctuation", "punctuation.delimiter"], style: { foreground: theme.syntaxOperator } }, + { scope: ["variable", "variable.parameter", "property"], style: { foreground: theme.syntaxVariable } }, + { scope: ["type", "module", "namespace"], style: { foreground: theme.syntaxType } }, + { scope: ["punctuation.bracket"], style: { foreground: theme.syntaxPunctuation } }, + { scope: ["markup.heading", "markup.heading.1", "markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"], style: { foreground: theme.markdownHeading, bold: true } }, + { scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } }, + { scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } }, + { scope: ["markup.list"], style: { foreground: theme.markdownListItem } }, + { scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } }, + { scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode } }, + { scope: ["markup.link", "markup.link.url", "string.special.url"], style: { foreground: theme.markdownLink, underline: true } }, + { scope: ["markup.link.label", "label"], style: { foreground: theme.markdownLinkText, underline: true } }, + { scope: ["diff.plus"], style: { foreground: theme.diffAdded, background: theme.diffAddedBg } }, + { scope: ["diff.minus"], style: { foreground: theme.diffRemoved, background: theme.diffRemovedBg } }, + { scope: ["diff.delta"], style: { foreground: theme.diffContext, background: theme.diffContextBg } }, + ] +} diff --git a/src/utils/system-theme.ts b/src/utils/system-theme.ts new file mode 100644 index 0000000..2a0f06d --- /dev/null +++ b/src/utils/system-theme.ts @@ -0,0 +1,110 @@ +import { RGBA, type TerminalColors } from "@opentui/core" +import { ansiToRgba } from "./ansi-to-rgba" +import { generateGrayScale, generateMutedTextColor, tint } from "./color-generation" +import type { ThemeJson } from "../types/theme-schema" + +let cached: TerminalColors | null = null + +export function clearPaletteCache() { + cached = null +} + +export function detectSystemTheme(colors: TerminalColors) { + const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0] ?? "#000000") + const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b + const mode = luminance > 0.5 ? "light" : "dark" + return { mode, background: bg } +} + +export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { + cached = colors + const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0] ?? "#000000") + const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7] ?? "#ffffff") + const transparent = RGBA.fromInts(0, 0, 0, 0) + const isDark = mode === "dark" + + const col = (i: number) => { + const value = colors.palette[i] + if (value) return RGBA.fromHex(value) + return ansiToRgba(i) + } + + const grays = generateGrayScale(bg, isDark) + const textMuted = generateMutedTextColor(bg, isDark) + + const ansi = { + black: col(0), + red: col(1), + green: col(2), + yellow: col(3), + blue: col(4), + magenta: col(5), + cyan: col(6), + white: col(7), + redBright: col(9), + greenBright: col(10), + } + + const diffAlpha = isDark ? 0.22 : 0.14 + const diffAddedBg = tint(bg, ansi.green, diffAlpha) + const diffRemovedBg = tint(bg, ansi.red, diffAlpha) + const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha) + const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha) + + return { + theme: { + primary: ansi.cyan, + secondary: ansi.magenta, + accent: ansi.cyan, + error: ansi.red, + warning: ansi.yellow, + success: ansi.green, + info: ansi.cyan, + text: fg, + textMuted, + selectedListItemText: bg, + background: transparent, + backgroundPanel: grays[2], + backgroundElement: grays[3], + backgroundMenu: grays[3], + borderSubtle: grays[6], + border: grays[7], + borderActive: grays[8], + diffAdded: ansi.green, + diffRemoved: ansi.red, + diffContext: grays[7], + diffHunkHeader: grays[7], + diffHighlightAdded: ansi.greenBright, + diffHighlightRemoved: ansi.redBright, + diffAddedBg, + diffRemovedBg, + diffContextBg: grays[1], + diffLineNumber: grays[6], + diffAddedLineNumberBg, + diffRemovedLineNumberBg, + markdownText: fg, + markdownHeading: fg, + markdownLink: ansi.blue, + markdownLinkText: ansi.cyan, + markdownCode: ansi.green, + markdownBlockQuote: ansi.yellow, + markdownEmph: ansi.yellow, + markdownStrong: fg, + markdownHorizontalRule: grays[7], + markdownListItem: ansi.blue, + markdownListEnumeration: ansi.cyan, + markdownImage: ansi.blue, + markdownImageText: ansi.cyan, + markdownCodeBlock: fg, + syntaxComment: textMuted, + syntaxKeyword: ansi.magenta, + syntaxFunction: ansi.blue, + syntaxVariable: fg, + syntaxString: ansi.green, + syntaxNumber: ansi.yellow, + syntaxType: ansi.cyan, + syntaxOperator: ansi.cyan, + syntaxPunctuation: fg, + }, + } +} diff --git a/src/utils/theme-css.ts b/src/utils/theme-css.ts new file mode 100644 index 0000000..72b2e16 --- /dev/null +++ b/src/utils/theme-css.ts @@ -0,0 +1,36 @@ +import { RGBA } from "@opentui/core" +import type { ColorValue } from "../types/theme-schema" + +const toCss = (value: ColorValue | RGBA) => { + if (value instanceof RGBA) { + const r = Math.round(value.r * 255) + const g = Math.round(value.g * 255) + const b = Math.round(value.b * 255) + return `rgba(${r}, ${g}, ${b}, ${value.a})` + } + if (typeof value === "number") return `var(--ansi-${value})` + if (typeof value === "string") return value + return value.dark +} + +export function applyThemeToCSS(theme: Record) { + const root = document.documentElement + for (const [key, value] of Object.entries(theme)) { + if (key === "layerBackgrounds" && typeof value === "object") { + const layers = value as Record + for (const [layer, color] of Object.entries(layers)) { + root.style.setProperty(`--color-${layer}`, toCss(color)) + } + } else { + root.style.setProperty(`--color-${key}`, toCss(value as ColorValue | RGBA)) + } + } +} + +export function setThemeAttribute(themeName: string) { + document.documentElement.setAttribute("data-theme", themeName) +} + +export function resolveColorReference(value: ColorValue) { + return toCss(value) +} diff --git a/src/utils/theme-loader.ts b/src/utils/theme-loader.ts new file mode 100644 index 0000000..a95c0d8 --- /dev/null +++ b/src/utils/theme-loader.ts @@ -0,0 +1,49 @@ +import path from "path" +import type { ThemeJson } from "../types/theme-schema" +import { THEME_JSON } from "../constants/themes" + +export async function loadTheme(name: string) { + if (THEME_JSON[name]) return THEME_JSON[name] + const file = path.resolve(process.cwd(), "themes", `${name}.json`) + return loadThemeFromPath(file) +} + +export async function loadThemeFromPath(file: string) { + const json = (await Bun.file(file).json()) as ThemeJson + validateTheme(json, file) + return json +} + +export async function getAllThemes() { + return { ...THEME_JSON, ...(await getCustomThemes()) } +} + +export async function getCustomThemes() { + const dirs = [ + path.join(process.env.HOME ?? "", ".config/podtui/themes"), + path.resolve(process.cwd(), ".podtui/themes"), + path.resolve(process.cwd(), "themes"), + ] + + const result: Record = {} + for (const dir of dirs) { + const glob = new Bun.Glob("*.json") + for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) { + const name = path.basename(item, ".json") + const json = (await Bun.file(item).json()) as ThemeJson + validateTheme(json, item) + result[name] = json + } + } + return result +} + +export function validateTheme(theme: ThemeJson, source?: string) { + if (!theme || typeof theme !== "object") { + throw new Error(`Invalid theme${source ? ` (${source})` : ""}`) + } + if (!theme.theme || typeof theme.theme !== "object") { + throw new Error(`Theme missing 'theme' object${source ? ` (${source})` : ""}`) + } + return true +} diff --git a/src/utils/theme-resolver.ts b/src/utils/theme-resolver.ts new file mode 100644 index 0000000..7389120 --- /dev/null +++ b/src/utils/theme-resolver.ts @@ -0,0 +1,46 @@ +import { RGBA } from "@opentui/core" +import type { ColorValue, ThemeJson } from "../types/theme-schema" +import { ansiToRgba } from "./ansi-to-rgba" + +export type ThemeMode = "dark" | "light" + +export function resolveTheme(theme: ThemeJson, mode: ThemeMode) { + const defs = theme.defs ?? {} + + function resolveColor(value: ColorValue): RGBA { + if (value instanceof RGBA) return value + if (typeof value === "number") return ansiToRgba(value) + if (typeof value === "string") { + if (value === "transparent" || value === "none") return RGBA.fromInts(0, 0, 0, 0) + if (value.startsWith("#")) return RGBA.fromHex(value) + if (defs[value] != null) return resolveColor(defs[value]) + const ref = theme.theme[value] + if (ref != null) return resolveColor(ref) + throw new Error(`Color reference "${value}" not found in defs or theme`) + } + return resolveColor(value[mode]) + } + + const resolved = Object.fromEntries( + Object.entries(theme.theme) + .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity") + .map(([key, value]) => [key, resolveColor(value)]) + ) as Record + + const hasSelected = theme.theme.selectedListItemText !== undefined + resolved.selectedListItemText = hasSelected + ? resolveColor(theme.theme.selectedListItemText!) + : resolved.background + + resolved.backgroundMenu = theme.theme.backgroundMenu + ? resolveColor(theme.theme.backgroundMenu) + : resolved.backgroundElement + + const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 + + return { + ...resolved, + _hasSelectedListItemText: hasSelected, + thinkingOpacity, + } +} diff --git a/src/utils/theme.test.ts b/src/utils/theme.test.ts new file mode 100644 index 0000000..0704755 --- /dev/null +++ b/src/utils/theme.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test" +import { ansiToRgba } from "./ansi-to-rgba" +import { resolveTheme } from "./theme-resolver" +import type { ThemeJson } from "../types/theme-schema" + +describe("theme utils", () => { + it("converts ansi codes", () => { + const color = ansiToRgba(1) + expect(color).toBeTruthy() + }) + + it("resolves simple theme", () => { + const json: ThemeJson = { + theme: { + primary: "#ffffff", + secondary: "#000000", + accent: "#000000", + error: "#000000", + warning: "#000000", + success: "#000000", + info: "#000000", + text: "#000000", + textMuted: "#000000", + background: "#000000", + backgroundPanel: "#000000", + backgroundElement: "#000000", + border: "#000000", + borderActive: "#000000", + borderSubtle: "#000000", + diffAdded: "#000000", + diffRemoved: "#000000", + diffContext: "#000000", + diffHunkHeader: "#000000", + diffHighlightAdded: "#000000", + diffHighlightRemoved: "#000000", + diffAddedBg: "#000000", + diffRemovedBg: "#000000", + diffContextBg: "#000000", + diffLineNumber: "#000000", + diffAddedLineNumberBg: "#000000", + diffRemovedLineNumberBg: "#000000", + markdownText: "#000000", + markdownHeading: "#000000", + markdownLink: "#000000", + markdownLinkText: "#000000", + markdownCode: "#000000", + markdownBlockQuote: "#000000", + markdownEmph: "#000000", + markdownStrong: "#000000", + markdownHorizontalRule: "#000000", + markdownListItem: "#000000", + markdownListEnumeration: "#000000", + markdownImage: "#000000", + markdownImageText: "#000000", + markdownCodeBlock: "#000000", + syntaxComment: "#000000", + syntaxKeyword: "#000000", + syntaxFunction: "#000000", + syntaxVariable: "#000000", + syntaxString: "#000000", + syntaxNumber: "#000000", + syntaxType: "#000000", + syntaxOperator: "#000000", + syntaxPunctuation: "#000000", + }, + } + + const resolved = resolveTheme(json, "dark") as unknown as { primary: unknown } + expect(resolved.primary).toBeTruthy() + }) +}) diff --git a/src/utils/theme.ts b/src/utils/theme.ts index 2ce9ff8..bc34b2e 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -3,44 +3,42 @@ * Handles dynamic theme switching by updating CSS custom properties */ -export function applyTheme(theme: { - background: string - surface: string - primary: string - secondary: string - accent: string - text: string - muted: string - warning: string - error: string - success: string - layerBackgrounds?: { - layer0: string - layer1: string - layer2: string - layer3: string +import { RGBA } from "@opentui/core" +import type { ThemeColors } from "../types/settings" +import type { ColorValue } from "../types/theme-schema" + +const toCss = (value: ColorValue | RGBA) => { + if (value instanceof RGBA) { + const r = Math.round(value.r * 255) + const g = Math.round(value.g * 255) + const b = Math.round(value.b * 255) + return `rgba(${r}, ${g}, ${b}, ${value.a})` } -}) { + if (typeof value === "number") return `var(--ansi-${value})` + if (typeof value === "string") return value + return value.dark +} + +export function applyTheme(theme: ThemeColors | Record) { + if (typeof document === "undefined") return const root = document.documentElement + root.style.setProperty("--color-background", toCss(theme.background as ColorValue)) + root.style.setProperty("--color-surface", toCss(theme.surface as ColorValue)) + root.style.setProperty("--color-primary", toCss(theme.primary as ColorValue)) + root.style.setProperty("--color-secondary", toCss(theme.secondary as ColorValue)) + root.style.setProperty("--color-accent", toCss(theme.accent as ColorValue)) + root.style.setProperty("--color-text", toCss(theme.text as ColorValue)) + root.style.setProperty("--color-muted", toCss(theme.muted as ColorValue)) + root.style.setProperty("--color-warning", toCss(theme.warning as ColorValue)) + root.style.setProperty("--color-error", toCss(theme.error as ColorValue)) + root.style.setProperty("--color-success", toCss(theme.success as ColorValue)) - // Apply base theme colors - root.style.setProperty("--color-background", theme.background) - root.style.setProperty("--color-surface", theme.surface) - root.style.setProperty("--color-primary", theme.primary) - root.style.setProperty("--color-secondary", theme.secondary) - root.style.setProperty("--color-accent", theme.accent) - root.style.setProperty("--color-text", theme.text) - root.style.setProperty("--color-muted", theme.muted) - root.style.setProperty("--color-warning", theme.warning) - root.style.setProperty("--color-error", theme.error) - root.style.setProperty("--color-success", theme.success) - - // Apply layer backgrounds if available - if (theme.layerBackgrounds) { - root.style.setProperty("--color-layer0", theme.layerBackgrounds.layer0) - root.style.setProperty("--color-layer1", theme.layerBackgrounds.layer1) - root.style.setProperty("--color-layer2", theme.layerBackgrounds.layer2) - root.style.setProperty("--color-layer3", theme.layerBackgrounds.layer3) + const layers = theme.layerBackgrounds as Record | undefined + if (layers) { + root.style.setProperty("--color-layer0", toCss(layers.layer0)) + root.style.setProperty("--color-layer1", toCss(layers.layer1)) + root.style.setProperty("--color-layer2", toCss(layers.layer2)) + root.style.setProperty("--color-layer3", toCss(layers.layer3)) } } @@ -58,6 +56,7 @@ export function getSystemThemeMode(): "dark" | "light" { * Apply CSS variable data-theme attribute */ export function setThemeAttribute(themeName: string) { + if (typeof document === "undefined") return const root = document.documentElement root.setAttribute("data-theme", themeName) }