From c907dad3da147ad81e4e31252b9848c501faea31 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 18 Jul 2026 19:12:37 -0400 Subject: [PATCH 1/2] feat(theme): support raw Shiki syntax scopes --- .changeset/bright-comments-listen.md | 5 + README.md | 17 +- src/core/config.test.ts | 61 +++- src/core/config.ts | 76 +++-- src/core/legacySyntaxScopes.test.ts | 54 ++++ src/core/legacySyntaxScopes.ts | 77 ++++++ src/core/types.ts | 6 + src/ui/diff/pierre.test.ts | 337 ++++++++++++++--------- src/ui/diff/pierre.ts | 102 +------ src/ui/diff/syntaxHighlightTheme.test.ts | 26 ++ src/ui/diff/syntaxHighlightTheme.ts | 79 ++++++ src/ui/diff/useHighlightedDiff.ts | 3 +- src/ui/diff/useHighlightedSource.ts | 3 +- src/ui/lib/ui-lib.test.ts | 6 +- src/ui/staticDiffPager.test.ts | 41 +++ src/ui/themes.test.ts | 7 +- src/ui/themes.ts | 13 +- src/ui/themes/types.ts | 4 +- 18 files changed, 626 insertions(+), 291 deletions(-) create mode 100644 .changeset/bright-comments-listen.md create mode 100644 src/core/legacySyntaxScopes.test.ts create mode 100644 src/core/legacySyntaxScopes.ts create mode 100644 src/ui/diff/syntaxHighlightTheme.test.ts create mode 100644 src/ui/diff/syntaxHighlightTheme.ts diff --git a/.changeset/bright-comments-listen.md b/.changeset/bright-comments-listen.md new file mode 100644 index 00000000..1a556108 --- /dev/null +++ b/.changeset/bright-comments-listen.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add exact Shiki/TextMate syntax color overrides under `custom_theme.syntax_scopes`, while temporarily translating the deprecated `custom_theme.syntax` role table for compatibility. diff --git a/README.md b/README.md index 04251bdb..bd43bfa7 100644 --- a/README.md +++ b/README.md @@ -155,15 +155,18 @@ accent = "#7fd1ff" panel = "#10161d" noteBorder = "#c49bff" -[custom_theme.syntax] -keyword = "#8ed4ff" -string = "#c7b4ff" -comment = "#6e85a7" -operator = "#7fd1ff" -variable = "#eef4ff" +[custom_theme.syntax_scopes] +"comment" = "#6e85a7" +"punctuation.definition.comment" = "#6e85a7" +"keyword.operator" = "#7fd1ff" +"entity.name.function" = "#8ed4ff" ``` -All custom theme colors must use `#rrggbb` hex values. Press `t` in the app, or choose `View -> Themes…`, to open the theme selector. +`syntax_scopes` uses [Shiki/TextMate scope selectors](https://shiki.style/guide/themes#token-colors) directly, so matching and precedence follow Shiki's theme rules without a Hunk-specific translation layer. Quote selectors containing dots. Declaration order is preserved; later rules win when matching selectors have equal specificity. All custom theme colors must use `#rrggbb` hex values. + +The former `[custom_theme.syntax]` role table is deprecated but temporarily translated into approximate scopes for compatibility. Both tables can coexist while migrating, and an exact `syntax_scopes` entry overrides a translated entry with the same selector. Because semantic roles have no one-to-one TextMate mapping, migrate when practical: for example, replace `comment = "#ffffff"` with both `"comment" = "#ffffff"` and `"punctuation.definition.comment" = "#ffffff"` under `[custom_theme.syntax_scopes]`, adding language-specific selectors when a grammar uses more specific scopes. The compatibility table will be removed in the next major release. + +Press `t` in the app, or choose `View -> Themes…`, to open the theme selector. ### Git integration diff --git a/src/core/config.test.ts b/src/core/config.test.ts index d8decc50..cf8bc09b 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -229,8 +229,8 @@ describe("config resolution", () => { 'label = "Global Custom"', 'accent = "#123456"', "", - "[custom_theme.syntax]", - 'keyword = "#abcdef"', + "[custom_theme.syntax_scopes]", + '"keyword.control" = "#abcdef"', ].join("\n"), ); @@ -244,8 +244,8 @@ describe("config resolution", () => { 'label = "Repo Custom"', 'panel = "#654321"', "", - "[custom_theme.syntax]", - 'string = "#fedcba"', + "[custom_theme.syntax_scopes]", + '"string.quoted" = "#fedcba"', ].join("\n"), ); @@ -260,9 +260,9 @@ describe("config resolution", () => { label: "Repo Custom", accent: "#123456", panel: "#654321", - syntax: { - keyword: "#abcdef", - string: "#fedcba", + syntaxScopes: { + "keyword.control": "#abcdef", + "string.quoted": "#fedcba", }, }); }); @@ -334,6 +334,47 @@ describe("config resolution", () => { ).toThrow("Expected custom_theme.accent to be a hex color like #112233."); }); + test("rejects invalid Shiki scope colors", () => { + const home = createTempDir("hunk-config-home-"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync( + join(home, ".config", "hunk", "config.toml"), + ["[custom_theme.syntax_scopes]", '"comment.line" = "white"'].join("\n"), + ); + + expect(() => + resolveConfiguredCliInput(createPatchPagerInput(), { + cwd: createTempDir("hunk-config-cwd-"), + env: { HOME: home }, + }), + ).toThrow("Expected custom_theme.syntax_scopes.comment.line to be a hex color like #112233."); + }); + + test("temporarily translates the deprecated semantic syntax table into exact scopes", () => { + const home = createTempDir("hunk-config-home-"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync( + join(home, ".config", "hunk", "config.toml"), + [ + "[custom_theme.syntax]", + 'comment = "#ffffff"', + "", + "[custom_theme.syntax_scopes]", + '"comment" = "#eeeeee"', + ].join("\n"), + ); + + const resolved = resolveConfiguredCliInput(createPatchPagerInput(), { + cwd: createTempDir("hunk-config-cwd-"), + env: { HOME: home }, + }); + + expect(resolved.customTheme?.syntaxScopes).toEqual({ + comment: "#eeeeee", + "punctuation.definition.comment": "#ffffff", + }); + }); + test("rejects theme = custom when no [custom_theme] table is configured", () => { const home = createTempDir("hunk-config-home-"); mkdirSync(join(home, ".config", "hunk"), { recursive: true }); @@ -670,8 +711,8 @@ describe("config resolution", () => { 'base = "catppuccin-mocha"', 'accent = "#7755aa"', "", - "[custom_theme.syntax]", - 'comment = "#998877"', + "[custom_theme.syntax_scopes]", + '"comment" = "#998877"', ].join("\n"), ); @@ -695,7 +736,7 @@ describe("config resolution", () => { expect(bootstrap.customTheme).toEqual({ base: "catppuccin-mocha", accent: "#7755aa", - syntax: { + syntaxScopes: { comment: "#998877", }, }); diff --git a/src/core/config.ts b/src/core/config.ts index f95b2113..41fd371f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -2,12 +2,14 @@ import fs from "node:fs"; import { dirname, join } from "node:path"; import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes"; import { normalizeBuiltInThemeId } from "../ui/themes"; +import { LEGACY_CUSTOM_SYNTAX_COLOR_KEYS, resolveSyntaxScopeOverrides } from "./legacySyntaxScopes"; import { resolveGlobalConfigPath } from "./paths"; import { detectVcs, findVcsRepoRootCandidate, getDefaultVcsAdapter, isVcsId } from "./vcs"; import type { CliInput, CommonOptions, CustomSyntaxColorsConfig, + CustomSyntaxScopesConfig, CustomThemeConfig, LayoutMode, PersistedViewPreferences, @@ -51,20 +53,6 @@ const CUSTOM_THEME_COLOR_KEYS = [ "noteTitleBackground", "noteTitleText", ] as const; -const CUSTOM_SYNTAX_COLOR_KEYS = [ - "default", - "keyword", - "string", - "comment", - "number", - "function", - "property", - "type", - "variable", - "operator", - "punctuation", -] as const; - const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = { mode: "auto", showLineNumbers: true, @@ -203,13 +191,13 @@ function normalizeCustomThemeBase(value: unknown) { return resolvedThemeId; } -/** Read the nested syntax color overrides from a [custom_theme.syntax] TOML table. */ -function readCustomSyntaxColors( +/** Read the deprecated semantic colors retained for one compatibility release window. */ +function readLegacyCustomSyntaxColors( source: Record, ): CustomSyntaxColorsConfig | undefined { const syntax: CustomSyntaxColorsConfig = {}; - for (const key of CUSTOM_SYNTAX_COLOR_KEYS) { + for (const key of LEGACY_CUSTOM_SYNTAX_COLOR_KEYS) { const value = normalizeThemeColor(source[key], `custom_theme.syntax.${key}`); if (value !== undefined) { syntax[key] = value; @@ -219,6 +207,26 @@ function readCustomSyntaxColors( return Object.keys(syntax).length > 0 ? syntax : undefined; } +/** Read exact Shiki/TextMate scope colors from a [custom_theme.syntax_scopes] TOML table. */ +function readCustomSyntaxScopes( + source: Record, +): CustomSyntaxScopesConfig | undefined { + const syntaxScopes: CustomSyntaxScopesConfig = {}; + + for (const [scope, rawColor] of Object.entries(source)) { + if (scope.trim().length === 0) { + throw new Error("Expected custom_theme.syntax_scopes keys to be non-empty Shiki scopes."); + } + + const color = normalizeThemeColor(rawColor, `custom_theme.syntax_scopes.${scope}`); + if (color !== undefined) { + syntaxScopes[scope] = color; + } + } + + return Object.keys(syntaxScopes).length > 0 ? syntaxScopes : undefined; +} + /** Read the optional config-defined custom theme palette from one TOML object level. */ function readCustomTheme(source: Record): CustomThemeConfig | undefined { const customThemeSource = source.custom_theme; @@ -226,11 +234,16 @@ function readCustomTheme(source: Record): CustomThemeConfig | u return undefined; } - const syntaxSource = customThemeSource.syntax; - if (syntaxSource !== undefined && !isRecord(syntaxSource)) { + const legacySyntaxSource = customThemeSource.syntax; + if (legacySyntaxSource !== undefined && !isRecord(legacySyntaxSource)) { throw new Error("Expected custom_theme.syntax to contain a TOML table."); } + const syntaxScopesSource = customThemeSource.syntax_scopes; + if (syntaxScopesSource !== undefined && !isRecord(syntaxScopesSource)) { + throw new Error("Expected custom_theme.syntax_scopes to contain a TOML table."); + } + const customTheme: CustomThemeConfig = { base: normalizeCustomThemeBase(customThemeSource.base), }; @@ -246,17 +259,22 @@ function readCustomTheme(source: Record): CustomThemeConfig | u } } - if (isRecord(syntaxSource)) { - const syntax = readCustomSyntaxColors(syntaxSource); - if (syntax) { - customTheme.syntax = syntax; - } + const legacySyntax = isRecord(legacySyntaxSource) + ? readLegacyCustomSyntaxColors(legacySyntaxSource) + : undefined; + const exactSyntaxScopes = isRecord(syntaxScopesSource) + ? readCustomSyntaxScopes(syntaxScopesSource) + : undefined; + const syntaxScopes = resolveSyntaxScopeOverrides(legacySyntax, exactSyntaxScopes); + if (syntaxScopes) { + // Normalize legacy config at the boundary so every runtime highlighter uses raw scopes only. + customTheme.syntaxScopes = syntaxScopes; } return customTheme; } -/** Merge partial custom theme layers while keeping nested syntax overrides field-based. */ +/** Merge partial custom theme layers while keeping exact syntax scope overrides field-based. */ function mergeCustomTheme( base: CustomThemeConfig | undefined, overrides: CustomThemeConfig | undefined, @@ -273,11 +291,11 @@ function mergeCustomTheme( ...overrides, base: overrides.base ?? base.base ?? "github-dark-default", label: overrides.label ?? base.label, - syntax: - base.syntax || overrides.syntax + syntaxScopes: + base.syntaxScopes || overrides.syntaxScopes ? { - ...base.syntax, - ...overrides.syntax, + ...base.syntaxScopes, + ...overrides.syntaxScopes, } : undefined, }; diff --git a/src/core/legacySyntaxScopes.test.ts b/src/core/legacySyntaxScopes.test.ts new file mode 100644 index 00000000..a63f94d9 --- /dev/null +++ b/src/core/legacySyntaxScopes.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { legacySyntaxColorsToScopes, resolveSyntaxScopeOverrides } from "./legacySyntaxScopes"; + +describe("legacy syntax scope compatibility", () => { + test("translates every deprecated semantic role into raw TextMate selectors", () => { + expect( + legacySyntaxColorsToScopes({ + default: "#000001", + keyword: "#000002", + string: "#000003", + comment: "#000004", + number: "#000005", + function: "#000006", + property: "#000007", + type: "#000008", + variable: "#000009", + operator: "#00000a", + punctuation: "#00000b", + }), + ).toEqual({ + source: "#000001", + keyword: "#000002", + string: "#000003", + comment: "#000004", + "punctuation.definition.comment": "#000004", + "constant.numeric": "#000005", + "entity.name.function": "#000006", + "support.function": "#000006", + "variable.function": "#000006", + "variable.other.property": "#000007", + "support.variable.property": "#000007", + "entity.name.type": "#000008", + "entity.name.class": "#000008", + "support.type": "#000008", + "support.class": "#000008", + variable: "#000009", + "keyword.operator": "#00000a", + punctuation: "#00000b", + }); + }); + + test("lets exact scope configuration override translated compatibility rules", () => { + expect( + resolveSyntaxScopeOverrides( + { comment: "#111111" }, + { comment: "#222222", "comment.block": "#333333" }, + ), + ).toEqual({ + comment: "#222222", + "punctuation.definition.comment": "#111111", + "comment.block": "#333333", + }); + }); +}); diff --git a/src/core/legacySyntaxScopes.ts b/src/core/legacySyntaxScopes.ts new file mode 100644 index 00000000..b796d3e8 --- /dev/null +++ b/src/core/legacySyntaxScopes.ts @@ -0,0 +1,77 @@ +import type { CustomSyntaxColorsConfig, CustomSyntaxScopesConfig } from "./types"; + +/** Deprecated role keys accepted only during the temporary configuration migration window. */ +export const LEGACY_CUSTOM_SYNTAX_COLOR_KEYS = [ + "default", + "keyword", + "string", + "comment", + "number", + "function", + "property", + "type", + "variable", + "operator", + "punctuation", +] as const satisfies readonly (keyof CustomSyntaxColorsConfig)[]; + +/** + * Temporary compatibility map for the deprecated [custom_theme.syntax] role table. + * + * Keep this adapter isolated from the highlighter: new code must use exact TextMate scopes through + * [custom_theme.syntax_scopes]. Remove this file in the next major release after the migration + * window. These selectors are intentionally approximate because semantic roles do not have a + * one-to-one relationship with language-specific TextMate grammars. + */ +const LEGACY_SYNTAX_ROLE_SCOPES: Record = { + default: ["source"], + keyword: ["keyword"], + string: ["string"], + comment: ["comment", "punctuation.definition.comment"], + number: ["constant.numeric"], + function: ["entity.name.function", "support.function", "variable.function"], + property: ["variable.other.property", "support.variable.property"], + type: ["entity.name.type", "entity.name.class", "support.type", "support.class"], + variable: ["variable"], + operator: ["keyword.operator"], + punctuation: ["punctuation"], +}; + +/** Translate deprecated semantic colors into approximate TextMate scope rules. */ +export function legacySyntaxColorsToScopes( + syntax: CustomSyntaxColorsConfig | undefined, +): CustomSyntaxScopesConfig | undefined { + if (!syntax) { + return undefined; + } + + const scopes: CustomSyntaxScopesConfig = {}; + for (const role of LEGACY_CUSTOM_SYNTAX_COLOR_KEYS) { + const color = syntax[role]; + if (!color) { + continue; + } + + for (const scope of LEGACY_SYNTAX_ROLE_SCOPES[role]) { + scopes[scope] = color; + } + } + + return Object.keys(scopes).length > 0 ? scopes : undefined; +} + +/** Layer exact scopes after translated legacy roles so migration overrides remain authoritative. */ +export function resolveSyntaxScopeOverrides( + syntax: CustomSyntaxColorsConfig | undefined, + syntaxScopes: CustomSyntaxScopesConfig | undefined, +): CustomSyntaxScopesConfig | undefined { + const legacyScopes = legacySyntaxColorsToScopes(syntax); + if (!legacyScopes) { + return syntaxScopes; + } + if (!syntaxScopes) { + return legacyScopes; + } + + return { ...legacyScopes, ...syntaxScopes }; +} diff --git a/src/core/types.ts b/src/core/types.ts index fdb09e16..3eaf7043 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -100,6 +100,7 @@ export interface CommonOptions { colorMoved?: boolean; } +/** @deprecated Use exact TextMate selectors through CustomSyntaxScopesConfig instead. */ export interface CustomSyntaxColorsConfig { default?: string; keyword?: string; @@ -114,6 +115,9 @@ export interface CustomSyntaxColorsConfig { punctuation?: string; } +/** Exact Shiki/TextMate selector-to-hex-color overrides, preserved in declaration order. */ +export type CustomSyntaxScopesConfig = Record; + export interface CustomThemeConfig { base?: string; label?: string; @@ -150,7 +154,9 @@ export interface CustomThemeConfig { noteBackground?: string; noteTitleBackground?: string; noteTitleText?: string; + /** @deprecated Use syntaxScopes. This compatibility field will be removed next major. */ syntax?: CustomSyntaxColorsConfig; + syntaxScopes?: CustomSyntaxScopesConfig; } export interface PersistedViewPreferences { diff --git a/src/ui/diff/pierre.test.ts b/src/ui/diff/pierre.test.ts index b4d56614..644df05f 100644 --- a/src/ui/diff/pierre.test.ts +++ b/src/ui/diff/pierre.test.ts @@ -77,36 +77,6 @@ function createEmptyLineDiffFile(): DiffFile { }; } -function createMarkdownDiffFile(): DiffFile { - const metadata = parseDiffFromFile( - { - name: "notes.md", - contents: "plain\n", - cacheKey: "before-md", - }, - { - name: "notes.md", - contents: "# Heading\n`inline code`\nplain\n", - cacheKey: "after-md", - }, - { context: 3 }, - true, - ); - - return { - id: "notes-md", - path: "notes.md", - patch: "", - language: "markdown", - stats: { - additions: 2, - deletions: 0, - }, - metadata, - agent: null, - }; -} - describe("Pierre diff rows", () => { test("builds split rows with Pierre-highlighted emphasis spans", async () => { const file = createDiffFile(); @@ -453,55 +423,40 @@ describe("Pierre diff rows", () => { ); }); - test("remaps Pierre markdown reds and greens away from diff-semantic hues", async () => { - const file = createMarkdownDiffFile(); - - for (const themeId of [ - "github-dark-default", - "github-light-default", - "catppuccin-latte", - "catppuccin-frappe", - "catppuccin-macchiato", - "catppuccin-mocha", - ] as const) { - const theme = resolveTheme(themeId, null); - const highlighted = await loadHighlightedDiff(file, theme.appearance); - const rows = buildStackRows(file, highlighted, theme).filter( - (row): row is Extract => - row.type === "stack-line" && row.cell.kind === "addition", - ); - - const headingRow = rows.find((row) => - row.cell.spans.some((span) => span.text.includes("Heading")), - ); - const inlineCodeRow = rows.find((row) => - row.cell.spans.some((span) => span.text.includes("inline code")), - ); + test("applies distinct custom palettes to expanded-source highlighting", async () => { + const file = createDiffFile(); + const text = "// expanded comment\nexport const hiddenMarker = true;\n"; + const firstTheme = resolveTheme("custom", null, { + base: "nord", + syntaxScopes: { + "comment.line.double-slash.ts": "#abcdef", + "punctuation.definition.comment.ts": "#abcdef", + }, + }); + const secondTheme = resolveTheme("custom", null, { + base: "nord", + syntaxScopes: { + "comment.line.double-slash.ts": "#fedcba", + "punctuation.definition.comment.ts": "#fedcba", + }, + }); + const [firstHighlighted, secondHighlighted] = await Promise.all([ + loadHighlightedSourceLines({ file, text, theme: firstTheme }), + loadHighlightedSourceLines({ file, text, theme: secondTheme }), + ]); + const firstSpans = spansForHighlightedSourceLine( + "// expanded comment", + firstHighlighted.lines[0], + firstTheme, + ); + const secondSpans = spansForHighlightedSourceLine( + "// expanded comment", + secondHighlighted.lines[0], + secondTheme, + ); - expect(headingRow).toBeDefined(); - expect(inlineCodeRow).toBeDefined(); - - if (!headingRow || !inlineCodeRow) { - throw new Error("Expected highlighted markdown rows"); - } - - expect( - headingRow.cell.spans.some( - (span) => span.text.includes("Heading") && span.fg === theme.syntaxColors.keyword, - ), - ).toBe(true); - expect( - inlineCodeRow.cell.spans.some( - (span) => span.text.includes("inline code") && span.fg === theme.syntaxColors.string, - ), - ).toBe(true); - expect( - headingRow.cell.spans.some((span) => span.fg === "#ff6762" || span.fg === "#d52c36"), - ).toBe(false); - expect( - inlineCodeRow.cell.spans.some((span) => span.fg === "#5ecc71" || span.fg === "#199f43"), - ).toBe(false); - } + expect(firstSpans[0]?.fg?.toLowerCase()).toBe("#abcdef"); + expect(secondSpans[0]?.fg?.toLowerCase()).toBe("#fedcba"); }); test("collapsed rows carry line ranges and position on both layouts", () => { @@ -585,58 +540,13 @@ describe("Pierre diff rows", () => { expect(between?.newRange).toEqual([9, 21]); }); - test("keeps reserved-color remaps isolated across dark themes", async () => { - const file = createMarkdownDiffFile(); - const highlighted = await loadHighlightedDiff(file, "dark"); - - for (const themeId of [ - "github-dark-default", - "github-dark-default", - "dracula", - "catppuccin-frappe", - "catppuccin-macchiato", - "catppuccin-mocha", - ] as const) { - const theme = resolveTheme(themeId, null); - const rows = buildStackRows(file, highlighted, theme).filter( - (row): row is Extract => - row.type === "stack-line" && row.cell.kind === "addition", - ); - - const headingRow = rows.find((row) => - row.cell.spans.some((span) => span.text.includes("Heading")), - ); - const inlineCodeRow = rows.find((row) => - row.cell.spans.some((span) => span.text.includes("inline code")), - ); - - expect(headingRow).toBeDefined(); - expect(inlineCodeRow).toBeDefined(); - - if (!headingRow || !inlineCodeRow) { - throw new Error("Expected highlighted markdown rows"); - } - - expect( - headingRow.cell.spans.some( - (span) => span.text.includes("Heading") && span.fg === theme.syntaxColors.keyword, - ), - ).toBe(true); - expect( - inlineCodeRow.cell.spans.some( - (span) => span.text.includes("inline code") && span.fg === theme.syntaxColors.string, - ), - ).toBe(true); - } - }); - - test("maps Pierre TypeScript syntax hues onto theme syntax colors in dark and light", async () => { + test("passes exact Shiki scope colors through in dark and light", async () => { const metadata = parseDiffFromFile( - { name: "syntax.ts", contents: "const a = 1;\n", cacheKey: "syntax-before" }, + { name: "syntax.ts", contents: "", cacheKey: "syntax-before" }, { name: "syntax.ts", contents: - 'const a = 1;\nexport function compute(): number {\n return 42;\n}\nconst greeting = "hello";\n', + '// visible comment\nexport class Greeter {\n count = 42;\n greet(user: User) {\n const message = "hello" + user.name;\n return message;\n }\n}\n', cacheKey: "syntax-after", }, { context: 3 }, @@ -647,7 +557,7 @@ describe("Pierre diff rows", () => { path: "syntax.ts", patch: "", language: "typescript", - stats: { additions: 4, deletions: 0 }, + stats: { additions: 8, deletions: 0 }, metadata, agent: null, }; @@ -655,13 +565,20 @@ describe("Pierre diff rows", () => { for (const themeId of ["github-dark-default", "github-light-default"] as const) { const theme = resolveTheme("custom", null, { base: themeId, - syntax: { - keyword: "#112233", - function: "#223344", - string: "#334455", + syntaxScopes: { + "storage.type.class.ts": "#112233", + "entity.name.function.ts": "#223344", + "string.quoted.double.ts": "#334455", + comment: "#445566", + "constant.numeric.decimal.ts": "#556677", + "variable.other.property.ts": "#667788", + "entity.name.type.class.ts": "#778899", + "variable.other.constant.ts": "#8899aa", + "keyword.operator.assignment.ts": "#99aabb", + "punctuation.terminator.statement.ts": "#aabbcc", }, }); - const highlighted = await loadHighlightedDiff(file, theme.appearance); + const highlighted = await loadHighlightedDiff(file, theme); const spans = buildStackRows(file, highlighted, theme) .filter( (row): row is Extract => @@ -669,12 +586,162 @@ describe("Pierre diff rows", () => { ) .flatMap((row) => row.cell.spans); - expect(spans.find((span) => span.text.includes("function"))?.fg).toBe("#112233"); - expect(spans.find((span) => span.text.includes("compute"))?.fg).toBe("#223344"); - expect(spans.find((span) => span.text.includes('"hello"'))?.fg).toBe("#334455"); + expect(spans.find((span) => span.text.includes("class"))?.fg).toBe("#112233"); + expect(spans.find((span) => span.text.includes("greet"))?.fg).toBe("#223344"); + expect(spans.find((span) => span.text.includes("hello"))?.fg).toBe("#334455"); + expect(spans.find((span) => span.text.includes("visible comment"))?.fg).toBe("#445566"); + expect(spans.find((span) => span.text.includes("42"))?.fg).toBe("#556677"); + expect(spans.find((span) => span.text.includes("name"))?.fg).toBe("#667788"); + expect(spans.find((span) => span.text.includes("Greeter"))?.fg).toBe("#778899"); + expect(spans.find((span) => span.text.includes("message"))?.fg?.toLowerCase()).toBe( + "#8899aa", + ); + expect(spans.find((span) => span.text.includes("="))?.fg?.toLowerCase()).toBe("#99aabb"); + expect(spans.find((span) => span.text === ";")?.fg?.toLowerCase()).toBe("#aabbcc"); } }); + test("preserves base Shiki colors outside partial custom syntax overrides", async () => { + const metadata = parseDiffFromFile( + { name: "partial.ts", contents: "const stable = 1;\n", cacheKey: "partial-before" }, + { + name: "partial.ts", + contents: + '// customized comment\nconst stable = 1;\nconst object = { property: "text" };\nobject.property;\n', + cacheKey: "partial-after", + }, + { context: 3 }, + true, + ); + const file: DiffFile = { + id: "partial-syntax", + path: "partial.ts", + patch: "", + language: "typescript", + stats: { additions: 3, deletions: 0 }, + metadata, + agent: null, + }; + const baseTheme = resolveTheme("nord", null); + const customTheme = resolveTheme("custom", null, { + base: "nord", + syntaxScopes: { + "comment.line.double-slash.ts": "#abcdef", + "punctuation.definition.comment.ts": "#abcdef", + }, + }); + const nextCustomTheme = resolveTheme("custom", null, { + base: "nord", + syntaxScopes: { + "comment.line.double-slash.ts": "#fedcba", + "punctuation.definition.comment.ts": "#fedcba", + }, + }); + const variableTheme = resolveTheme("custom", null, { + base: "nord", + syntaxScopes: { "variable.other.object.ts": "#030303" }, + }); + const [baseHighlighted, customHighlighted, nextCustomHighlighted, variableHighlighted] = + await Promise.all([ + loadHighlightedDiff(file, baseTheme), + loadHighlightedDiff(file, customTheme), + loadHighlightedDiff(file, nextCustomTheme), + loadHighlightedDiff(file, variableTheme), + ]); + const baseSpans = buildStackRows(file, baseHighlighted, baseTheme) + .filter( + (row): row is Extract => + row.type === "stack-line" && row.cell.kind === "addition", + ) + .flatMap((row) => row.cell.spans); + const customSpans = buildStackRows(file, customHighlighted, customTheme) + .filter( + (row): row is Extract => + row.type === "stack-line" && row.cell.kind === "addition", + ) + .flatMap((row) => row.cell.spans); + const nextCustomSpans = buildStackRows(file, nextCustomHighlighted, nextCustomTheme) + .filter( + (row): row is Extract => + row.type === "stack-line" && row.cell.kind === "addition", + ) + .flatMap((row) => row.cell.spans); + const variableSpans = buildStackRows(file, variableHighlighted, variableTheme) + .filter( + (row): row is Extract => + row.type === "stack-line" && row.cell.kind === "addition", + ) + .flatMap((row) => row.cell.spans); + + expect(customSpans.find((span) => span.text.includes("const"))?.fg).toBe( + baseSpans.find((span) => span.text.includes("const"))?.fg, + ); + expect( + customSpans.find((span) => span.text.includes("customized comment"))?.fg?.toLowerCase(), + ).toBe("#abcdef"); + expect( + nextCustomSpans.find((span) => span.text.includes("customized comment"))?.fg?.toLowerCase(), + ).toBe("#fedcba"); + expect( + variableSpans.some( + (span) => span.text.includes("object") && span.fg?.toLowerCase() === "#030303", + ), + ).toBe(true); + expect(variableSpans.filter((span) => span.text.includes("property")).at(-1)?.fg).toBe( + baseSpans.filter((span) => span.text.includes("property")).at(-1)?.fg, + ); + }); + + test("leaves unrelated tokens unchanged when a raw operator scope is overridden", async () => { + const metadata = parseDiffFromFile( + { name: "operator.ts", contents: "", cacheKey: "operator-before" }, + { + name: "operator.ts", + contents: "class Example {}\nconst result = 1 + 2;\n", + cacheKey: "operator-after", + }, + { context: 3 }, + true, + ); + const file: DiffFile = { + id: "operator-scope", + path: "operator.ts", + patch: "", + language: "typescript", + stats: { additions: 2, deletions: 0 }, + metadata, + agent: null, + }; + const baseTheme = resolveTheme("everforest-dark", null); + const customTheme = resolveTheme("custom", null, { + base: "everforest-dark", + syntaxScopes: { "keyword.operator": "#123456" }, + }); + const [baseHighlighted, customHighlighted] = await Promise.all([ + loadHighlightedDiff(file, baseTheme), + loadHighlightedDiff(file, customTheme), + ]); + const baseSpans = buildStackRows(file, baseHighlighted, baseTheme) + .filter( + (row): row is Extract => + row.type === "stack-line" && row.cell.kind === "addition", + ) + .flatMap((row) => row.cell.spans); + const customSpans = buildStackRows(file, customHighlighted, customTheme) + .filter( + (row): row is Extract => + row.type === "stack-line" && row.cell.kind === "addition", + ) + .flatMap((row) => row.cell.spans); + + expect(customSpans.find((span) => span.text.includes("class"))?.fg).toBe( + baseSpans.find((span) => span.text.includes("class"))?.fg, + ); + expect( + customSpans.some((span) => span.text.includes("=") && span.fg?.toLowerCase() === "#123456"), + ).toBe(true); + }); + 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..a5e0ab87 100644 --- a/src/ui/diff/pierre.ts +++ b/src/ui/diff/pierre.ts @@ -13,35 +13,22 @@ import { blendHex, hexColorDistance } from "../lib/color"; import { sanitizeTerminalLine } from "../../lib/terminalText"; import { TRANSPARENT_BACKGROUND, type AppTheme } from "../themes"; import { expandDiffTabs } from "./codeColumns"; - -const PIERRE_THEME = { - light: "pierre-light", - dark: "pierre-dark", -} as const; +import { + ensureSyntaxHighlightThemeRegistered, + syntaxHighlightThemeName, +} from "./syntaxHighlightTheme"; type HighlightThemeInput = AppTheme | AppTheme["appearance"]; -/** Resolve the default Pierre theme name needed for one light/dark appearance. */ -function pierreThemeName(appearance: AppTheme["appearance"]) { - return PIERRE_THEME[appearance]; -} - /** Return the light/dark mode for a theme object or legacy appearance argument. */ function highlightThemeAppearance(theme: HighlightThemeInput) { return typeof theme === "string" ? theme : theme.appearance; } -/** Resolve the Shiki/Pierre syntax theme that should color highlighted code. */ -function highlighterThemeName(theme: HighlightThemeInput) { - return typeof theme === "string" - ? pierreThemeName(theme) - : (theme.syntaxTheme ?? pierreThemeName(theme.appearance)); -} - /** Build render options for the active syntax theme. */ function pierreRenderOptions(theme: HighlightThemeInput) { return { - theme: highlighterThemeName(theme), + theme: syntaxHighlightThemeName(theme), useTokenTransformer: false, tokenizeMaxLineLength: 1_000, lineDiffType: "word-alt" as const, @@ -214,45 +201,6 @@ function parseStyleValue(styleValue: unknown) { return styles; } -const RESERVED_PIERRE_TOKEN_COLORS = { - dark: { - "#ff6762": "keyword", - "#ff855e": "keyword", - "#ff678d": "keyword", - "#d568ea": "keyword", - "#9d6afb": "function", - "#ffab16": "default", - "#ffca00": "default", - "#68cdf2": "number", - "#5ecc71": "string", - "#ffa359": "property", - "#a3a3a3": "variable", - "#08c0ef": "operator", - "#636363": "punctuation", - }, - light: { - "#d52c36": "keyword", - "#d5512f": "keyword", - "#d32a61": "keyword", - "#fc2b73": "keyword", - "#a631be": "keyword", - "#c635e4": "keyword", - "#693acf": "function", - "#7b43f8": "function", - "#d5901c": "default", - "#d5a910": "default", - "#1ca1c7": "number", - "#199f43": "string", - "#d47628": "property", - "#a3a3a3": "variable", - "#08c0ef": "operator", - "#636363": "punctuation", - }, -} as const; -// After style parsing, token colors still need one normalization step so syntax hues never -// collide with diff-semantic add/remove colors. Cache that remap per theme because themes that -// share an appearance can still use different syntax palettes. -const normalizedColorCache = new Map>(); // The expensive part after highlighting is walking Pierre's HAST line tree and flattening it // into terminal spans. The same highlighted line objects are reused when files remount or when // we build both split and stack rows, so memoize flattened spans by line node + theme/background. @@ -328,37 +276,6 @@ function wordDiffHighlightBg(kind: SplitLineCell["kind"], theme: AppTheme) { return cached[kind]; } -/** Remap Pierre token hues that collide with diff add/remove semantics into theme-safe syntax colors. */ -function normalizeHighlightedColor(color: string | undefined, theme: AppTheme) { - if (!color) { - return color; - } - - const themeKey = themeRenderCacheKey(theme); - let cacheForTheme = normalizedColorCache.get(themeKey); - if (!cacheForTheme) { - cacheForTheme = new Map(); - normalizedColorCache.set(themeKey, cacheForTheme); - } - - const cached = cacheForTheme.get(color); - if (cached) { - return cached; - } - - const normalized = color.trim().toLowerCase(); - const reserved = - RESERVED_PIERRE_TOKEN_COLORS[theme.appearance][ - normalized as keyof (typeof RESERVED_PIERRE_TOKEN_COLORS)[typeof theme.appearance] - ]; - const resolvedColor = reserved - ? (theme.syntaxColors[reserved] ?? - (reserved === "operator" ? theme.syntaxColors.punctuation : theme.syntaxColors.default)) - : color; - cacheForTheme.set(color, resolvedColor); - return resolvedColor; -} - /** Append a span while coalescing adjacent runs with identical colors. */ function mergeSpan(target: RenderSpan[], next: RenderSpan) { if (next.text.length === 0) { @@ -413,11 +330,8 @@ function flattenHighlightedLine(node: HastNode | undefined, theme: AppTheme, emp const properties = current.properties ?? {}; const styles = parseStyleValue(properties.style); const nextStyle: Pick = { - // Newer Pierre output can emit direct `color:#...` styles instead of theme CSS variables. - fg: normalizeHighlightedColor( - styles.get(colorVariable) ?? styles.get("color") ?? inherited.fg, - theme, - ), + // The registered Shiki theme has already applied any user-authored scope colors. + fg: styles.get(colorVariable) ?? styles.get("color") ?? inherited.fg, // Pierre marks inline word-diff emphasis spans with a data attribute rather than a separate row kind. bg: Object.hasOwn(properties, "data-diff-span") ? emphasisBg : inherited.bg, }; @@ -574,7 +488,7 @@ export function trailingCollapsedLines(metadata: FileDiffMetadata) { /** Prepare syntax highlighting for one language/theme pair using Pierre's shared highlighter. */ async function prepareHighlighter(language: string | undefined, theme: HighlightThemeInput) { const resolvedLanguage = language ?? "text"; - const syntaxTheme = highlighterThemeName(theme); + const syntaxTheme = ensureSyntaxHighlightThemeRegistered(theme); const cacheKey = `${syntaxTheme}:${resolvedLanguage}`; const options = highlighterOptionsByKey.get(cacheKey) ?? diff --git a/src/ui/diff/syntaxHighlightTheme.test.ts b/src/ui/diff/syntaxHighlightTheme.test.ts new file mode 100644 index 00000000..575040a9 --- /dev/null +++ b/src/ui/diff/syntaxHighlightTheme.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { resolveTheme } from "../themes"; +import { syntaxHighlightThemeName } from "./syntaxHighlightTheme"; + +describe("syntaxHighlightThemeName", () => { + test("includes precedence-sensitive TextMate rule order in the theme identity", () => { + const broadRuleFirst = resolveTheme("custom", null, { + base: "github-dark-default", + syntaxScopes: { + comment: "#111111", + "comment, string": "#222222", + }, + }); + const broadRuleLast = resolveTheme("custom", null, { + base: "github-dark-default", + syntaxScopes: { + "comment, string": "#222222", + comment: "#111111", + }, + }); + + expect(syntaxHighlightThemeName(broadRuleFirst)).not.toBe( + syntaxHighlightThemeName(broadRuleLast), + ); + }); +}); diff --git a/src/ui/diff/syntaxHighlightTheme.ts b/src/ui/diff/syntaxHighlightTheme.ts new file mode 100644 index 00000000..e09c8c43 --- /dev/null +++ b/src/ui/diff/syntaxHighlightTheme.ts @@ -0,0 +1,79 @@ +import { createHash } from "node:crypto"; +import { + registerCustomTheme, + resolveTheme as resolvePierreTheme, + type ThemeRegistrationResolved, +} from "@pierre/diffs"; +import type { AppTheme } from "../themes"; + +const PIERRE_THEME = { + light: "pierre-light", + dark: "pierre-dark", +} as const; + +const registeredSyntaxThemes = new Set(); + +/** Build a stable id so each distinct scope palette gets its own Shiki cache entry. */ +function syntaxThemeFingerprint(value: string) { + return createHash("sha256").update(value).digest("hex").slice(0, 16); +} + +/** Derive a Shiki theme by appending user-authored TextMate scope colors unchanged. */ +async function buildCustomSyntaxTheme( + name: string, + baseThemeName: string, + scopeOverrides: Record, +) { + const baseTheme = await resolvePierreTheme(baseThemeName); + const scopeSettings = Object.entries(scopeOverrides).map(([scope, foreground]) => ({ + scope, + settings: { foreground }, + })); + + return { + ...baseTheme, + name, + settings: [...(baseTheme.settings ?? []), ...scopeSettings], + } satisfies ThemeRegistrationResolved; +} + +/** Resolve the content-addressed syntax theme name used for one Hunk app theme. */ +export function syntaxHighlightThemeName(theme: AppTheme | AppTheme["appearance"]) { + if (typeof theme === "string") { + return PIERRE_THEME[theme]; + } + + const baseThemeName = theme.syntaxTheme ?? PIERRE_THEME[theme.appearance]; + // TextMate resolves equally specific rules by declaration order, so order is part of identity. + const orderedOverrides = Object.entries(theme.syntaxScopeOverrides ?? {}).map( + ([scope, color]) => [scope, color.toLowerCase()], + ); + const fingerprintInput = JSON.stringify({ baseThemeName, orderedOverrides }); + + return orderedOverrides.length > 0 + ? `hunk-custom-${syntaxThemeFingerprint(fingerprintInput)}` + : baseThemeName; +} + +/** Register a derived scope theme before Pierre asks its shared highlighter to resolve it. */ +export function ensureSyntaxHighlightThemeRegistered(theme: AppTheme | AppTheme["appearance"]) { + const themeName = syntaxHighlightThemeName(theme); + if (typeof theme === "string" || !theme.syntaxScopeOverrides) { + return themeName; + } + + const baseThemeName = theme.syntaxTheme ?? PIERRE_THEME[theme.appearance]; + if (themeName === baseThemeName) { + return themeName; + } + + if (!registeredSyntaxThemes.has(themeName)) { + const capturedOverrides = { ...theme.syntaxScopeOverrides }; + registerCustomTheme(themeName, () => + buildCustomSyntaxTheme(themeName, baseThemeName, capturedOverrides), + ); + registeredSyntaxThemes.add(themeName); + } + + return themeName; +} diff --git a/src/ui/diff/useHighlightedDiff.ts b/src/ui/diff/useHighlightedDiff.ts index 74ab5774..dd1e6cbf 100644 --- a/src/ui/diff/useHighlightedDiff.ts +++ b/src/ui/diff/useHighlightedDiff.ts @@ -2,6 +2,7 @@ import { useLayoutEffect, useState } from "react"; import type { DiffFile } from "../../core/types"; import type { AppTheme } from "../themes"; import { loadHighlightedDiff, type HighlightedDiffCode } from "./pierre"; +import { syntaxHighlightThemeName } from "./syntaxHighlightTheme"; /** * Maximum cached highlight results. @@ -80,7 +81,7 @@ function patchFingerprint(file: DiffFile) { /** Cache key that includes a content fingerprint so stale entries are never served * after reload. Unchanged files keep their cache hit across reloads. */ function buildCacheKey(theme: AppTheme, file: DiffFile) { - return `${theme.id}:${theme.syntaxTheme ?? theme.appearance}:${file.id}:${patchFingerprint(file)}`; + return `${theme.id}:${syntaxHighlightThemeName(theme)}:${file.id}:${patchFingerprint(file)}`; } /** Only commit a highlight result if the promise is still the active one for that key. diff --git a/src/ui/diff/useHighlightedSource.ts b/src/ui/diff/useHighlightedSource.ts index 94897449..6f54ee43 100644 --- a/src/ui/diff/useHighlightedSource.ts +++ b/src/ui/diff/useHighlightedSource.ts @@ -2,6 +2,7 @@ import { useLayoutEffect, useMemo, useState } from "react"; import type { DiffFile } from "../../core/types"; import type { AppTheme } from "../themes"; import { loadHighlightedSourceLines, type HighlightedSourceCode } from "./pierre"; +import { syntaxHighlightThemeName } from "./syntaxHighlightTheme"; interface HighlightedSourceState { cacheKey: string; @@ -22,7 +23,7 @@ function sourceTextFingerprint(text: string) { /** Cache key for full-source highlights used by expanded unchanged rows. */ function buildSourceCacheKey(theme: AppTheme, file: DiffFile, text: string) { - return `${theme.id}:${theme.syntaxTheme ?? theme.appearance}:${file.id}:${file.path}:${file.language ?? ""}:${sourceTextFingerprint(text)}`; + return `${theme.id}:${syntaxHighlightThemeName(theme)}:${file.id}:${file.path}:${file.language ?? ""}:${sourceTextFingerprint(text)}`; } /** Resolve highlighted full-source content for expanded unchanged rows. */ diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 098ea5cb..a7a644ea 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -451,8 +451,8 @@ describe("ui helpers", () => { base: "github-light-default", label: "My Theme", accent: "#7755aa", - syntax: { - keyword: "#123456", + syntaxScopes: { + "keyword.control": "#123456", }, }); const missingCustom = resolveTheme("custom", null); @@ -466,7 +466,7 @@ describe("ui helpers", () => { expect(custom.label).toBe("My Theme"); expect(custom.appearance).toBe("light"); expect(custom.accent).toBe("#7755aa"); - expect(custom.syntaxColors.keyword).toBe("#123456"); + expect(custom.syntaxScopeOverrides).toEqual({ "keyword.control": "#123456" }); expect(missingCustom.id).toBe("github-dark-default"); expect(resolveTheme("github-dark-default", null).syntaxStyle).toBeDefined(); expect(custom.syntaxStyle).toBeDefined(); diff --git a/src/ui/staticDiffPager.test.ts b/src/ui/staticDiffPager.test.ts index 99a69b0e..c381f342 100644 --- a/src/ui/staticDiffPager.test.ts +++ b/src/ui/staticDiffPager.test.ts @@ -109,6 +109,47 @@ describe("static diff pager", () => { expect(output).toContain("\x1b[38;2;18;52;86m"); }); + test("translates deprecated semantic comment colors in static pager output", async () => { + const patchText = + "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1,2 @@\n+// visible comment\n const value = 1;\n"; + + const output = await renderStaticDiffPager( + patchText, + { theme: "custom" }, + { + customTheme: { + base: "nord", + syntax: { comment: "#ff00ff" }, + }, + }, + ); + + expect(stripAnsi(output)).toContain("// visible comment"); + expect(output).toContain("\x1b[38;2;255;0;255m"); + }); + + test("applies raw Shiki comment scopes in static pager output", async () => { + const patchText = + "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1,2 @@\n+// visible comment\n const value = 1;\n"; + + const output = await renderStaticDiffPager( + patchText, + { theme: "custom" }, + { + customTheme: { + base: "nord", + syntaxScopes: { + comment: "#ff00ff", + "punctuation.definition.comment": "#ff00ff", + }, + }, + }, + ); + + expect(stripAnsi(output)).toContain("// visible comment"); + expect(output).toContain("\x1b[38;2;255;0;255m"); + }); + test("keeps only added/removed backgrounds when transparent background is requested", async () => { const patchText = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n const a = 1;\n-const value = 1;\n+const value = 2;\n const z = 3;\n"; diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts index 355c9d3a..5685f86a 100644 --- a/src/ui/themes.test.ts +++ b/src/ui/themes.test.ts @@ -235,15 +235,16 @@ describe("themes", () => { base: "catppuccin-mocha", label: "My Theme", text: "#ffffff", - syntax: { keyword: "#ff00ff" }, + syntaxScopes: { "keyword.control": "#ff00ff" }, }); expect(custom.id).toBe("custom"); expect(custom.label).toBe("My Theme"); expect(custom.background).toBe(resolveTheme("catppuccin-mocha", null).background); expect(custom.text).toBe("#ffffff"); - expect(custom.syntaxTheme).toBeUndefined(); - expect(custom.syntaxColors.keyword).toBe("#ff00ff"); + expect(custom.syntaxTheme).toBe("catppuccin-mocha"); + expect(custom.syntaxScopeOverrides).toEqual({ "keyword.control": "#ff00ff" }); + expect(custom.syntaxColors).toBe(resolveTheme("catppuccin-mocha", null).syntaxColors); }); test("withTransparentSurfaces keeps added/removed row tints", () => { diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 29126738..43cd0cec 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -1,4 +1,5 @@ import type { ThemeMode } from "@opentui/core"; +import { resolveSyntaxScopeOverrides } from "../core/legacySyntaxScopes"; import type { CustomThemeConfig } from "../core/types"; import { blendHex, contrastRatio, relativeLuminance } from "./lib/color"; import { @@ -290,15 +291,13 @@ 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, + // Keep the source-accurate base theme and pass exact TextMate selectors through unchanged. + // The diff highlighter registers that derived palette with Pierre by content hash. + syntaxTheme: baseTheme.syntaxTheme, + syntaxScopeOverrides: resolveSyntaxScopeOverrides(customTheme.syntax, customTheme.syntaxScopes), }; - return withLazySyntaxStyle(themeBase, { - ...baseTheme.syntaxColors, - ...customTheme.syntax, - }); + return withLazySyntaxStyle(themeBase, baseTheme.syntaxColors); } /** Return the theme ids the app should expose based on whether config defines a custom palette. */ diff --git a/src/ui/themes/types.ts b/src/ui/themes/types.ts index b884af7a..1b9d879e 100644 --- a/src/ui/themes/types.ts +++ b/src/ui/themes/types.ts @@ -37,8 +37,10 @@ export interface AppTheme { noteBackground: string; noteTitleBackground: string; noteTitleText: string; - /** Optional Shiki/Pierre theme name for source-accurate code highlighting. */ + /** Optional Shiki/Pierre base theme name for source-accurate code highlighting. */ syntaxTheme?: string; + /** Exact Shiki/TextMate scope colors layered onto the base syntax theme. */ + syntaxScopeOverrides?: Record; syntaxColors: SyntaxColors; syntaxStyle: SyntaxStyle; } From e3f0d4faa49f27d9ce7ba2b4f40a2d6286f1f483 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 18 Jul 2026 19:24:00 -0400 Subject: [PATCH 2/2] refactor(theme): clarify syntax override caching --- README.md | 2 +- src/ui/diff/pierre.ts | 44 +++++++++++++------------------------------ src/ui/themes.ts | 1 + 3 files changed, 15 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index bd43bfa7..c0e15dbb 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ noteBorder = "#c49bff" "entity.name.function" = "#8ed4ff" ``` -`syntax_scopes` uses [Shiki/TextMate scope selectors](https://shiki.style/guide/themes#token-colors) directly, so matching and precedence follow Shiki's theme rules without a Hunk-specific translation layer. Quote selectors containing dots. Declaration order is preserved; later rules win when matching selectors have equal specificity. All custom theme colors must use `#rrggbb` hex values. +`syntax_scopes` uses [Shiki/TextMate scope selectors](https://shiki.style/guide/themes#token-colors) directly, so matching and precedence follow Shiki's theme rules without a Hunk-specific translation layer. Quote selectors containing dots. Declaration order is preserved; later rules win when matching selectors have equal specificity, while a more-specific base-theme selector beats a broader override. Add the grammar-specific selector when that happens. All custom theme colors must use `#rrggbb` hex values. The former `[custom_theme.syntax]` role table is deprecated but temporarily translated into approximate scopes for compatibility. Both tables can coexist while migrating, and an exact `syntax_scopes` entry overrides a translated entry with the same selector. Because semantic roles have no one-to-one TextMate mapping, migrate when practical: for example, replace `comment = "#ffffff"` with both `"comment" = "#ffffff"` and `"punctuation.definition.comment" = "#ffffff"` under `[custom_theme.syntax_scopes]`, adding language-specific selectors when a grammar uses more specific scopes. The compatibility table will be removed in the next major release. diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts index a5e0ab87..453ca6c7 100644 --- a/src/ui/diff/pierre.ts +++ b/src/ui/diff/pierre.ts @@ -41,35 +41,6 @@ type HighlightOptions = ReturnType; const highlighterOptionsByKey = new Map(); let queuedHighlightWork = Promise.resolve(); -/** Build a cache key for theme-dependent terminal colors, not just the stable UI theme id. */ -function themeRenderCacheKey(theme: AppTheme) { - return [ - theme.id, - theme.syntaxTheme ?? "", - theme.appearance, - theme.background, - theme.panelAlt, - theme.contextBg, - theme.addedBg, - theme.removedBg, - theme.addedContentBg, - theme.removedContentBg, - theme.addedSignColor, - theme.removedSignColor, - theme.syntaxColors.default, - theme.syntaxColors.keyword, - theme.syntaxColors.string, - theme.syntaxColors.comment, - theme.syntaxColors.number, - theme.syntaxColors.function, - theme.syntaxColors.property, - theme.syntaxColors.type, - theme.syntaxColors.variable ?? "", - theme.syntaxColors.operator ?? "", - theme.syntaxColors.punctuation, - ].join(":"); -} - type HastNode = HastTextNode | HastElementNode; interface HastTextNode { @@ -250,7 +221,16 @@ function resolveWordDiffHighlightBg(contentBg: string, lineBg: string, signColor /** Resolve the inline word-diff background, strengthening theme colors that are too subtle to see. */ function wordDiffHighlightBg(kind: SplitLineCell["kind"], theme: AppTheme) { - const cacheKey = [themeRenderCacheKey(theme), theme.contextContentBg, theme.panelAlt].join(":"); + const cacheKey = [ + theme.addedContentBg, + theme.addedBg, + theme.addedSignColor, + theme.removedContentBg, + theme.removedBg, + theme.removedSignColor, + theme.contextContentBg, + theme.panelAlt, + ].join(":"); let cached = wordDiffBackgroundCache.get(cacheKey); if (!cached) { const addition = resolveWordDiffHighlightBg( @@ -297,7 +277,9 @@ function flattenHighlightedLine(node: HastNode | undefined, theme: AppTheme, emp return []; } - const cacheKey = `${themeRenderCacheKey(theme)}:${emphasisBg}`; + // The highlighted HAST node is already unique to the content-addressed Shiki theme. Only + // post-highlight choices belong in the inner key; syntax identity comes from the WeakMap key. + const cacheKey = `${theme.appearance}:${emphasisBg}`; const cachedByTheme = flattenedHighlightedLineCache.get(node); const cached = cachedByTheme?.get(cacheKey); if (cached) { diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 43cd0cec..7a68c295 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -294,6 +294,7 @@ function buildCustomTheme(customTheme: CustomThemeConfig) { // Keep the source-accurate base theme and pass exact TextMate selectors through unchanged. // The diff highlighter registers that derived palette with Pierre by content hash. syntaxTheme: baseTheme.syntaxTheme, + // TOML config is normalized at parse time; repeat the adapter here for direct API callers. syntaxScopeOverrides: resolveSyntaxScopeOverrides(customTheme.syntax, customTheme.syntaxScopes), };