diff --git a/.changeset/custom-syntax-theme-json.md b/.changeset/custom-syntax-theme-json.md new file mode 100644 index 00000000..dcba6c38 --- /dev/null +++ b/.changeset/custom-syntax-theme-json.md @@ -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. diff --git a/README.md b/README.md index 761947a2..42d63dc1 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 7715ea5a..1bcfa17d 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -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 }, + }), + ).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 }); diff --git a/src/core/config.ts b/src/core/config.ts index b3750366..0c3272c0 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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"; @@ -8,6 +8,7 @@ import type { CliInput, CommonOptions, CustomSyntaxColorsConfig, + CustomSyntaxThemeData, CustomThemeConfig, LayoutMode, PersistedViewPreferences, @@ -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): CustomThemeConfig | undefined { +function readCustomTheme( + source: Record, + configPath?: string, +): CustomThemeConfig | undefined { const customThemeSource = source.custom_theme; if (!isRecord(customThemeSource)) { return undefined; @@ -181,6 +226,12 @@ function readCustomTheme(source: Record): 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) { @@ -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 ? { @@ -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); diff --git a/src/core/types.ts b/src/core/types.ts index 9da14f08..f6c102dd 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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; diff --git a/src/ui/diff/pierre.test.ts b/src/ui/diff/pierre.test.ts index a315e823..cef310e4 100644 --- a/src/ui/diff/pierre.test.ts +++ b/src/ui/diff/pierre.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseDiffFromFile } from "@pierre/diffs"; +import { disposeHighlighter, parseDiffFromFile } from "@pierre/diffs"; import type { DiffFile } from "../../core/types"; import { buildSplitRows, @@ -667,6 +667,59 @@ describe("Pierre diff rows", () => { } }); + test("registers custom syntax themes under a collision-safe internal name", async () => { + const metadata = parseDiffFromFile( + { name: "syntax.ts", contents: "const a = 1;\n", cacheKey: "collision-before" }, + { + name: "syntax.ts", + contents: "export const a = 2;\n", + cacheKey: "collision-after", + }, + { context: 3 }, + true, + ); + const file: DiffFile = { + id: "custom-syntax-collision", + path: "syntax.ts", + patch: "", + language: "typescript", + stats: { additions: 1, deletions: 1 }, + metadata, + agent: null, + }; + + await disposeHighlighter(); + await loadHighlightedDiff(file, resolveTheme("dracula", null)); + + const theme = resolveTheme("custom", null, { + base: "github-dark-default", + syntaxThemeData: { + name: "dracula", + type: "dark", + colors: { + "editor.background": "#000000", + "editor.foreground": "#ffffff", + }, + tokenColors: [ + { + scope: ["keyword", "storage", "storage.type"], + settings: { foreground: "#ff00ff" }, + }, + ], + }, + }); + const highlighted = await loadHighlightedDiff(file, theme); + const spans = buildStackRows(file, highlighted, theme) + .filter( + (row): row is Extract => + row.type === "stack-line" && row.cell.kind === "addition", + ) + .flatMap((row) => row.cell.spans); + + expect(theme.syntaxTheme).toBe("hunk-custom-syntax:dracula"); + expect(spans.find((span) => span.text.includes("export"))?.fg?.toLowerCase()).toBe("#ff00ff"); + }); + test("uses Shiki's bundled Catppuccin theme for Catppuccin syntax", async () => { const metadata = parseDiffFromFile( { name: "syntax.ts", contents: "const a = 1;\n", cacheKey: "catppuccin-before" }, diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts index 41af3497..8790b3a9 100644 --- a/src/ui/diff/pierre.ts +++ b/src/ui/diff/pierre.ts @@ -2,6 +2,7 @@ import { cleanLastNewline, getHighlighterOptions, getSharedHighlighter, + registerCustomTheme, renderDiffWithHighlighter, renderFileWithHighlighter, type FileContents, @@ -571,8 +572,39 @@ export function trailingCollapsedLines(metadata: FileDiffMetadata) { return Math.max(additionRemaining, 0); } +// Custom Shiki themes are registered once with Pierre's global theme registry. Track which +// internal ids we've registered so repeated highlight passes don't re-register (Pierre warns on +// dupes). Hunk uses a namespaced id instead of the JSON's own `name` so user themes can share names +// with Shiki's bundled themes without hitting Pierre's resolved-theme cache for the built-in. +const registeredCustomSyntaxThemes = new Set(); + +/** 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; + const themeName = highlighterThemeName(theme); + if (!data || registeredCustomSyntaxThemes.has(themeName)) { + return; + } + + registeredCustomSyntaxThemes.add(themeName); + // Pierre requires the loader result's `name` to match the requested theme key, so clone the JSON + // with Hunk's internal registration id while keeping the original data untouched for callers. + type CustomThemeLoader = Parameters[1]; + const loader: CustomThemeLoader = () => + Promise.resolve({ + ...data, + name: themeName, + } as unknown as Awaited>); + registerCustomTheme(themeName, loader); +} + /** 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}`; diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts index 2d72a32d..595d87d6 100644 --- a/src/ui/themes.test.ts +++ b/src/ui/themes.test.ts @@ -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("hunk-custom-syntax: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); diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 78a91864..ab6bfdc0 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -1,5 +1,5 @@ import type { ThemeMode } from "@opentui/core"; -import type { CustomThemeConfig } from "../core/types"; +import type { CustomSyntaxThemeData, CustomThemeConfig } from "../core/types"; import { blendHex, contrastRatio, relativeLuminance } from "./lib/color"; import { BUNDLED_SHIKI_THEME_IDS, @@ -250,6 +250,13 @@ function fallbackTheme(themeMode?: ThemeMode | null) { return builtInThemeById(fallbackId) ?? THEMES[0]!; } +const CUSTOM_SYNTAX_THEME_PREFIX = "hunk-custom-syntax:"; + +/** Return the collision-safe internal id used when registering a config-provided Shiki theme. */ +function customSyntaxThemeId(themeData: CustomSyntaxThemeData) { + return `${CUSTOM_SYNTAX_THEME_PREFIX}${themeData.name}`; +} + /** Build one config-defined custom theme by inheriting from a Shiki-backed base palette. */ function buildCustomTheme(customTheme: CustomThemeConfig) { const baseTheme = builtInThemeById(customTheme.base) ?? fallbackTheme(); @@ -290,9 +297,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 + ? customSyntaxThemeId(customTheme.syntaxThemeData) + : customTheme.syntax + ? undefined + : baseTheme.syntaxTheme, + syntaxThemeData: customTheme.syntaxThemeData, }; return withLazySyntaxStyle(themeBase, { diff --git a/src/ui/themes/types.ts b/src/ui/themes/types.ts index b884af7a..0587803e 100644 --- a/src/ui/themes/types.ts +++ b/src/ui/themes/types.ts @@ -1,4 +1,5 @@ import type { SyntaxStyle } from "@opentui/core"; +import type { CustomSyntaxThemeData } from "../../core/types"; export interface AppTheme { id: string; @@ -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; }