1import { parse, type ParseError } from "jsonc-parser";
2
3import {
4 createVividThemeColors,
5 getStandardThemeColors,
6 getThemeModes,
7 isReservedThemeId,
8 parseThemeFile,
9 readableThemeColorOn,
10 themeColorToHex,
11 themeIdFromName,
12 THEME_FILE_VERSION,
13 type ThemeAppearance,
14 type ThemeColorRole,
15 type ThemeDefinition,
16} from "./themePalette";
17
18type VsCodeRgba = { r: number; g: number; b: number; a: number };
19type VsCodeRgb = { r: number; g: number; b: number };
20
21function isRecord(value: unknown): value is Record<string, unknown> {
22 return typeof value === "object" && value !== null && !Array.isArray(value);
23}
24
25function decodeGamma(value: number): number {
26 return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
27}
28
29function encodeGamma(value: number): number {
30 const clamped = Math.max(0, Math.min(1, value));
31 return clamped <= 0.0031308 ? clamped * 12.92 : 1.055 * clamped ** (1 / 2.4) - 0.055;
32}
33
34function parseColorFunction(value: string): VsCodeRgba | null {
35 const match = /^color\(\s*(display-p3|srgb)\s+([^)]+)\)$/i.exec(value);
36 if (!match) return null;
37 const [space, body] = [match[1]!.toLowerCase(), match[2]!];
38 const [channelPart, alphaPart] = body.split("/");
39 const channels = channelPart!
40 .trim()
41 .split(/\s+/)
42 .map((part) => (part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part)));
43 if (channels.length !== 3 || channels.some((channel) => !Number.isFinite(channel))) return null;
44 const alphaRaw = alphaPart?.trim();
45 const alpha =
46 alphaRaw === undefined
47 ? 1
48 : alphaRaw.endsWith("%")
49 ? Number.parseFloat(alphaRaw) / 100
50 : Number.parseFloat(alphaRaw);
51 if (!Number.isFinite(alpha)) return null;
52
53 const [red, green, blue] = channels as [number, number, number];
54 if (space === "srgb") {
55 return { r: red * 255, g: green * 255, b: blue * 255, a: Math.max(0, Math.min(1, alpha)) };
56 }
57 const [linearRed, linearGreen, linearBlue] = [red, green, blue].map(decodeGamma) as [
58 number,
59 number,
60 number,
61 ];
62 const srgb = [
63 1.2249401762805 * linearRed - 0.2249401762805 * linearGreen,
64 -0.042056961239 * linearRed + 1.042056961239 * linearGreen,
65 -0.0196375547643 * linearRed - 0.0786360655012 * linearGreen + 1.0982736202656 * linearBlue,
66 ].map((channel) => encodeGamma(channel) * 255) as [number, number, number];
67 return { r: srgb[0], g: srgb[1], b: srgb[2], a: Math.max(0, Math.min(1, alpha)) };
68}
69
70function parseVsCodeColor(value: unknown): VsCodeRgba | null {
71 if (typeof value !== "string") return null;
72 const trimmed = value.trim();
73 if (trimmed.startsWith("color(")) return parseColorFunction(trimmed);
74 const hex = trimmed.replace(/^#/, "");
75 if (!/^(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)) return null;
76 const expand = (part: string) =>
77 part.length === 1 ? Number.parseInt(part + part, 16) : Number.parseInt(part, 16);
78 if (hex.length <= 4) {
79 return {
80 r: expand(hex[0]!),
81 g: expand(hex[1]!),
82 b: expand(hex[2]!),
83 a: hex.length === 4 ? expand(hex[3]!) / 255 : 1,
84 };
85 }
86 return {
87 r: Number.parseInt(hex.slice(0, 2), 16),
88 g: Number.parseInt(hex.slice(2, 4), 16),
89 b: Number.parseInt(hex.slice(4, 6), 16),
90 a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1,
91 };
92}
93
94function toHex(color: VsCodeRgb): string {
95 const channel = (value: number) =>
96 Math.max(0, Math.min(255, Math.round(value)))
97 .toString(16)
98 .padStart(2, "0");
99 return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
100}
101
102// Composite VS Code translucent overlays onto their surfaces; our roles are opaque.
103function flattenOver(color: VsCodeRgba, base: VsCodeRgb): string {
104 if (color.a >= 1) return toHex(color);
105 return toHex({
106 r: color.r * color.a + base.r * (1 - color.a),
107 g: color.g * color.a + base.g * (1 - color.a),
108 b: color.b * color.a + base.b * (1 - color.a),
109 });
110}
111
112function relativeLuminance(color: VsCodeRgb): number {
113 const channel = (value: number) => {
114 const ratio = value / 255;
115 return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
116 };
117 return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
118}
119
120function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number {
121 const a = relativeLuminance(first);
122 const b = relativeLuminance(second);
123 return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
124}
125
126function hexToRgb(value: string): VsCodeRgb {
127 return parseVsCodeColor(themeColorToHex(value) ?? value) ?? { r: 0, g: 0, b: 0, a: 1 };
128}
129
130export function isVsCodeThemeFile(value: unknown): boolean {
131 if (!isRecord(value)) return false;
132 if (value.version === THEME_FILE_VERSION) return false;
133 const hasWorkbenchColors =
134 isRecord(value.colors) && Object.keys(value.colors).some((key) => key.includes("."));
135 return hasWorkbenchColors || Array.isArray(value.tokenColors);
136}
137
138export function parseThemeImport(source: string): ThemeDefinition {
139 const errors: ParseError[] = [];
140 const value: unknown = parse(source, errors, { allowTrailingComma: true });
141 if (errors.length > 0) throw new Error("That theme file is not valid JSON.");
142 return isVsCodeThemeFile(value)
143 ? parseVsCodeThemeFile(value)
144 : parseThemeFile(JSON.parse(source));
145}
146
147function resolveAppearance(value: Record<string, unknown>, canvas: VsCodeRgb): ThemeAppearance {
148 const type = typeof value.type === "string" ? value.type.toLowerCase() : null;
149 if (type === "light" || type === "hc-light") return "light";
150 if (type === "dark" || type === "hc-black") return "dark";
151 return relativeLuminance(canvas) < 0.179 ? "dark" : "light";
152}
153
154export function humanizeThemeName(raw: string): string {
155 const trimmed = raw.trim();
156 if (/\s/.test(trimmed) || !/[-_.]/.test(trimmed)) return trimmed;
157 return trimmed
158 .split(/[-_.]+/)
159 .filter(Boolean)
160 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
161 .join(" ");
162}
163
164function resolveName(value: Record<string, unknown>): string {
165 for (const candidate of [value.displayName, value.name]) {
166 if (typeof candidate !== "string") continue;
167 const humanized = humanizeThemeName(candidate);
168 if (humanized.length > 0) return humanized.slice(0, 48);
169 }
170 return "VS Code theme";
171}
172
173export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
174 if (!isRecord(value)) throw new Error("Theme files must contain a JSON object.");
175 const colors = isRecord(value.colors) ? value.colors : {};
176
177 const pick = (...keys: ReadonlyArray<string>): VsCodeRgba | null => {
178 for (const key of keys) {
179 const parsed = parseVsCodeColor(colors[key]);
180 if (parsed) return parsed;
181 }
182 return null;
183 };
184 const solidOver = (base: VsCodeRgb, ...keys: ReadonlyArray<string>): string | null => {
185 const parsed = pick(...keys);
186 return parsed ? flattenOver(parsed, base) : null;
187 };
188
189 const canvasColor = pick("editor.background", "editorPane.background");
190 if (!canvasColor) {
191 throw new Error(
192 'That VS Code theme has no "editor.background" color, so there is nothing to build a palette from.',
193 );
194 }
195 const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b };
196 const appearance = resolveAppearance(value, canvas);
197
198 const canvasHex = toHex(canvas);
199 const raisedSurfaceCandidateHex = solidOver(
200 canvas,
201 "editorWidget.background",
202 "dropdown.background",
203 );
204
205 const standsApart = (first: string, second: string) =>
206 contrastRatio(hexToRgb(first), hexToRgb(second)) >= 1.1;
207
208 let accentColor: VsCodeRgba | null = null;
209 let accentHex: string | null = null;
210 for (const key of [
211 "focusBorder",
212 "button.background",
213 "textLink.foreground",
214 "activityBarBadge.background",
215 "progressBar.background",
216 "badge.background",
217 ]) {
218 const candidate = parseVsCodeColor(colors[key]);
219 if (!candidate) continue;
220 const candidateHex = flattenOver(candidate, canvas);
221 if (
222 !standsApart(candidateHex, canvasHex) ||
223 (raisedSurfaceCandidateHex !== null && !standsApart(candidateHex, raisedSurfaceCandidateHex))
224 )
225 continue;
226 accentColor = candidate;
227 accentHex = candidateHex;
228 break;
229 }
230 if (!accentColor || !accentHex) {
231 const standardAccentHex = themeColorToHex(getStandardThemeColors(appearance).accent)!;
232 accentHex =
233 [standardAccentHex, "#ffffff", "#000000"].find(
234 (candidate) =>
235 standsApart(candidate, canvasHex) &&
236 (raisedSurfaceCandidateHex === null || standsApart(candidate, raisedSurfaceCandidateHex)),
237 ) ?? standardAccentHex;
238 accentColor = parseVsCodeColor(accentHex)!;
239 }
240
241 const mutedAccentHex = flattenOver(
242 { r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 },
243 canvas,
244 );
245 const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex);
246 const sidebarHex =
247 solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar;
248 const sidebar = hexToRgb(sidebarHex);
249 const terminalHex =
250 solidOver(canvas, "terminal.background", "panel.background") ?? derived.terminalBackground;
251 const terminal = hexToRgb(terminalHex);
252
253 const readableOn = (
254 surface: string,
255 fallback: string,
256 ...keys: ReadonlyArray<string>
257 ): string => {
258 const surfaceRgb = hexToRgb(surface);
259 const isReadable = (candidate: string) => contrastRatio(hexToRgb(candidate), surfaceRgb) >= 4.5;
260 const specified = solidOver(surfaceRgb, ...keys);
261 if (specified && isReadable(specified)) return specified;
262 if (isReadable(fallback)) return fallback;
263 return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000";
264 };
265 // Status colors also fill controls such as destructive buttons, which carry
266 // white text, so a dim one is lightened along its hue rather than replaced
267 // by readableOn's black or white. One hidden in the canvas has no hue to
268 // keep, so the derived color stands in.
269 const readableStatusColor = (fallback: string, ...keys: ReadonlyArray<string>): string => {
270 const specified = solidOver(canvas, ...keys);
271 const base = specified && standsApart(specified, canvasHex) ? specified : fallback;
272 return readableThemeColorOn(base, canvasHex, 4.5);
273 };
274
275 const surfaceRaisedHex = raisedSurfaceCandidateHex ?? derived.surfaceRaised;
276 // Checked tracks use messageAction; resolve it before the unchecked input role.
277 const buttonHex = solidOver(canvas, "button.background");
278 const actionHex =
279 buttonHex && standsApart(buttonHex, canvasHex) && standsApart(buttonHex, surfaceRaisedHex)
280 ? buttonHex
281 : accentHex;
282 const inputCandidates = [
283 derived.input,
284 derived.surfaceRaised,
285 getStandardThemeColors(appearance).input,
286 "#000000",
287 "#ffffff",
288 "#808080",
289 ];
290 let inputHex =
291 inputCandidates.find(
292 (candidate) => standsApart(candidate, canvasHex) && standsApart(candidate, actionHex),
293 ) ?? "#808080";
294 for (const key of ["input.background", "input.border"]) {
295 const candidate = solidOver(canvas, key);
296 if (candidate && standsApart(candidate, canvasHex) && standsApart(candidate, actionHex)) {
297 inputHex = candidate;
298 break;
299 }
300 }
301 const searchMatchBackground =
302 solidOver(canvas, "editor.findMatchHighlightBackground") ?? derived.searchMatchBackground;
303 const searchMatchActiveBackground =
304 solidOver(canvas, "editor.findMatchBackground") ?? derived.searchMatchActiveBackground;
305
306 const overrides: Partial<Record<ThemeColorRole, string>> = {
307 canvas: canvasHex,
308 text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"),
309 textMuted: readableOn(
310 canvasHex,
311 derived.textMuted,
312 "descriptionForeground",
313 "disabledForeground",
314 ),
315 surface: solidOver(canvas, "editorWidget.background") ?? derived.surface,
316 surfaceRaised: surfaceRaisedHex,
317 surfaceOverlay:
318 solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ??
319 derived.surfaceOverlay,
320 border:
321 solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border,
322 input: inputHex,
323 placeholder: readableOn(surfaceRaisedHex, derived.placeholder, "input.placeholderForeground"),
324 error: readableStatusColor(derived.error, "editorError.foreground", "errorForeground"),
325 warning: readableStatusColor(derived.warning, "editorWarning.foreground"),
326 accentSurface:
327 solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ??
328 derived.accentSurface,
329 codeBackground: solidOver(canvas, "textCodeBlock.background") ?? derived.codeBackground,
330 searchMatchBackground,
331 searchMatchForeground: readableOn(
332 searchMatchBackground,
333 derived.searchMatchForeground,
334 "editor.findMatchHighlightForeground",
335 "editor.foreground",
336 "foreground",
337 ),
338 searchMatchActiveBackground,
339 searchMatchActiveForeground: readableOn(
340 searchMatchActiveBackground,
341 derived.searchMatchActiveForeground,
342 "editor.findMatchForeground",
343 "editor.foreground",
344 "foreground",
345 ),
346 sidebar: sidebarHex,
347 sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"),
348 sidebarBorder: solidOver(sidebar, "sideBar.border") ?? derived.sidebarBorder,
349 sidebarRowHover: solidOver(sidebar, "list.hoverBackground") ?? derived.sidebarRowHover,
350 sidebarRowActive:
351 solidOver(sidebar, "list.inactiveSelectionBackground", "list.hoverBackground") ??
352 derived.sidebarRowActive,
353 sidebarRowSelected:
354 solidOver(sidebar, "list.activeSelectionBackground") ?? derived.sidebarRowSelected,
355 terminalBackground: terminalHex,
356 terminalForeground: readableOn(terminalHex, derived.terminalForeground, "terminal.foreground"),
357 terminalCursor:
358 solidOver(terminal, "terminalCursor.foreground", "editorCursor.foreground") ??
359 derived.terminalCursor,
360 terminalSelection:
361 solidOver(terminal, "terminal.selectionBackground", "editor.selectionBackground") ??
362 derived.terminalSelection,
363 terminalScrollbar:
364 solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar,
365 };
366 overrides.accent = accentHex;
367 overrides.focus = accentHex;
368 overrides.messageAction = actionHex;
369 overrides.messageActionForeground = readableOn(
370 actionHex,
371 derived.messageActionForeground,
372 "button.foreground",
373 );
374 overrides.accentForeground = readableOn(accentHex, derived.accentForeground, "button.foreground");
375
376 const name = resolveName(value);
377 const generatedId = themeIdFromName(name);
378 return parseThemeFile({
379 version: THEME_FILE_VERSION,
380 ...(isReservedThemeId(generatedId) ? { id: `${generatedId}-vscode` } : {}),
381 name,
382 appearance,
383 colors: { ...derived, ...overrides },
384 });
385}
386
387export function pairVsCodeThemes(
388 themes: ReadonlyArray<ThemeDefinition>,
389 options?: {
390 pairedId?: (light: ThemeDefinition, dark: ThemeDefinition) => string;
391 },
392): ReadonlyArray<ThemeDefinition> {
393 const stripAppearance = (label: string) =>
394 label
395 .replace(/\b(?:light|dark)\b/gi, " ")
396 .replace(/\s+/g, " ")
397 .trim();
398
399 type Group = { light: ThemeDefinition[]; dark: ThemeDefinition[]; order: number };
400 const groups = new Map<string, Group>();
401 const passthrough: Array<{ theme: ThemeDefinition; order: number }> = [];
402 themes.forEach((theme, order) => {
403 const key = stripAppearance(theme.label);
404 if (getThemeModes(theme).length !== 1 || key === theme.label || !/[a-z0-9]/i.test(key)) {
405 passthrough.push({ theme, order });
406 return;
407 }
408 const group = groups.get(key) ?? { light: [], dark: [], order };
409 group[theme.appearance].push(theme);
410 groups.set(key, group);
411 });
412
413 const paired: Array<{ theme: ThemeDefinition; order: number }> = [];
414 for (const [key, group] of groups) {
415 if (group.light.length === 1 && group.dark.length === 1) {
416 try {
417 paired.push({
418 order: group.order,
419 theme: parseThemeFile({
420 version: THEME_FILE_VERSION,
421 ...(options?.pairedId ? { id: options.pairedId(group.light[0]!, group.dark[0]!) } : {}),
422 name: key,
423 appearance: "light",
424 colors: group.light[0]!.colors,
425 variants: { dark: group.dark[0]!.colors },
426 }),
427 });
428 continue;
429 } catch {}
430 }
431 for (const theme of [...group.light, ...group.dark]) {
432 paired.push({ theme, order: group.order });
433 }
434 }
435
436 return [...passthrough, ...paired].sort((a, b) => a.order - b.order).map((entry) => entry.theme);
437}
438
439export function resolveThemeLabelCollisions(
440 entries: ReadonlyArray<{ theme: ThemeDefinition; sourceName?: string | undefined }>,
441): ReadonlyArray<ThemeDefinition> {
442 const rename = (theme: ThemeDefinition, name: string): ThemeDefinition | null => {
443 try {
444 return parseThemeFile({
445 version: THEME_FILE_VERSION,
446 name: name.slice(0, 48),
447 appearance: theme.appearance,
448 colors: theme.colors,
449 ...(theme.variants ? { variants: theme.variants } : {}),
450 ...(theme.managed ? { managed: true } : {}),
451 });
452 } catch {
453 return null;
454 }
455 };
456
457 const counts = new Map<string, number>();
458 for (const entry of entries) {
459 counts.set(entry.theme.id, (counts.get(entry.theme.id) ?? 0) + 1);
460 }
461 const relabelled = entries.map(({ theme, sourceName }) => {
462 if ((counts.get(theme.id) ?? 0) < 2) return theme;
463 const stem = sourceName?.replace(/\.[^.]+$/, "");
464 const fromFile = stem ? humanizeThemeName(stem) : null;
465 const renamed =
466 fromFile && fromFile.toLowerCase() !== theme.label.toLowerCase()
467 ? rename(theme, fromFile)
468 : null;
469 return renamed ?? theme;
470 });
471
472 const seen = new Set<string>();
473 return relabelled.map((theme) => {
474 if (!seen.has(theme.id)) {
475 seen.add(theme.id);
476 return theme;
477 }
478 for (let suffix = 2; suffix < 100; suffix += 1) {
479 const candidate = rename(theme, `${theme.label} ${suffix}`);
480 if (candidate && !seen.has(candidate.id)) {
481 seen.add(candidate.id);
482 return candidate;
483 }
484 }
485 return theme;
486 });
487}