import { createVividThemeColors, getThemeModes, parseThemeFile, themeColorToHex, THEME_FILE_VERSION, type ThemeAppearance, type ThemeColorRole, type ThemeDefinition, } from "./themePalette"; /** * Best-effort import of a VS Code color theme (`*-color-theme.json`). * * VS Code themes describe editor chrome, not an app palette: they carry a few * hundred workbench keys, leave most of them unset, and freely use 8-digit * hex with alpha for overlays. So the conversion derives a complete, contrast * -solved palette from the theme's editor background and accent, then layers * the workbench colors it did specify on top. Anything the file omits keeps * the derived value instead of falling back to an unrelated palette. */ type VsCodeRgba = { r: number; g: number; b: number; a: number }; type VsCodeRgb = { r: number; g: number; b: number }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** sRGB transfer function, and its inverse, shared by the wide-gamut path. */ function decodeGamma(value: number): number { return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; } function encodeGamma(value: number): number { const clamped = Math.max(0, Math.min(1, value)); return clamped <= 0.0031308 ? clamped * 12.92 : 1.055 * clamped ** (1 / 2.4) - 0.055; } /** * `color(display-p3 r g b / a)` shows up in themes authored for wide-gamut * displays. Out-of-gamut colors clip to the sRGB edge, which is what a browser * on an sRGB screen shows anyway. */ function parseColorFunction(value: string): VsCodeRgba | null { const match = /^color\(\s*(display-p3|srgb)\s+([^)]+)\)$/i.exec(value); if (!match) return null; const [space, body] = [match[1]!.toLowerCase(), match[2]!]; const [channelPart, alphaPart] = body.split("/"); const channels = channelPart! .trim() .split(/\s+/) .map((part) => (part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part))); if (channels.length !== 3 || channels.some((channel) => !Number.isFinite(channel))) return null; const alphaRaw = alphaPart?.trim(); const alpha = alphaRaw === undefined ? 1 : alphaRaw.endsWith("%") ? Number.parseFloat(alphaRaw) / 100 : Number.parseFloat(alphaRaw); if (!Number.isFinite(alpha)) return null; const [red, green, blue] = channels as [number, number, number]; if (space === "srgb") { return { r: red * 255, g: green * 255, b: blue * 255, a: Math.max(0, Math.min(1, alpha)) }; } const [linearRed, linearGreen, linearBlue] = [red, green, blue].map(decodeGamma) as [ number, number, number, ]; // Display P3 linear -> sRGB linear. const srgb = [ 1.2249401762805 * linearRed - 0.2249401762805 * linearGreen, -0.042056961239 * linearRed + 1.042056961239 * linearGreen, -0.0196375547643 * linearRed - 0.0786360655012 * linearGreen + 1.0982736202656 * linearBlue, ].map((channel) => encodeGamma(channel) * 255) as [number, number, number]; return { r: srgb[0], g: srgb[1], b: srgb[2], a: Math.max(0, Math.min(1, alpha)) }; } /** VS Code accepts #RGB, #RGBA, #RRGGBB, and #RRGGBBAA; some themes also use * CSS color() notation for wide-gamut palettes. */ function parseVsCodeColor(value: unknown): VsCodeRgba | null { if (typeof value !== "string") return null; const trimmed = value.trim(); if (trimmed.startsWith("color(")) return parseColorFunction(trimmed); const hex = trimmed.replace(/^#/, ""); if (!/^(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)) return null; const expand = (part: string) => part.length === 1 ? Number.parseInt(part + part, 16) : Number.parseInt(part, 16); if (hex.length <= 4) { return { r: expand(hex[0]!), g: expand(hex[1]!), b: expand(hex[2]!), a: hex.length === 4 ? expand(hex[3]!) / 255 : 1, }; } return { r: Number.parseInt(hex.slice(0, 2), 16), g: Number.parseInt(hex.slice(2, 4), 16), b: Number.parseInt(hex.slice(4, 6), 16), a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1, }; } function toHex(color: VsCodeRgb): string { const channel = (value: number) => Math.max(0, Math.min(255, Math.round(value))) .toString(16) .padStart(2, "0"); return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`; } /** Overlays are semi-transparent in VS Code; our roles are opaque, so they are * composited onto whatever surface they sit on. */ function flattenOver(color: VsCodeRgba, base: VsCodeRgb): string { if (color.a >= 1) return toHex(color); return toHex({ r: color.r * color.a + base.r * (1 - color.a), g: color.g * color.a + base.g * (1 - color.a), b: color.b * color.a + base.b * (1 - color.a), }); } function relativeLuminance(color: VsCodeRgb): number { const channel = (value: number) => { const ratio = value / 255; return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4; }; return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b); } function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number { const a = relativeLuminance(first); const b = relativeLuminance(second); return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); } function hexToRgb(value: string): VsCodeRgb { return parseVsCodeColor(themeColorToHex(value) ?? value) ?? { r: 0, g: 0, b: 0, a: 1 }; } /** * A VS Code theme is recognised by its workbench colors: the keys are dotted * paths (`editor.background`), which our own files never use. */ export function isVsCodeThemeFile(value: unknown): boolean { if (!isRecord(value)) return false; if (value.version === THEME_FILE_VERSION) return false; const hasWorkbenchColors = isRecord(value.colors) && Object.keys(value.colors).some((key) => key.includes(".")); return hasWorkbenchColors || Array.isArray(value.tokenColors); } function resolveAppearance(value: Record, canvas: VsCodeRgb): ThemeAppearance { const type = typeof value.type === "string" ? value.type.toLowerCase() : null; if (type === "light" || type === "hc-light") return "light"; if (type === "dark" || type === "hc-black") return "dark"; // Unlabelled themes (and the odd custom `type`) follow the editor surface. return relativeLuminance(canvas) < 0.179 ? "dark" : "light"; } /** Extension `name` fields are often package slugs; read them as words. */ export function humanizeThemeName(raw: string): string { const trimmed = raw.trim(); if (/\s/.test(trimmed) || !/[-_.]/.test(trimmed)) return trimmed; return trimmed .split(/[-_.]+/) .filter(Boolean) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } function resolveName(value: Record): string { // Judge candidates by their humanized form: a displayName of "---" // humanizes to nothing and must fall through to the name. for (const candidate of [value.displayName, value.name]) { if (typeof candidate !== "string") continue; const humanized = humanizeThemeName(candidate); if (humanized.length > 0) return humanized.slice(0, 48); } return "VS Code theme"; } export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { if (!isRecord(value)) throw new Error("Theme files must contain a JSON object."); const colors = isRecord(value.colors) ? value.colors : {}; /** First key that carries a usable color, in priority order. */ const pick = (...keys: ReadonlyArray): VsCodeRgba | null => { for (const key of keys) { const parsed = parseVsCodeColor(colors[key]); if (parsed) return parsed; } return null; }; const solidOver = (base: VsCodeRgb, ...keys: ReadonlyArray): string | null => { const parsed = pick(...keys); return parsed ? flattenOver(parsed, base) : null; }; const canvasColor = pick("editor.background", "editorPane.background"); if (!canvasColor) { throw new Error( 'That VS Code theme has no "editor.background" color, so there is nothing to build a palette from.', ); } const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b }; const appearance = resolveAppearance(value, canvas); const accentColor = pick( "focusBorder", "button.background", "textLink.foreground", "activityBarBadge.background", "progressBar.background", "badge.background", ); const canvasHex = toHex(canvas); const accentHex = accentColor ? flattenOver(accentColor, canvas) : null; // The derived palette is the floor: every role starts contrast-solved, then // the theme's own workbench colors replace what it actually specified. The // floor derives from a muted accent -- the vivid engine carries the accent // hue into every surface, which washes an imported neutral palette (a gray // theme with a blue focusBorder would get blue code and text surfaces). const mutedAccentHex = accentColor ? flattenOver({ r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, canvas) : null; const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex ?? canvasHex); const sidebarHex = solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar; const sidebar = hexToRgb(sidebarHex); const terminalHex = solidOver(canvas, "terminal.background", "panel.background") ?? derived.terminalBackground; const terminal = hexToRgb(terminalHex); /** Foregrounds only win when they stay readable on the surface they land on; * a theme tuned for its own chrome can be unreadable on ours. */ const readableOn = ( surface: string, fallback: string, ...keys: ReadonlyArray ): string => { const surfaceRgb = hexToRgb(surface); const isReadable = (candidate: string) => contrastRatio(hexToRgb(candidate), surfaceRgb) >= 4.5; const specified = solidOver(surfaceRgb, ...keys); if (specified && isReadable(specified)) return specified; // The derived fallback was solved against the derived surface. When the // file replaced that surface (a light sideBar in a dark theme, say), the // fallback has to clear the bar there too, or the text falls back to the // readable end of the greyscale for the surface actually in play. if (isReadable(fallback)) return fallback; return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000"; }; const overrides: Partial> = { canvas: canvasHex, text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"), textMuted: readableOn( canvasHex, derived.textMuted, "descriptionForeground", "disabledForeground", ), surface: solidOver(canvas, "editorWidget.background") ?? derived.surface, surfaceRaised: solidOver(canvas, "editorWidget.background", "dropdown.background") ?? derived.surfaceRaised, surfaceOverlay: solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ?? derived.surfaceOverlay, border: solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border, input: solidOver(canvas, "input.border", "dropdown.border") ?? derived.input, placeholder: readableOn(canvasHex, derived.placeholder, "input.placeholderForeground"), error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"), warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"), accentSurface: solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ?? derived.accentSurface, codeBackground: solidOver(canvas, "textCodeBlock.background") ?? derived.codeBackground, sidebar: sidebarHex, sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"), sidebarBorder: solidOver(sidebar, "sideBar.border") ?? derived.sidebarBorder, sidebarRowHover: solidOver(sidebar, "list.hoverBackground") ?? derived.sidebarRowHover, sidebarRowActive: solidOver(sidebar, "list.inactiveSelectionBackground", "list.hoverBackground") ?? derived.sidebarRowActive, sidebarRowSelected: solidOver(sidebar, "list.activeSelectionBackground") ?? derived.sidebarRowSelected, terminalBackground: terminalHex, terminalForeground: readableOn(terminalHex, derived.terminalForeground, "terminal.foreground"), terminalCursor: solidOver(terminal, "terminalCursor.foreground", "editorCursor.foreground") ?? derived.terminalCursor, terminalSelection: solidOver(terminal, "terminal.selectionBackground", "editor.selectionBackground") ?? derived.terminalSelection, terminalScrollbar: solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar, }; if (accentHex) { overrides.accent = accentHex; overrides.focus = accentHex; // The button pair is the closest thing VS Code has to our action color. const actionHex = solidOver(canvas, "button.background") ?? accentHex; overrides.messageAction = actionHex; overrides.messageActionForeground = readableOn( actionHex, derived.messageActionForeground, "button.foreground", ); overrides.accentForeground = readableOn( accentHex, derived.accentForeground, "button.foreground", ); } // Reuse the theme-file parser so ids, names, and color values go through the // same validation as a hand-written file. return parseThemeFile({ version: THEME_FILE_VERSION, name: resolveName(value), appearance, colors: { ...derived, ...overrides }, }); } /** * Extensions ship a family of themes (GitHub: dark, light, dark-colorblind, * light-colorblind, dark-dimmed, ...). When several are imported together, * a light and a dark file whose names differ only by the appearance word * become one dual-mode theme; everything else stays its own theme. */ export function pairVsCodeThemes( themes: ReadonlyArray, options?: { pairedId?: (light: ThemeDefinition, dark: ThemeDefinition) => string; }, ): ReadonlyArray { const stripAppearance = (label: string) => label .replace(/\b(?:light|dark)\b/gi, " ") .replace(/\s+/g, " ") .trim(); type Group = { light: ThemeDefinition[]; dark: ThemeDefinition[]; order: number }; const groups = new Map(); const passthrough: Array<{ theme: ThemeDefinition; order: number }> = []; themes.forEach((theme, order) => { // Only single-appearance themes with an appearance word in the name can // pair; anything else is already what the user asked for. const key = stripAppearance(theme.label); if (getThemeModes(theme).length !== 1 || key === theme.label || key.length === 0) { passthrough.push({ theme, order }); return; } const group = groups.get(key) ?? { light: [], dark: [], order }; group[theme.appearance].push(theme); groups.set(key, group); }); const paired: Array<{ theme: ThemeDefinition; order: number }> = []; for (const [key, group] of groups) { // Ambiguity (two darks for one light) is not guessed at, and a pair // whose stripped name collides with a built-in id ("Grove Light" + // "Grove Dark" -> the reserved "grove") stays two single themes rather // than failing the whole batch. if (group.light.length === 1 && group.dark.length === 1) { try { paired.push({ order: group.order, theme: parseThemeFile({ version: THEME_FILE_VERSION, ...(options?.pairedId ? { id: options.pairedId(group.light[0]!, group.dark[0]!) } : {}), name: key, appearance: "light", colors: group.light[0]!.colors, variants: { dark: group.dark[0]!.colors }, }), }); continue; } catch { // Fall through to the individual themes below. } } for (const theme of [...group.light, ...group.dark]) { paired.push({ theme, order: group.order }); } } return [...passthrough, ...paired].sort((a, b) => a.order - b.order).map((entry) => entry.theme); } /** * Some extensions reuse one display name across every file: Dracula ships * dracula.json and dracula-soft.json that both say "Dracula". When a batch * carries the same id twice, the file names are the only thing that tells * the variants apart, so colliding entries are relabelled from their file * name (and numbered only when even that collides). */ export function resolveThemeLabelCollisions( entries: ReadonlyArray<{ theme: ThemeDefinition; sourceName?: string | undefined }>, ): ReadonlyArray { // Null when the new name is unusable (reserved id, invalid characters). const rename = (theme: ThemeDefinition, name: string): ThemeDefinition | null => { try { return parseThemeFile({ version: THEME_FILE_VERSION, name: name.slice(0, 48), appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), ...(theme.managed ? { managed: true } : {}), }); } catch { return null; } }; const counts = new Map(); for (const entry of entries) { counts.set(entry.theme.id, (counts.get(entry.theme.id) ?? 0) + 1); } const relabelled = entries.map(({ theme, sourceName }) => { if ((counts.get(theme.id) ?? 0) < 2) return theme; const stem = sourceName?.replace(/\.[^.]+$/, ""); const fromFile = stem ? humanizeThemeName(stem) : null; const renamed = fromFile && fromFile.toLowerCase() !== theme.label.toLowerCase() ? rename(theme, fromFile) : null; return renamed ?? theme; }); const seen = new Set(); return relabelled.map((theme) => { if (!seen.has(theme.id)) { seen.add(theme.id); return theme; } for (let suffix = 2; suffix < 100; suffix += 1) { const candidate = rename(theme, `${theme.label} ${suffix}`); if (candidate && !seen.has(candidate.id)) { seen.add(candidate.id); return candidate; } } return theme; }); }