Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/tui/src/context/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -78,6 +79,7 @@ const THEME_REFRESH_DELAYS = [250, 1000] as const

type State = {
themes: Record<string, ThemeJson>
systemV2: ThemeFile | undefined
mode: "dark" | "light"
lock: "dark" | "light" | undefined
active: string
Expand All @@ -104,6 +106,7 @@ type ThemeService = {

const [store, setStore] = createStore<State>({
themes: allThemes(),
systemV2: undefined,
mode: "dark",
lock: undefined,
active: "opencode",
Expand Down Expand Up @@ -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
Expand All @@ -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")
})
Expand Down Expand Up @@ -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())
Expand Down
205 changes: 146 additions & 59 deletions packages/tui/src/theme/system.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<HueStep, string>

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)
Expand All @@ -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),
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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<typeof resolveSystem>, 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<HueStep, string>
}

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")
}
37 changes: 37 additions & 0 deletions packages/tui/test/theme/system.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
Loading