diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 836277261c8d..85a8501a4f1e 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -16,7 +16,8 @@ import { type Theme, type ThemeJson, } from "../theme" -import { generateSystem, terminalMode } from "../theme/system" +import { generateSystem, generateSystemV2, terminalMode } from "../theme/system" +import type { ThemeFile } from "../theme/v2" import { createComponentTheme, type ComponentTheme } from "../theme/v2/component" import { resolveThemeFile } from "../theme/v2/resolve" import { migrateV1 } from "../theme/v2/v1-migrate" @@ -78,6 +79,7 @@ const THEME_REFRESH_DELAYS = [250, 1000] as const type State = { themes: Record + systemV2: ThemeFile | undefined mode: "dark" | "light" lock: "dark" | "light" | undefined active: string @@ -104,6 +106,7 @@ type ThemeService = { const [store, setStore] = createStore({ themes: allThemes(), + systemV2: undefined, mode: "dark", lock: undefined, active: "opencode", @@ -181,6 +184,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ .then((colors: TerminalColors) => { if (!colors.palette[0]) { if (hasResolvedSystemTheme) return + setStore("systemV2", undefined) setSystemTheme(undefined) if (store.active === "system") setStore("active", "opencode") return @@ -192,10 +196,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (store.themes.system && systemThemeSignature === signature && systemThemeMode === next) return systemThemeSignature = signature systemThemeMode = next + setStore("systemV2", generateSystemV2(colors)) setSystemTheme(generateSystem(colors, next)) }) .catch(() => { if (hasResolvedSystemTheme) return + setStore("systemV2", undefined) setSystemTheme(undefined) if (store.active === "system") setStore("active", "opencode") }) @@ -290,7 +296,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const values = createMemo(() => resolveTheme(source(), store.mode)) const valuesV2 = createMemo(() => { const started = performance.now() - const file = migrateV1(source()) + const file = sourceName() === "system" && store.systemV2 ? store.systemV2 : migrateV1(source()) themePerformance.set("Convert V1 to V2", duration(performance.now() - started)) const resolveStarted = performance.now() const result = resolveThemeFile(file, store.mode, sourceName()) diff --git a/packages/tui/src/theme/system.ts b/packages/tui/src/theme/system.ts index 4c584b7a59f1..659a34be286b 100644 --- a/packages/tui/src/theme/system.ts +++ b/packages/tui/src/theme/system.ts @@ -1,5 +1,6 @@ import { RGBA, type TerminalColors } from "@opentui/core" import { ansiToRgba, tint } from "./color" +import { HueStep, type Mode, type ThemeFile } from "./v2" export function terminalMode(colors: TerminalColors): "dark" | "light" | undefined { const bg = colors.defaultBackground @@ -9,6 +10,97 @@ export function terminalMode(colors: TerminalColors): "dark" | "light" | undefin } export function generateSystem(colors: TerminalColors, mode: "dark" | "light") { + const value = resolveSystem(colors, mode) + + return { + theme: { + primary: value.ansi.cyan, + secondary: value.ansi.magenta, + accent: value.ansi.cyan, + error: value.ansi.red, + warning: value.ansi.yellow, + success: value.ansi.green, + info: value.ansi.cyan, + text: value.fg, + textMuted: value.textMuted, + selectedListItemText: value.bg, + background: value.transparent, + backgroundPanel: value.grays[2], + backgroundElement: value.grays[3], + backgroundMenu: value.grays[3], + borderSubtle: value.grays[6], + border: value.grays[7], + borderActive: value.grays[8], + diffAdded: value.ansi.green, + diffRemoved: value.ansi.red, + diffContext: value.grays[7], + diffHunkHeader: value.grays[7], + diffHighlightAdded: value.ansi.greenBright, + diffHighlightRemoved: value.ansi.redBright, + diffAddedBg: value.diffAddedBg, + diffRemovedBg: value.diffRemovedBg, + diffContextBg: value.diffContextBg, + diffLineNumber: value.textMuted, + diffAddedLineNumberBg: value.diffAddedLineNumberBg, + diffRemovedLineNumberBg: value.diffRemovedLineNumberBg, + markdownText: value.fg, + markdownHeading: value.fg, + markdownLink: value.ansi.blue, + markdownLinkText: value.ansi.cyan, + markdownCode: value.ansi.green, + markdownBlockQuote: value.ansi.yellow, + markdownEmph: value.ansi.yellow, + markdownStrong: value.fg, + markdownHorizontalRule: value.grays[7], + markdownListItem: value.ansi.blue, + markdownListEnumeration: value.ansi.cyan, + markdownImage: value.ansi.blue, + markdownImageText: value.ansi.cyan, + markdownCodeBlock: value.fg, + syntaxComment: value.textMuted, + syntaxKeyword: value.ansi.magenta, + syntaxFunction: value.ansi.blue, + syntaxVariable: value.fg, + syntaxString: value.ansi.green, + syntaxNumber: value.ansi.yellow, + syntaxType: value.ansi.cyan, + syntaxOperator: value.ansi.cyan, + syntaxPunctuation: value.fg, + }, + } +} + +export function generateSystemV2(colors: TerminalColors): ThemeFile { + return { + version: 2, + light: generateSystemMode(colors, "light"), + dark: generateSystemMode(colors, "dark"), + } +} + +function generateSystemMode(colors: TerminalColors, mode: Mode): ThemeFile["light"] { + const value = resolveSystem(colors, mode) + const scale = (color: RGBA) => + Object.fromEntries(HueStep.literals.map((step) => [step, hex(color)])) as Record + + return { + hue: { + gray: neutralScale(value, mode), + red: scale(value.ansi.red), + orange: scale(value.ansi.yellow), + yellow: scale(value.ansi.yellow), + green: scale(value.ansi.green), + cyan: scale(value.ansi.cyan), + blue: scale(value.ansi.blue), + purple: scale(value.ansi.magenta), + accent: scale(value.ansi.cyan), + interactive: scale(value.ansi.cyan), + neutral: "$hue.gray", + }, + } +} + +function resolveSystem(colors: TerminalColors, mode: Mode) { const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0) @@ -22,7 +114,7 @@ export function generateSystem(colors: TerminalColors, mode: "dark" | "light") { const grays = generateGrayScale(bg, isDark) const textMuted = generateMutedTextColor(bg, isDark) - const ansiColors = { + const ansi = { red: col(1), green: col(2), yellow: col(3), @@ -34,67 +126,24 @@ export function generateSystem(colors: TerminalColors, mode: "dark" | "light") { } const diffAlpha = isDark ? 0.22 : 0.14 - const diffAddedBg = tint(bg, ansiColors.green, diffAlpha) - const diffRemovedBg = tint(bg, ansiColors.red, diffAlpha) + const diffAddedBg = tint(bg, ansi.green, diffAlpha) + const diffRemovedBg = tint(bg, ansi.red, diffAlpha) const diffContextBg = grays[2] - const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha) - const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.red, diffAlpha) + const diffAddedLineNumberBg = tint(diffContextBg, ansi.green, diffAlpha) + const diffRemovedLineNumberBg = tint(diffContextBg, ansi.red, diffAlpha) return { - theme: { - primary: ansiColors.cyan, - secondary: ansiColors.magenta, - accent: ansiColors.cyan, - error: ansiColors.red, - warning: ansiColors.yellow, - success: ansiColors.green, - info: ansiColors.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: ansiColors.green, - diffRemoved: ansiColors.red, - diffContext: grays[7], - diffHunkHeader: grays[7], - diffHighlightAdded: ansiColors.greenBright, - diffHighlightRemoved: ansiColors.redBright, - diffAddedBg, - diffRemovedBg, - diffContextBg, - diffLineNumber: textMuted, - diffAddedLineNumberBg, - diffRemovedLineNumberBg, - markdownText: fg, - markdownHeading: fg, - markdownLink: ansiColors.blue, - markdownLinkText: ansiColors.cyan, - markdownCode: ansiColors.green, - markdownBlockQuote: ansiColors.yellow, - markdownEmph: ansiColors.yellow, - markdownStrong: fg, - markdownHorizontalRule: grays[7], - markdownListItem: ansiColors.blue, - markdownListEnumeration: ansiColors.cyan, - markdownImage: ansiColors.blue, - markdownImageText: ansiColors.cyan, - markdownCodeBlock: fg, - syntaxComment: textMuted, - syntaxKeyword: ansiColors.magenta, - syntaxFunction: ansiColors.blue, - syntaxVariable: fg, - syntaxString: ansiColors.green, - syntaxNumber: ansiColors.yellow, - syntaxType: ansiColors.cyan, - syntaxOperator: ansiColors.cyan, - syntaxPunctuation: fg, - }, + bg, + fg, + transparent, + grays, + textMuted, + ansi, + diffAddedBg, + diffRemovedBg, + diffContextBg, + diffAddedLineNumberBg, + diffRemovedLineNumberBg, } } @@ -142,3 +191,41 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA { const gray = luminance > 245 ? 75 : Math.max(Math.floor(100 - (255 - luminance) * 0.2), 60) return RGBA.fromInts(gray, gray, gray) } + +function neutralScale(value: ReturnType, mode: Mode) { + const light: { step: HueStep; color: RGBA }[] = [ + { step: 100, color: value.transparent }, + { step: 200, color: value.grays[2] }, + { step: 300, color: value.grays[3] }, + { step: 700, color: value.textMuted }, + { step: 900, color: value.fg }, + ] + const anchors = + mode === "light" + ? light + : light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep })) + return Object.fromEntries( + HueStep.literals.map((step) => { + const exact = anchors.find((anchor) => anchor.step === step) + if (exact) return [step, hex(exact.color)] + const lower = anchors.filter((anchor) => anchor.step < step).at(-1)! + const upper = anchors.find((anchor) => anchor.step > step)! + return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))] + }), + ) as Record +} + +function interpolate(first: RGBA, second: RGBA, amount: number) { + const start = first.toInts() + const end = second.toInts() + return `#${start.map((value, index) => byte(Math.round(value + (end[index]! - value) * amount))).join("")}` +} + +function hex(color: RGBA) { + const [red, green, blue, alpha] = color.toInts() + return `#${byte(red)}${byte(green)}${byte(blue)}${alpha === 255 ? "" : byte(alpha)}` +} + +function byte(value: number) { + return value.toString(16).padStart(2, "0") +} diff --git a/packages/tui/test/theme/system.test.ts b/packages/tui/test/theme/system.test.ts new file mode 100644 index 000000000000..4c79cd1d9599 --- /dev/null +++ b/packages/tui/test/theme/system.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test" +import type { TerminalColors } from "@opentui/core" +import { generateSystemV2 } from "../../src/theme/system" +import { resolveThemeFile } from "../../src/theme/v2/resolve" + +const colors: TerminalColors = { + palette: ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#a9b1d6"], + defaultForeground: "#c0caf5", + defaultBackground: "#1a1b26", + cursorColor: null, + mouseForeground: null, + mouseBackground: null, + tekForeground: null, + tekBackground: null, + highlightBackground: null, + highlightForeground: null, +} + +test("generates literal V2 hue scales from the system palette", () => { + const resolved = resolveThemeFile(generateSystemV2(colors), "dark") + + for (const step of [100, 300, 500, 700, 900] as const) { + expect(resolved.hue.red[step].toInts()).toEqual([247, 118, 142, 255]) + expect(resolved.hue.orange[step].toInts()).toEqual([224, 175, 104, 255]) + expect(resolved.hue.yellow[step].toInts()).toEqual([224, 175, 104, 255]) + expect(resolved.hue.green[step].toInts()).toEqual([158, 206, 106, 255]) + expect(resolved.hue.cyan[step].toInts()).toEqual([125, 207, 255, 255]) + expect(resolved.hue.blue[step].toInts()).toEqual([122, 162, 247, 255]) + expect(resolved.hue.purple[step].toInts()).toEqual([187, 154, 247, 255]) + expect(resolved.hue.accent[step].toInts()).toEqual([125, 207, 255, 255]) + expect(resolved.hue.interactive[step].toInts()).toEqual([125, 207, 255, 255]) + } + + expect(resolved.text.default.toInts()).toEqual([192, 202, 245, 255]) + expect(resolved.background.default.toInts()).toEqual([26, 27, 38, 0]) + expect(resolved.background.action.primary.default.toInts()).toEqual([125, 207, 255, 255]) +})