theme redux
This commit is contained in:
@@ -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<number, LayerConfig> = {
|
||||
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 ? (
|
||||
<box
|
||||
style={{
|
||||
height: 4,
|
||||
backgroundColor: theme?.surface,
|
||||
backgroundColor: toColor(theme?.surface),
|
||||
}}
|
||||
>
|
||||
<box style={{ padding: 1 }}>
|
||||
@@ -80,7 +83,7 @@ export function Layout(props: LayoutProps) {
|
||||
<box
|
||||
style={{
|
||||
height: 2,
|
||||
backgroundColor: theme?.surface,
|
||||
backgroundColor: toColor(theme?.surface),
|
||||
}}
|
||||
>
|
||||
<box style={{ padding: 1 }}>
|
||||
@@ -96,7 +99,7 @@ export function Layout(props: LayoutProps) {
|
||||
<box
|
||||
style={{
|
||||
height: 1,
|
||||
backgroundColor: theme?.surface,
|
||||
backgroundColor: toColor(theme?.surface),
|
||||
}}
|
||||
>
|
||||
<box style={{ padding: 1 }}>
|
||||
|
||||
@@ -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<ThemeName, ThemeColors> = {
|
||||
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<string, ThemeDefinition> = {
|
||||
opencode: opencode as ThemeDefinition,
|
||||
catppuccin: catppuccin as ThemeDefinition,
|
||||
gruvbox: gruvbox as ThemeDefinition,
|
||||
tokyo: tokyo as ThemeDefinition,
|
||||
nord: nord as ThemeDefinition,
|
||||
}
|
||||
|
||||
8
src/context/ThemeContext.test.ts
Normal file
8
src/context/ThemeContext.test.ts
Normal file
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
selected: () => string
|
||||
all: () => Record<string, ThemeJson>
|
||||
syntax: () => unknown
|
||||
subtleSyntax: () => unknown
|
||||
mode: () => "dark" | "light"
|
||||
setMode: (mode: "dark" | "light") => void
|
||||
set: (theme: string) => void
|
||||
ready: () => boolean
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>()
|
||||
const ThemeContext = createContext<ThemeContextValue>()
|
||||
|
||||
export function ThemeProvider({ children }: { children: any }) {
|
||||
const appStore = useAppStore()
|
||||
const [themeName, setThemeName] = createSignal<ThemeName>(appStore.state().settings.theme)
|
||||
const [resolvedTheme, setResolvedTheme] = createSignal<ThemeColors>(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<string, ThemeJson>,
|
||||
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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ themeName, setThemeName, resolvedTheme, isSystemTheme, currentMode }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
})
|
||||
.finally(() => setReady(true))
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
createEffect(() => {
|
||||
setStore("active", appStore.state().settings.theme)
|
||||
setThemeAttribute(appStore.state().settings.theme)
|
||||
})
|
||||
|
||||
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<string, RGBA>))
|
||||
const subtleSyntax = createMemo(() =>
|
||||
generateSubtleSyntax(values() as unknown as Record<string, RGBA> & { 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 <ThemeContext.Provider value={context}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
74
src/themes/catppuccin.json
Normal file
74
src/themes/catppuccin.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
74
src/themes/gruvbox.json
Normal file
74
src/themes/gruvbox.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
74
src/themes/nord.json
Normal file
74
src/themes/nord.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
245
src/themes/opencode.json
Normal file
245
src/themes/opencode.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/themes/schema.json
Normal file
59
src/themes/schema.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
74
src/themes/tokyo.json
Normal file
74
src/themes/tokyo.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
@@ -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("#")
|
||||
}
|
||||
|
||||
@@ -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<string, RGBA> & {
|
||||
layerBackgrounds: Record<string, RGBA>
|
||||
_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
|
||||
|
||||
26
src/types/theme-schema.ts
Normal file
26
src/types/theme-schema.ts
Normal file
@@ -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<string, HexColor | RefName>
|
||||
theme: Record<string, ColorValue> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ThemeColors = Record<string, RGBA> & {
|
||||
_hasSelectedListItemText: boolean
|
||||
thinkingOpacity: number
|
||||
}
|
||||
41
src/utils/ansi-to-rgba.ts
Normal file
41
src/utils/ansi-to-rgba.ts
Normal file
@@ -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)
|
||||
}
|
||||
67
src/utils/color-generation.ts
Normal file
67
src/utils/color-generation.ts
Normal file
@@ -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<number, RGBA> = {}
|
||||
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)
|
||||
}
|
||||
23
src/utils/custom-themes.ts
Normal file
23
src/utils/custom-themes.ts
Normal file
@@ -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<string, ThemeJson> = {}
|
||||
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
|
||||
}
|
||||
29
src/utils/syntax-highlighter.ts
Normal file
29
src/utils/syntax-highlighter.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RGBA, SyntaxStyle } from "@opentui/core"
|
||||
import { getSyntaxRules } from "./syntax-rules"
|
||||
|
||||
export function generateSyntax(theme: Record<string, RGBA>) {
|
||||
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
|
||||
}
|
||||
|
||||
export function generateSubtleSyntax(theme: Record<string, RGBA> & { 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)
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
40
src/utils/syntax-rules.ts
Normal file
40
src/utils/syntax-rules.ts
Normal file
@@ -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<string, RGBA>): 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 } },
|
||||
]
|
||||
}
|
||||
110
src/utils/system-theme.ts
Normal file
110
src/utils/system-theme.ts
Normal file
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
36
src/utils/theme-css.ts
Normal file
36
src/utils/theme-css.ts
Normal file
@@ -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<string, RGBA | ColorValue>) {
|
||||
const root = document.documentElement
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
if (key === "layerBackgrounds" && typeof value === "object") {
|
||||
const layers = value as Record<string, RGBA | ColorValue>
|
||||
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)
|
||||
}
|
||||
49
src/utils/theme-loader.ts
Normal file
49
src/utils/theme-loader.ts
Normal file
@@ -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<string, ThemeJson> = {}
|
||||
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
|
||||
}
|
||||
46
src/utils/theme-resolver.ts
Normal file
46
src/utils/theme-resolver.ts
Normal file
@@ -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<string, RGBA>
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
71
src/utils/theme.test.ts
Normal file
71
src/utils/theme.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, RGBA>) {
|
||||
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<string, ColorValue> | 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user