theme redux

This commit is contained in:
2026-02-05 01:07:22 -05:00
parent 6950deaa88
commit ea9ab4d3f9
25 changed files with 1339 additions and 131 deletions

41
src/utils/ansi-to-rgba.ts Normal file
View 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)
}

View 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)
}

View 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
}

View 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
View 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
View 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
View 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
View 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
}

View 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
View 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()
})
})

View File

@@ -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)
}