Skip to content

Commit e4311e7

Browse files
committed
feat(theme): load a VS Code/Shiki theme JSON for syntax highlighting
Custom themes could only approximate syntax colors with nine semantic tokens, so any real VS Code theme (e.g. Shades of Purple) rendered as a rough remap of Pierre's default theme rather than the theme itself. Add `custom_theme.syntax_theme`: a path (absolute, or relative to the config file) to a full VS Code/Shiki theme JSON. The file is loaded and validated at config time, registered with Pierre's highlighter, and used as the active syntax theme so code is colored exactly as that theme would in the editor. The nine `[custom_theme.syntax]` tokens stay as the collision-normalization palette against diff add/remove backgrounds.
1 parent 0a3cc06 commit e4311e7

9 files changed

Lines changed: 212 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hunkdiff": minor
3+
---
4+
5+
Add `custom_theme.syntax_theme` to load a full VS Code / Shiki theme JSON for source-accurate syntax highlighting. The referenced theme is registered with the highlighter and drives code coloring, so any VS Code theme renders exactly as it would in the editor instead of being approximated by the nine `[custom_theme.syntax]` tokens.

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,16 @@ variable = "#eef4ff"
157157

158158
All custom theme colors must use `#rrggbb` hex values. Press `t` in the app, or choose `View -> Themes…`, to open the theme selector.
159159

160+
For source-accurate syntax highlighting, point `syntax_theme` at a VS Code / Shiki theme JSON file. Hunk loads it and hands it to its Shiki-based highlighter, so any VS Code theme colors your code exactly as that theme would:
161+
162+
```toml
163+
[custom_theme]
164+
base = "catppuccin-mocha"
165+
syntax_theme = "shades-of-purple.json" # absolute, or relative to this config file
166+
```
167+
168+
When `syntax_theme` is set it drives code highlighting; the `[custom_theme.syntax]` colors then only refine tokens that would otherwise collide with diff add/remove backgrounds.
169+
160170
### Git integration
161171

162172
Set Hunk as your Git pager so `git diff` and `git show` open in Hunk automatically:

src/core/config.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,72 @@ describe("config resolution", () => {
221221
).toThrow("Expected custom_theme.accent to be a hex color like #112233.");
222222
});
223223

224+
test("loads a custom_theme.syntax_theme JSON relative to the config file", () => {
225+
const home = createTempDir("hunk-config-home-");
226+
const hunkDir = join(home, ".config", "hunk");
227+
mkdirSync(hunkDir, { recursive: true });
228+
writeFileSync(
229+
join(hunkDir, "my-theme.json"),
230+
JSON.stringify({ name: "My VS Code Theme", type: "dark", tokenColors: [] }),
231+
);
232+
writeFileSync(
233+
join(hunkDir, "config.toml"),
234+
[
235+
'theme = "custom"',
236+
"",
237+
"[custom_theme]",
238+
'base = "github-dark-default"',
239+
'syntax_theme = "my-theme.json"',
240+
].join("\n"),
241+
);
242+
243+
const resolved = resolveConfiguredCliInput(createPatchPagerInput(), {
244+
cwd: createTempDir("hunk-config-cwd-"),
245+
env: { HOME: home },
246+
});
247+
248+
expect(resolved.customTheme?.syntaxThemePath).toBe("my-theme.json");
249+
expect(resolved.customTheme?.syntaxThemeData).toEqual({
250+
name: "My VS Code Theme",
251+
type: "dark",
252+
tokenColors: [],
253+
});
254+
});
255+
256+
test("rejects a custom_theme.syntax_theme that does not exist", () => {
257+
const home = createTempDir("hunk-config-home-");
258+
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
259+
writeFileSync(
260+
join(home, ".config", "hunk", "config.toml"),
261+
["[custom_theme]", 'syntax_theme = "missing.json"'].join("\n"),
262+
);
263+
264+
expect(() =>
265+
resolveConfiguredCliInput(createPatchPagerInput(), {
266+
cwd: createTempDir("hunk-config-cwd-"),
267+
env: { HOME: home },
268+
}),
269+
).toThrow("Expected custom_theme.syntax_theme to point at a file.");
270+
});
271+
272+
test("rejects a custom_theme.syntax_theme JSON without a name", () => {
273+
const home = createTempDir("hunk-config-home-");
274+
const hunkDir = join(home, ".config", "hunk");
275+
mkdirSync(hunkDir, { recursive: true });
276+
writeFileSync(join(hunkDir, "nameless.json"), JSON.stringify({ type: "dark" }));
277+
writeFileSync(
278+
join(hunkDir, "config.toml"),
279+
["[custom_theme]", 'syntax_theme = "nameless.json"'].join("\n"),
280+
);
281+
282+
expect(() =>
283+
resolveConfiguredCliInput(createPatchPagerInput(), {
284+
cwd: createTempDir("hunk-config-cwd-"),
285+
env: { HOME: home },
286+
}),
287+
).toThrow('to be a Shiki theme with a non-empty "name".');
288+
});
289+
224290
test("rejects theme = custom when no [custom_theme] table is configured", () => {
225291
const home = createTempDir("hunk-config-home-");
226292
mkdirSync(join(home, ".config", "hunk"), { recursive: true });

src/core/config.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import fs from "node:fs";
2-
import { join } from "node:path";
2+
import { dirname, isAbsolute, join, resolve } from "node:path";
33
import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes";
44
import { normalizeBuiltInThemeId } from "../ui/themes";
55
import { resolveGlobalConfigPath } from "./paths";
@@ -8,6 +8,7 @@ import type {
88
CliInput,
99
CommonOptions,
1010
CustomSyntaxColorsConfig,
11+
CustomSyntaxThemeData,
1112
CustomThemeConfig,
1213
LayoutMode,
1314
PersistedViewPreferences,
@@ -161,8 +162,52 @@ function readCustomSyntaxColors(
161162
return Object.keys(syntax).length > 0 ? syntax : undefined;
162163
}
163164

165+
/**
166+
* Load and validate a full Shiki theme JSON referenced by `custom_theme.syntax_theme`.
167+
* The path may be absolute or relative to the config file that declared it. We read it
168+
* eagerly so a bad path fails fast at config time rather than silently dropping
169+
* highlighting later.
170+
*/
171+
function readCustomSyntaxTheme(
172+
value: unknown,
173+
configPath: string | undefined,
174+
): CustomSyntaxThemeData | undefined {
175+
const rawPath = normalizeString(value);
176+
if (rawPath === undefined) {
177+
return undefined;
178+
}
179+
180+
const basis = configPath ? dirname(configPath) : process.cwd();
181+
const themePath = isAbsolute(rawPath) ? rawPath : resolve(basis, rawPath);
182+
183+
if (!fs.existsSync(themePath)) {
184+
throw new Error(`Expected custom_theme.syntax_theme to point at a file. Missing: ${themePath}`);
185+
}
186+
187+
let parsed: unknown;
188+
try {
189+
parsed = JSON.parse(fs.readFileSync(themePath, "utf8"));
190+
} catch (error) {
191+
const reason = error instanceof Error ? error.message : String(error);
192+
throw new Error(
193+
`Expected custom_theme.syntax_theme (${themePath}) to be valid JSON: ${reason}`,
194+
);
195+
}
196+
197+
if (!isRecord(parsed) || typeof parsed.name !== "string" || parsed.name.length === 0) {
198+
throw new Error(
199+
`Expected custom_theme.syntax_theme (${themePath}) to be a Shiki theme with a non-empty "name".`,
200+
);
201+
}
202+
203+
return parsed as CustomSyntaxThemeData;
204+
}
205+
164206
/** Read the optional config-defined custom theme palette from one TOML object level. */
165-
function readCustomTheme(source: Record<string, unknown>): CustomThemeConfig | undefined {
207+
function readCustomTheme(
208+
source: Record<string, unknown>,
209+
configPath?: string,
210+
): CustomThemeConfig | undefined {
166211
const customThemeSource = source.custom_theme;
167212
if (!isRecord(customThemeSource)) {
168213
return undefined;
@@ -181,6 +226,12 @@ function readCustomTheme(source: Record<string, unknown>): CustomThemeConfig | u
181226
customTheme.label = label;
182227
}
183228

229+
const syntaxThemePath = normalizeString(customThemeSource.syntax_theme);
230+
if (syntaxThemePath !== undefined) {
231+
customTheme.syntaxThemePath = syntaxThemePath;
232+
customTheme.syntaxThemeData = readCustomSyntaxTheme(customThemeSource.syntax_theme, configPath);
233+
}
234+
184235
for (const key of CUSTOM_THEME_COLOR_KEYS) {
185236
const value = normalizeThemeColor(customThemeSource[key], `custom_theme.${key}`);
186237
if (value !== undefined) {
@@ -215,6 +266,8 @@ function mergeCustomTheme(
215266
...overrides,
216267
base: overrides.base ?? base.base ?? "github-dark-default",
217268
label: overrides.label ?? base.label,
269+
syntaxThemePath: overrides.syntaxThemePath ?? base.syntaxThemePath,
270+
syntaxThemeData: overrides.syntaxThemeData ?? base.syntaxThemeData,
218271
syntax:
219272
base.syntax || overrides.syntax
220273
? {
@@ -333,13 +386,19 @@ export function resolveConfiguredCliInput(
333386
if (userConfigPath) {
334387
const userConfig = readTomlRecord(userConfigPath);
335388
resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(userConfig, input));
336-
resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(userConfig));
389+
resolvedCustomTheme = mergeCustomTheme(
390+
resolvedCustomTheme,
391+
readCustomTheme(userConfig, userConfigPath),
392+
);
337393
}
338394

339395
if (repoConfigPath) {
340396
const repoConfig = readTomlRecord(repoConfigPath);
341397
resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(repoConfig, input));
342-
resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(repoConfig));
398+
resolvedCustomTheme = mergeCustomTheme(
399+
resolvedCustomTheme,
400+
readCustomTheme(repoConfig, repoConfigPath),
401+
);
343402
}
344403

345404
resolvedOptions = mergeOptions(resolvedOptions, input.options);

src/core/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,23 @@ export interface CustomSyntaxColorsConfig {
110110
punctuation?: string;
111111
}
112112

113+
/**
114+
* A full VS Code / Shiki theme JSON loaded from disk and registered with the
115+
* highlighter for source-accurate syntax coloring. Only `name` is required; the
116+
* remaining TextMate fields are passed through to Shiki untouched.
117+
*/
118+
export interface CustomSyntaxThemeData {
119+
name: string;
120+
[key: string]: unknown;
121+
}
122+
113123
export interface CustomThemeConfig {
114124
base?: string;
115125
label?: string;
126+
/** Path (from config) to a Shiki theme JSON used for syntax highlighting. */
127+
syntaxThemePath?: string;
128+
/** The loaded + validated Shiki theme JSON referenced by `syntaxThemePath`. */
129+
syntaxThemeData?: CustomSyntaxThemeData;
116130
background?: string;
117131
panel?: string;
118132
panelAlt?: string;

src/ui/diff/pierre.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
cleanLastNewline,
33
getHighlighterOptions,
44
getSharedHighlighter,
5+
registerCustomTheme,
56
renderDiffWithHighlighter,
67
renderFileWithHighlighter,
78
type FileContents,
@@ -571,8 +572,33 @@ export function trailingCollapsedLines(metadata: FileDiffMetadata) {
571572
return Math.max(additionRemaining, 0);
572573
}
573574

575+
// Custom Shiki themes are registered once with Pierre's global theme registry. Track which
576+
// names we've registered so repeated highlight passes don't re-register (Pierre warns on dupes).
577+
const registeredCustomSyntaxThemes = new Set<string>();
578+
579+
/** Register a config-provided Shiki theme JSON with Pierre before it's referenced by name. */
580+
function ensureCustomSyntaxThemeRegistered(theme: HighlightThemeInput) {
581+
if (typeof theme === "string") {
582+
return;
583+
}
584+
585+
const data = theme.syntaxThemeData;
586+
if (!data || registeredCustomSyntaxThemes.has(data.name)) {
587+
return;
588+
}
589+
590+
registeredCustomSyntaxThemes.add(data.name);
591+
// Pierre resolves themes by name against its custom registry first, then Shiki's bundled
592+
// themes. The registry stores async loaders, so hand it one resolving to the loaded JSON.
593+
type CustomThemeLoader = Parameters<typeof registerCustomTheme>[1];
594+
const loader: CustomThemeLoader = () =>
595+
Promise.resolve(data as unknown as Awaited<ReturnType<CustomThemeLoader>>);
596+
registerCustomTheme(data.name, loader);
597+
}
598+
574599
/** Prepare syntax highlighting for one language/theme pair using Pierre's shared highlighter. */
575600
async function prepareHighlighter(language: string | undefined, theme: HighlightThemeInput) {
601+
ensureCustomSyntaxThemeRegistered(theme);
576602
const resolvedLanguage = language ?? "text";
577603
const syntaxTheme = highlighterThemeName(theme);
578604
const cacheKey = `${syntaxTheme}:${resolvedLanguage}`;

src/ui/themes.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,22 @@ describe("themes", () => {
247247
expect(custom.syntaxColors.keyword).toBe("#ff00ff");
248248
});
249249

250+
test("a full syntax theme JSON drives highlighting by name", () => {
251+
const syntaxThemeData = { name: "Shades of Purple", type: "dark" as const, tokenColors: [] };
252+
const custom = resolveTheme("custom", null, {
253+
base: "catppuccin-mocha",
254+
label: "My Theme",
255+
syntaxThemeData,
256+
// A 9-token block is present too, but the full theme JSON should take precedence.
257+
syntax: { keyword: "#ff00ff" },
258+
});
259+
260+
expect(custom.syntaxTheme).toBe("Shades of Purple");
261+
expect(custom.syntaxThemeData).toEqual(syntaxThemeData);
262+
// The 9-token palette is still kept for collision normalization against diff backgrounds.
263+
expect(custom.syntaxColors.keyword).toBe("#ff00ff");
264+
});
265+
250266
test("withTransparentBackground only swaps painted background fields", () => {
251267
const theme = resolveTheme("github-dark-default", null);
252268
const transparent = withTransparentBackground(theme);

src/ui/themes.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,15 @@ function buildCustomTheme(customTheme: CustomThemeConfig) {
290290
noteBackground: customTheme.noteBackground ?? baseTheme.noteBackground,
291291
noteTitleBackground: customTheme.noteTitleBackground ?? baseTheme.noteTitleBackground,
292292
noteTitleText: customTheme.noteTitleText ?? baseTheme.noteTitleText,
293-
// Explicit syntax color overrides should use Hunk's semantic remap path rather than the
294-
// inherited Shiki theme, otherwise the overrides would never affect highlighted code.
295-
syntaxTheme: customTheme.syntax ? undefined : baseTheme.syntaxTheme,
293+
// A full Shiki theme JSON wins: highlight from its own tokens for source-accurate color.
294+
// Otherwise explicit 9-token overrides use Hunk's semantic remap path (so they actually
295+
// affect highlighted code), and a bare custom palette inherits the base theme's syntax.
296+
syntaxTheme: customTheme.syntaxThemeData
297+
? customTheme.syntaxThemeData.name
298+
: customTheme.syntax
299+
? undefined
300+
: baseTheme.syntaxTheme,
301+
syntaxThemeData: customTheme.syntaxThemeData,
296302
};
297303

298304
return withLazySyntaxStyle(themeBase, {

src/ui/themes/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SyntaxStyle } from "@opentui/core";
2+
import type { CustomSyntaxThemeData } from "../../core/types";
23

34
export interface AppTheme {
45
id: string;
@@ -39,6 +40,8 @@ export interface AppTheme {
3940
noteTitleText: string;
4041
/** Optional Shiki/Pierre theme name for source-accurate code highlighting. */
4142
syntaxTheme?: string;
43+
/** Optional full Shiki theme JSON registered with the highlighter under `syntaxTheme`. */
44+
syntaxThemeData?: CustomSyntaxThemeData;
4245
syntaxColors: SyntaxColors;
4346
syntaxStyle: SyntaxStyle;
4447
}

0 commit comments

Comments
 (0)