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
5 changes: 5 additions & 0 deletions .changeset/custom-syntax-theme-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

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.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ variable = "#eef4ff"

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

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:

```toml
[custom_theme]
base = "catppuccin-mocha"
syntax_theme = "shades-of-purple.json" # absolute, or relative to this config file
```

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.

### Git integration

Set Hunk as your Git pager so `git diff` and `git show` open in Hunk automatically:
Expand Down
84 changes: 84 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,90 @@ describe("config resolution", () => {
).toThrow("Expected custom_theme.accent to be a hex color like #112233.");
});

test("loads a custom_theme.syntax_theme JSON relative to the config file", () => {
const home = createTempDir("hunk-config-home-");
const hunkDir = join(home, ".config", "hunk");
mkdirSync(hunkDir, { recursive: true });
writeFileSync(
join(hunkDir, "my-theme.json"),
JSON.stringify({ name: "My VS Code Theme", type: "dark", tokenColors: [] }),
);
writeFileSync(
join(hunkDir, "config.toml"),
[
'theme = "custom"',
"",
"[custom_theme]",
'base = "github-dark-default"',
'syntax_theme = "my-theme.json"',
].join("\n"),
);

const resolved = resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
});

expect(resolved.customTheme?.syntaxThemePath).toBe("my-theme.json");
expect(resolved.customTheme?.syntaxThemeData).toEqual({
name: "My VS Code Theme",
type: "dark",
tokenColors: [],
});
});

test("rejects a custom_theme.syntax_theme that does not exist", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(
join(home, ".config", "hunk", "config.toml"),
["[custom_theme]", 'syntax_theme = "missing.json"'].join("\n"),
);

expect(() =>
resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
}),
Comment thread
eduwass marked this conversation as resolved.
).toThrow("Expected custom_theme.syntax_theme to point at a file.");
});

test("rejects a custom_theme.syntax_theme that is not valid JSON", () => {
const home = createTempDir("hunk-config-home-");
const hunkDir = join(home, ".config", "hunk");
mkdirSync(hunkDir, { recursive: true });
writeFileSync(join(hunkDir, "broken.json"), "{ not valid json }");
writeFileSync(
join(hunkDir, "config.toml"),
["[custom_theme]", 'syntax_theme = "broken.json"'].join("\n"),
);

expect(() =>
resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
}),
).toThrow("to be valid JSON:");
});

test("rejects a custom_theme.syntax_theme JSON without a name", () => {
const home = createTempDir("hunk-config-home-");
const hunkDir = join(home, ".config", "hunk");
mkdirSync(hunkDir, { recursive: true });
writeFileSync(join(hunkDir, "nameless.json"), JSON.stringify({ type: "dark" }));
writeFileSync(
join(hunkDir, "config.toml"),
["[custom_theme]", 'syntax_theme = "nameless.json"'].join("\n"),
);

expect(() =>
resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
}),
).toThrow('to be a Shiki theme with a non-empty "name".');
});

test("rejects theme = custom when no [custom_theme] table is configured", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
Expand Down
67 changes: 63 additions & 4 deletions src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs from "node:fs";
import { join } from "node:path";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes";
import { normalizeBuiltInThemeId } from "../ui/themes";
import { resolveGlobalConfigPath } from "./paths";
Expand All @@ -8,6 +8,7 @@ import type {
CliInput,
CommonOptions,
CustomSyntaxColorsConfig,
CustomSyntaxThemeData,
CustomThemeConfig,
LayoutMode,
PersistedViewPreferences,
Expand Down Expand Up @@ -161,8 +162,52 @@ function readCustomSyntaxColors(
return Object.keys(syntax).length > 0 ? syntax : undefined;
}

/**
* Load and validate a full Shiki theme JSON referenced by `custom_theme.syntax_theme`.
* The path may be absolute or relative to the config file that declared it. We read it
* eagerly so a bad path fails fast at config time rather than silently dropping
* highlighting later.
*/
function readCustomSyntaxTheme(
value: unknown,
configPath: string | undefined,
): CustomSyntaxThemeData | undefined {
const rawPath = normalizeString(value);
if (rawPath === undefined) {
return undefined;
}

const basis = configPath ? dirname(configPath) : process.cwd();
const themePath = isAbsolute(rawPath) ? rawPath : resolve(basis, rawPath);

if (!fs.existsSync(themePath)) {
throw new Error(`Expected custom_theme.syntax_theme to point at a file. Missing: ${themePath}`);
}

let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(themePath, "utf8"));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Expected custom_theme.syntax_theme (${themePath}) to be valid JSON: ${reason}`,
);
}

if (!isRecord(parsed) || typeof parsed.name !== "string" || parsed.name.length === 0) {
throw new Error(
`Expected custom_theme.syntax_theme (${themePath}) to be a Shiki theme with a non-empty "name".`,
);
}

return parsed as CustomSyntaxThemeData;
}

/** Read the optional config-defined custom theme palette from one TOML object level. */
function readCustomTheme(source: Record<string, unknown>): CustomThemeConfig | undefined {
function readCustomTheme(
source: Record<string, unknown>,
configPath?: string,
): CustomThemeConfig | undefined {
const customThemeSource = source.custom_theme;
if (!isRecord(customThemeSource)) {
return undefined;
Expand All @@ -181,6 +226,12 @@ function readCustomTheme(source: Record<string, unknown>): CustomThemeConfig | u
customTheme.label = label;
}

const syntaxThemePath = normalizeString(customThemeSource.syntax_theme);
if (syntaxThemePath !== undefined) {
customTheme.syntaxThemePath = syntaxThemePath;
customTheme.syntaxThemeData = readCustomSyntaxTheme(customThemeSource.syntax_theme, configPath);
}

for (const key of CUSTOM_THEME_COLOR_KEYS) {
const value = normalizeThemeColor(customThemeSource[key], `custom_theme.${key}`);
if (value !== undefined) {
Expand Down Expand Up @@ -215,6 +266,8 @@ function mergeCustomTheme(
...overrides,
base: overrides.base ?? base.base ?? "github-dark-default",
label: overrides.label ?? base.label,
syntaxThemePath: overrides.syntaxThemePath ?? base.syntaxThemePath,
syntaxThemeData: overrides.syntaxThemeData ?? base.syntaxThemeData,
syntax:
base.syntax || overrides.syntax
? {
Expand Down Expand Up @@ -333,13 +386,19 @@ export function resolveConfiguredCliInput(
if (userConfigPath) {
const userConfig = readTomlRecord(userConfigPath);
resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(userConfig, input));
resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(userConfig));
resolvedCustomTheme = mergeCustomTheme(
resolvedCustomTheme,
readCustomTheme(userConfig, userConfigPath),
);
}

if (repoConfigPath) {
const repoConfig = readTomlRecord(repoConfigPath);
resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(repoConfig, input));
resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(repoConfig));
resolvedCustomTheme = mergeCustomTheme(
resolvedCustomTheme,
readCustomTheme(repoConfig, repoConfigPath),
);
}

resolvedOptions = mergeOptions(resolvedOptions, input.options);
Expand Down
14 changes: 14 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,23 @@ export interface CustomSyntaxColorsConfig {
punctuation?: string;
}

/**
* A full VS Code / Shiki theme JSON loaded from disk and registered with the
* highlighter for source-accurate syntax coloring. Only `name` is required; the
* remaining TextMate fields are passed through to Shiki untouched.
*/
export interface CustomSyntaxThemeData {
name: string;
[key: string]: unknown;
}

export interface CustomThemeConfig {
base?: string;
label?: string;
/** Path (from config) to a Shiki theme JSON used for syntax highlighting. */
syntaxThemePath?: string;
/** The loaded + validated Shiki theme JSON referenced by `syntaxThemePath`. */
syntaxThemeData?: CustomSyntaxThemeData;
background?: string;
panel?: string;
panelAlt?: string;
Expand Down
31 changes: 31 additions & 0 deletions src/ui/diff/pierre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
cleanLastNewline,
getHighlighterOptions,
getSharedHighlighter,
registerCustomTheme,
renderDiffWithHighlighter,
renderFileWithHighlighter,
type FileContents,
Expand Down Expand Up @@ -571,8 +572,38 @@ export function trailingCollapsedLines(metadata: FileDiffMetadata) {
return Math.max(additionRemaining, 0);
}

// Custom Shiki themes are registered once with Pierre's global theme registry. Track which
// names we've registered so repeated highlight passes don't re-register (Pierre warns on dupes).
// ponytail: dedup is by theme name only. Pierre's registry is itself keyed by name and rejects
// re-registration, so swapping a theme's JSON under the same name within one process would keep
// serving the original tokens. There is no in-process JSON-reload path today (config is read once
// at startup; the theme picker only cycles already-built themes), so this is a non-issue in
// practice. If live theme-file reload is ever added, register under a content-derived name.
const registeredCustomSyntaxThemes = new Set<string>();

/** Register a config-provided Shiki theme JSON with Pierre before it's referenced by name. */
function ensureCustomSyntaxThemeRegistered(theme: HighlightThemeInput) {
if (typeof theme === "string") {
return;
}

const data = theme.syntaxThemeData;
if (!data || registeredCustomSyntaxThemes.has(data.name)) {
return;
}

registeredCustomSyntaxThemes.add(data.name);
// Pierre resolves themes by name against its custom registry first, then Shiki's bundled
// themes. The registry stores async loaders, so hand it one resolving to the loaded JSON.
type CustomThemeLoader = Parameters<typeof registerCustomTheme>[1];
const loader: CustomThemeLoader = () =>
Promise.resolve(data as unknown as Awaited<ReturnType<CustomThemeLoader>>);
registerCustomTheme(data.name, loader);
Comment thread
eduwass marked this conversation as resolved.
}

/** Prepare syntax highlighting for one language/theme pair using Pierre's shared highlighter. */
async function prepareHighlighter(language: string | undefined, theme: HighlightThemeInput) {
ensureCustomSyntaxThemeRegistered(theme);
const resolvedLanguage = language ?? "text";
const syntaxTheme = highlighterThemeName(theme);
const cacheKey = `${syntaxTheme}:${resolvedLanguage}`;
Expand Down
16 changes: 16 additions & 0 deletions src/ui/themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,22 @@ describe("themes", () => {
expect(custom.syntaxColors.keyword).toBe("#ff00ff");
});

test("a full syntax theme JSON drives highlighting by name", () => {
const syntaxThemeData = { name: "Shades of Purple", type: "dark" as const, tokenColors: [] };
const custom = resolveTheme("custom", null, {
base: "catppuccin-mocha",
label: "My Theme",
syntaxThemeData,
// A 9-token block is present too, but the full theme JSON should take precedence.
syntax: { keyword: "#ff00ff" },
});

expect(custom.syntaxTheme).toBe("Shades of Purple");
expect(custom.syntaxThemeData).toEqual(syntaxThemeData);
// The 9-token palette is still kept for collision normalization against diff backgrounds.
expect(custom.syntaxColors.keyword).toBe("#ff00ff");
});

test("withTransparentBackground only swaps painted background fields", () => {
const theme = resolveTheme("github-dark-default", null);
const transparent = withTransparentBackground(theme);
Expand Down
12 changes: 9 additions & 3 deletions src/ui/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,9 +290,15 @@ function buildCustomTheme(customTheme: CustomThemeConfig) {
noteBackground: customTheme.noteBackground ?? baseTheme.noteBackground,
noteTitleBackground: customTheme.noteTitleBackground ?? baseTheme.noteTitleBackground,
noteTitleText: customTheme.noteTitleText ?? baseTheme.noteTitleText,
// Explicit syntax color overrides should use Hunk's semantic remap path rather than the
// inherited Shiki theme, otherwise the overrides would never affect highlighted code.
syntaxTheme: customTheme.syntax ? undefined : baseTheme.syntaxTheme,
// A full Shiki theme JSON wins: highlight from its own tokens for source-accurate color.
// Otherwise explicit 9-token overrides use Hunk's semantic remap path (so they actually
// affect highlighted code), and a bare custom palette inherits the base theme's syntax.
syntaxTheme: customTheme.syntaxThemeData
? customTheme.syntaxThemeData.name
: customTheme.syntax
? undefined
: baseTheme.syntaxTheme,
syntaxThemeData: customTheme.syntaxThemeData,
};

return withLazySyntaxStyle(themeBase, {
Expand Down
3 changes: 3 additions & 0 deletions src/ui/themes/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SyntaxStyle } from "@opentui/core";
import type { CustomSyntaxThemeData } from "../../core/types";

export interface AppTheme {
id: string;
Expand Down Expand Up @@ -39,6 +40,8 @@ export interface AppTheme {
noteTitleText: string;
/** Optional Shiki/Pierre theme name for source-accurate code highlighting. */
syntaxTheme?: string;
/** Optional full Shiki theme JSON registered with the highlighter under `syntaxTheme`. */
syntaxThemeData?: CustomSyntaxThemeData;
syntaxColors: SyntaxColors;
syntaxStyle: SyntaxStyle;
}
Expand Down