theme redux
This commit is contained in:
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