462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
import { RGBA } from "@opentui/core"
|
|
import { oklchToHex, rgbToOklch } from "./color.js"
|
|
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
|
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js"
|
|
import { HueStep } from "./schema.js"
|
|
import type { Theme, ThemeV1Json } from "./v1.js"
|
|
|
|
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
|
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
|
|
type V1HueToken = "secondary" | "accent" | "success" | "warning" | "primary" | "error" | "info"
|
|
|
|
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
|
|
const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
|
|
const minimumChroma = 0.03
|
|
const lightThreshold = 0.6
|
|
|
|
export function migrateV1(theme: ThemeV1Json): ThemeDocument {
|
|
const light = resolveV1(theme, "light")
|
|
const dark = resolveV1(theme, "dark")
|
|
if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) {
|
|
const lightMode = detectMode(light)
|
|
const darkMode = detectMode(dark)
|
|
if (lightMode === darkMode) {
|
|
if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") }
|
|
return { version: 2, standalone: true, dark: migrateMode(dark, "dark") }
|
|
}
|
|
}
|
|
return {
|
|
version: 2,
|
|
standalone: true,
|
|
light: migrateMode(light, "light"),
|
|
dark: migrateMode(dark, "dark"),
|
|
}
|
|
}
|
|
|
|
function detectMode(theme: Theme): Mode {
|
|
return luminance(theme.text) > luminance(theme.background) ? "dark" : "light"
|
|
}
|
|
|
|
function luminance(color: RGBA) {
|
|
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
|
}
|
|
|
|
function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
|
const color = (key: ThemeColor) => hex(theme[key])
|
|
const selected = hex(selectedForeground(theme, theme.primary))
|
|
const destructive = hex(selectedForeground(theme, theme.error))
|
|
const hues = inferHues(theme, mode)
|
|
const categorical = categoricalTokens.flatMap((token) => {
|
|
const hue = hues.byToken[token]
|
|
return hue ? [hue] : []
|
|
})
|
|
const uniqueCategorical = categorical.filter((hue, index) => categorical.indexOf(hue) === index)
|
|
const text = mode === "light" ? "$hue.neutral.800" : "$hue.neutral.200"
|
|
const textMuted = mode === "light" ? "$hue.neutral.600" : "$hue.neutral.400"
|
|
const primary = mode === "light" ? "$hue.interactive.800" : "$hue.interactive.200"
|
|
const background = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
|
|
const backgroundPanel = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
|
|
const backgroundMenu = mode === "light" ? "$hue.neutral.400" : "$hue.neutral.600"
|
|
|
|
return referenceHues({
|
|
hue: {
|
|
gray: neutralScale(theme, mode),
|
|
...Object.fromEntries(
|
|
chromaticHues.map((name) => {
|
|
const match = hues.byHue[name]
|
|
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
|
|
}),
|
|
),
|
|
accent: hues.byToken.accent ? `$hue.${hues.byToken.accent}` : "$hue.gray",
|
|
interactive: hues.byToken.primary ? `$hue.${hues.byToken.primary}` : "$hue.gray",
|
|
neutral: "$hue.gray",
|
|
},
|
|
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
|
|
text: {
|
|
default: text,
|
|
subdued: textMuted,
|
|
action: {
|
|
primary: {
|
|
default: "$text.default",
|
|
$disabled: textMuted,
|
|
$focused: selected,
|
|
$selected: primary,
|
|
},
|
|
destructive: { default: destructive, $disabled: textMuted },
|
|
},
|
|
formfield: {
|
|
default: text,
|
|
$hovered: primary,
|
|
$focused: primary,
|
|
$pressed: primary,
|
|
$disabled: textMuted,
|
|
$selected: primary,
|
|
},
|
|
feedback: {
|
|
error: { default: color("error") },
|
|
warning: { default: color("warning") },
|
|
success: { default: color("success") },
|
|
info: { default: color("info") },
|
|
},
|
|
},
|
|
background: {
|
|
default: background,
|
|
surface: {
|
|
offset: backgroundPanel,
|
|
overlay: backgroundMenu,
|
|
},
|
|
action: {
|
|
primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: "transparent" },
|
|
destructive: { default: color("error") },
|
|
},
|
|
formfield: {
|
|
default: "$background.default",
|
|
},
|
|
feedback: {
|
|
error: { default: "$background.default" },
|
|
warning: { default: "$background.default" },
|
|
success: { default: "$background.default" },
|
|
info: { default: "$background.default" },
|
|
},
|
|
},
|
|
border: { default: color("border") },
|
|
scrollbar: { default: color("borderActive") },
|
|
diff: {
|
|
text: {
|
|
added: color("diffAdded"),
|
|
removed: color("diffRemoved"),
|
|
context: color("diffContext"),
|
|
hunkHeader: color("diffHunkHeader"),
|
|
},
|
|
background: {
|
|
added: color("diffAddedBg"),
|
|
removed: color("diffRemovedBg"),
|
|
context: color("diffContextBg"),
|
|
},
|
|
highlight: { added: color("diffHighlightAdded"), removed: color("diffHighlightRemoved") },
|
|
lineNumber: {
|
|
text: color("diffLineNumber"),
|
|
background: {
|
|
added: color("diffAddedLineNumberBg"),
|
|
removed: color("diffRemovedLineNumberBg"),
|
|
},
|
|
},
|
|
},
|
|
syntax: {
|
|
comment: color("syntaxComment"),
|
|
keyword: color("syntaxKeyword"),
|
|
function: color("syntaxFunction"),
|
|
variable: color("syntaxVariable"),
|
|
string: color("syntaxString"),
|
|
number: color("syntaxNumber"),
|
|
type: color("syntaxType"),
|
|
operator: color("syntaxOperator"),
|
|
punctuation: color("syntaxPunctuation"),
|
|
},
|
|
markdown: {
|
|
text: color("markdownText"),
|
|
heading: color("markdownHeading"),
|
|
link: color("markdownLink"),
|
|
linkText: color("markdownLinkText"),
|
|
code: color("markdownCode"),
|
|
blockQuote: color("markdownBlockQuote"),
|
|
emphasis: color("markdownEmph"),
|
|
strong: color("markdownStrong"),
|
|
horizontalRule: color("markdownHorizontalRule"),
|
|
listItem: color("markdownListItem"),
|
|
listEnumeration: color("markdownListEnumeration"),
|
|
image: color("markdownImage"),
|
|
imageText: color("markdownImageText"),
|
|
codeBlock: color("markdownCodeBlock"),
|
|
},
|
|
"@context:elevated": {
|
|
background: {
|
|
default: "$background.surface.offset",
|
|
action: { primary: { $hovered: "$background.surface.overlay" } },
|
|
},
|
|
},
|
|
"@context:overlay": { background: { default: "$background.surface.overlay" } },
|
|
})
|
|
}
|
|
|
|
function referenceHues(theme: FileThemeDefinition): FileThemeDefinition {
|
|
const definitions = theme.hue as Record<string, string | Partial<Record<HueStep, string>>> | undefined
|
|
if (!definitions) return theme
|
|
const scales = new Map<string, Partial<Record<HueStep, string>>>()
|
|
|
|
function resolve(name: string, chain: string[] = []): Partial<Record<HueStep, string>> | undefined {
|
|
const cached = scales.get(name)
|
|
if (cached) return cached
|
|
if (chain.includes(name)) return
|
|
const value = definitions?.[name]
|
|
if (!value) return
|
|
if (typeof value !== "string") {
|
|
scales.set(name, value)
|
|
return value
|
|
}
|
|
const target = /^\$hue\.([^.]+)$/.exec(value)?.[1]
|
|
if (!target) return
|
|
const scale = resolve(target, [...chain, name])
|
|
if (scale) scales.set(name, scale)
|
|
return scale
|
|
}
|
|
|
|
const references = new Map<string, string>()
|
|
const index = (name: string, overwrite: boolean) => {
|
|
const scale = resolve(name)
|
|
if (!scale) return
|
|
HueStep.literals.forEach((step) => {
|
|
const color = scale[step]
|
|
if (!color || (!overwrite && references.has(color.toLowerCase()))) return
|
|
references.set(color.toLowerCase(), `$hue.${name}.${step}`)
|
|
})
|
|
}
|
|
chromaticHues.forEach((name) => index(name, false))
|
|
index("gray", false)
|
|
index("accent", true)
|
|
index("interactive", true)
|
|
index("neutral", true)
|
|
|
|
function replace(value: unknown): unknown {
|
|
if (typeof value === "string") return references.get(value.toLowerCase()) ?? value
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replace(item)]))
|
|
}
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(theme).map(([key, value]) => [key, key === "hue" || key === "categorical" ? value : replace(value)]),
|
|
) as FileThemeDefinition
|
|
}
|
|
|
|
function inferHues(theme: Theme, mode: "light" | "dark") {
|
|
const colors: readonly [V1HueToken, RGBA][] = [
|
|
["accent", theme.accent],
|
|
["success", theme.success],
|
|
["warning", theme.warning],
|
|
["primary", theme.primary],
|
|
["error", theme.error],
|
|
["info", theme.info],
|
|
["secondary", theme.secondary],
|
|
]
|
|
const inferred = colors.reduce<{
|
|
byHue: Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
|
|
byToken: Partial<Record<V1HueToken, ChromaticHue>>
|
|
}>(
|
|
(result, [token, color]) => {
|
|
const nearest = inferHue(color, mode)
|
|
if (!nearest) return result
|
|
const current = result.byHue[nearest.name]
|
|
return {
|
|
byHue:
|
|
current && current.distance <= nearest.distance
|
|
? result.byHue
|
|
: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
|
|
byToken: { ...result.byToken, [token]: nearest.name },
|
|
}
|
|
},
|
|
{ byHue: {}, byToken: {} },
|
|
)
|
|
return (
|
|
[
|
|
["accent", theme.accent],
|
|
["primary", theme.primary],
|
|
] as const
|
|
).reduce((result, [token, color]) => {
|
|
const nearest = inferHue(color, mode)
|
|
if (!nearest) return result
|
|
return {
|
|
byHue: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
|
|
byToken: { ...result.byToken, [token]: nearest.name },
|
|
}
|
|
}, inferred)
|
|
}
|
|
|
|
function inferHue(color: RGBA, mode: Mode) {
|
|
const value = toOklch(color)
|
|
if (ambiguous(color, value.c)) return
|
|
const anchor = inferenceAnchor(value.l)
|
|
return chromaticHues
|
|
.map((name) => ({
|
|
name,
|
|
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
|
|
}))
|
|
.sort((first, second) => first.distance - second.distance)[0]
|
|
}
|
|
|
|
function inferenceAnchor(lightness: number): HueStep {
|
|
return lightness >= lightThreshold ? 300 : 700
|
|
}
|
|
|
|
function hueDistance(first: number, second: number) {
|
|
const difference = Math.abs(first - second)
|
|
return Math.min(difference, 360 - difference)
|
|
}
|
|
|
|
function ambiguous(color: RGBA, chroma = toOklch(color).c) {
|
|
return color.toInts()[3] === 0 || chroma < minimumChroma
|
|
}
|
|
|
|
function resolveV1(theme: ThemeV1Json, mode: "dark" | "light"): Theme {
|
|
const defs = theme.defs ?? {}
|
|
|
|
function resolveColor(value: unknown, chain: string[] = []): RGBA {
|
|
if (value instanceof RGBA) return 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 (chain.includes(value)) throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
|
|
const next = defs[value] ?? theme.theme[value as ThemeColor]
|
|
if (next === undefined) throw new Error(`Color reference "${value}" not found in defs or theme`)
|
|
return resolveColor(next, [...chain, value])
|
|
}
|
|
if (typeof value === "number") return ansi(value)
|
|
if (!value || typeof value !== "object" || !(mode in value)) throw new Error("Invalid V1 theme color")
|
|
return resolveColor((value as Record<"dark" | "light", unknown>)[mode], chain)
|
|
}
|
|
|
|
const resolved = Object.fromEntries(
|
|
Object.entries(theme.theme)
|
|
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
|
.map(([key, value]) => [key, resolveColor(value)]),
|
|
) as Partial<Record<ThemeColor, RGBA>>
|
|
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
|
resolved.selectedListItemText = hasSelectedListItemText
|
|
? resolveColor(theme.theme.selectedListItemText)
|
|
: resolved.background
|
|
resolved.backgroundMenu = theme.theme.backgroundMenu
|
|
? resolveColor(theme.theme.backgroundMenu)
|
|
: resolved.backgroundElement
|
|
|
|
return {
|
|
...resolved,
|
|
_hasSelectedListItemText: hasSelectedListItemText,
|
|
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
|
} as Theme
|
|
}
|
|
|
|
function selectedForeground(theme: Theme, background: RGBA) {
|
|
if (theme._hasSelectedListItemText) return theme.selectedListItemText
|
|
if (theme.background.a !== 0) return theme.background
|
|
return 0.299 * background.r + 0.587 * background.g + 0.114 * background.b > 0.5
|
|
? RGBA.fromInts(0, 0, 0)
|
|
: RGBA.fromInts(255, 255, 255)
|
|
}
|
|
|
|
function hueScale(color: RGBA, mode: "light" | "dark") {
|
|
const value = toOklch(color)
|
|
const anchor = mode === "light" ? 800 : 200
|
|
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
|
|
const alpha = color.toInts()[3]
|
|
return Object.fromEntries(
|
|
HueStep.literals.map((step) => {
|
|
if (step === anchor) return [step, hex(color)]
|
|
const progress = mode === "light" ? (anchor - step) / (anchor - 100) : (step - anchor) / (900 - anchor)
|
|
const generated = oklchToHex({
|
|
l: value.l + (endpoint - value.l) * progress,
|
|
c: value.c * (1 - progress * 0.5),
|
|
h: value.h,
|
|
})
|
|
return [step, alpha === 255 ? generated : `${generated}${byte(alpha)}`]
|
|
}),
|
|
) as Record<HueStep, string>
|
|
}
|
|
|
|
function neutralScale(theme: Theme, mode: "light" | "dark") {
|
|
const anchors = neutralAnchors(theme, mode)
|
|
return Object.fromEntries(
|
|
HueStep.literals.map((step) => {
|
|
const exact = anchors.find((anchor) => anchor.step === step)
|
|
if (exact) return [step, hex(exact.color)]
|
|
const first = anchors[0]!
|
|
const last = anchors.at(-1)!
|
|
const [lower, upper] =
|
|
step < first.step
|
|
? [first, anchors[1]!]
|
|
: step > last.step
|
|
? [anchors.at(-2)!, last]
|
|
: [anchors.filter((anchor) => anchor.step < step).at(-1)!, 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 neutralAnchors(theme: Theme, mode: "light" | "dark") {
|
|
const light: { step: HueStep; color: RGBA }[] = [
|
|
{ step: 200, color: theme.background },
|
|
{ step: 300, color: theme.backgroundPanel },
|
|
{ step: 400, color: theme.backgroundElement || theme.backgroundMenu },
|
|
{ step: 600, color: theme.textMuted },
|
|
{ step: 800, color: theme.text },
|
|
]
|
|
if (mode === "light") return light
|
|
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
|
|
}
|
|
|
|
function interpolate(first: RGBA, second: RGBA, amount: number) {
|
|
const start = toOklch(first)
|
|
const end = toOklch(second)
|
|
const startHue = Number.isFinite(start.h) ? start.h : Number.isFinite(end.h) ? end.h : 0
|
|
const endHue = Number.isFinite(end.h) ? end.h : startHue
|
|
const hue = ((((endHue - startHue) % 360) + 540) % 360) - 180
|
|
const generated = oklchToHex({
|
|
l: start.l + (end.l - start.l) * amount,
|
|
c: start.c + (end.c - start.c) * amount,
|
|
h: startHue + hue * amount,
|
|
})
|
|
const alpha = Math.max(
|
|
0,
|
|
Math.min(255, Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)),
|
|
)
|
|
return alpha === 255 ? generated : `${generated}${byte(alpha)}`
|
|
}
|
|
|
|
function toOklch(color: RGBA) {
|
|
const [red, green, blue] = color.toInts()
|
|
return rgbToOklch(red / 255, green / 255, blue / 255)
|
|
}
|
|
|
|
function hex(color: RGBA) {
|
|
return hexInts(...color.toInts())
|
|
}
|
|
|
|
function hexInts(r: number, g: number, b: number, a: number) {
|
|
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
|
|
}
|
|
|
|
function byte(value: number) {
|
|
return value.toString(16).padStart(2, "0")
|
|
}
|
|
|
|
function ansi(code: number) {
|
|
if (code < 16) {
|
|
const colors = [
|
|
"#000000",
|
|
"#800000",
|
|
"#008000",
|
|
"#808000",
|
|
"#000080",
|
|
"#800080",
|
|
"#008080",
|
|
"#c0c0c0",
|
|
"#808080",
|
|
"#ff0000",
|
|
"#00ff00",
|
|
"#ffff00",
|
|
"#0000ff",
|
|
"#ff00ff",
|
|
"#00ffff",
|
|
"#ffffff",
|
|
]
|
|
return RGBA.fromHex(colors[code] ?? "#000000")
|
|
}
|
|
if (code < 232) {
|
|
const index = code - 16
|
|
const value = (part: number) => (part === 0 ? 0 : part * 40 + 55)
|
|
return RGBA.fromInts(value(Math.floor(index / 36)), value(Math.floor(index / 6) % 6), value(index % 6))
|
|
}
|
|
if (code < 256) {
|
|
const gray = (code - 232) * 10 + 8
|
|
return RGBA.fromInts(gray, gray, gray)
|
|
}
|
|
return RGBA.fromInts(0, 0, 0)
|
|
}
|