Skip to content
Merged
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/bright-comments-listen.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.

Press `t` in the app, or choose `View -> Themes…`, to open the theme selector.

### Git integration

Expand Down
61 changes: 51 additions & 10 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
);

Expand All @@ -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"),
);

Expand All @@ -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",
},
});
});
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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"),
);

Expand All @@ -695,7 +736,7 @@ describe("config resolution", () => {
expect(bootstrap.customTheme).toEqual({
base: "catppuccin-mocha",
accent: "#7755aa",
syntax: {
syntaxScopes: {
comment: "#998877",
},
});
Expand Down
76 changes: 47 additions & 29 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>,
): 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;
Expand All @@ -219,18 +207,43 @@ 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<string, unknown>,
): 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<string, unknown>): CustomThemeConfig | undefined {
const customThemeSource = source.custom_theme;
if (!isRecord(customThemeSource)) {
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),
};
Expand All @@ -246,17 +259,22 @@ function readCustomTheme(source: Record<string, unknown>): 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,
Expand All @@ -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,
};
Expand Down
54 changes: 54 additions & 0 deletions src/core/legacySyntaxScopes.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
Loading