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/high-contrast-theme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Add high-contrast dark and light built-in themes.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ You can persist preferences to a config file:
Example:

```toml
theme = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn, custom
theme = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn, high-contrast-dark, high-contrast-light, custom
mode = "auto" # auto, split, stack
vcs = "git" # git, jj, sl
watch = false
Expand All @@ -139,7 +139,7 @@ Custom themes can inherit from any built-in base theme and override only the col
theme = "custom"

[custom_theme]
base = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn
base = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn, high-contrast-dark, high-contrast-light
label = "My Theme"
accent = "#7fd1ff"
panel = "#10161d"
Expand Down
26 changes: 13 additions & 13 deletions docs/opentui-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,19 +190,19 @@ If you need direct access to Pierre's parser, `parsePatchFiles(...)` is still re

## Common props

| Prop | Type | Default | Notes |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------- |
| `layout` | `"split" \| "stack"` | `"split"` | Chooses side-by-side or stacked rendering. |
| `width` | `number` | — | Required content width in terminal columns. |
| `theme` | `"graphite" \| "midnight" \| "paper" \| "ember" \| "catppuccin-latte" \| "catppuccin-frappe" \| "catppuccin-macchiato" \| "catppuccin-mocha" \| "zenburn"` | `"graphite"` | Matches Hunk's built-in themes. |
| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. |
| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. |
| `showFileSeparators` | `boolean` | `true` | Toggles separator rows between files in `HunkReviewStream`. |
| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. |
| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. |
| `highlight` | `boolean` | `true` | Enables syntax highlighting. |
| `selectedHunkIndex` | `number` | `0` | Highlights one hunk as the active target for single-file components. |
| `scrollable` | `boolean` | `true` | `HunkDiffView` only; primitives should be wrapped in OpenTUI scrollbox when needed. |
| Prop | Type | Default | Notes |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------- |
| `layout` | `"split" \| "stack"` | `"split"` | Chooses side-by-side or stacked rendering. |
| `width` | `number` | — | Required content width in terminal columns. |
| `theme` | `"graphite" \| "midnight" \| "paper" \| "ember" \| "catppuccin-latte" \| "catppuccin-frappe" \| "catppuccin-macchiato" \| "catppuccin-mocha" \| "zenburn" \| "high-contrast-dark" \| "high-contrast-light"` | `"graphite"` | Matches Hunk's built-in themes. |
| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. |
| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. |
| `showFileSeparators` | `boolean` | `true` | Toggles separator rows between files in `HunkReviewStream`. |
| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. |
| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. |
| `highlight` | `boolean` | `true` | Enables syntax highlighting. |
| `selectedHunkIndex` | `number` | `0` | Highlights one hunk as the active target for single-file components. |
| `scrollable` | `boolean` | `true` | `HunkDiffView` only; primitives should be wrapped in OpenTUI scrollbox when needed. |

## Other exports

Expand Down
4 changes: 3 additions & 1 deletion src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ describe("config resolution", () => {
"catppuccin-macchiato",
"catppuccin-mocha",
"zenburn",
"high-contrast-dark",
"high-contrast-light",
])("accepts custom theme base id: %s", (base) => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
Expand Down Expand Up @@ -188,7 +190,7 @@ describe("config resolution", () => {
env: { HOME: home },
}),
).toThrow(
"Expected custom_theme.base to be one of: graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn.",
"Expected custom_theme.base to be one of: graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn, high-contrast-dark, high-contrast-light.",
);
});

Expand Down
2 changes: 2 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const BUILT_IN_THEME_IDS = [
"catppuccin-macchiato",
"catppuccin-mocha",
"zenburn",
"high-contrast-dark",
"high-contrast-light",
] as const;
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
const CUSTOM_THEME_COLOR_KEYS = [
Expand Down
2 changes: 2 additions & 0 deletions src/opentui/HunkDiffView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ describe("OpenTUI public components", () => {
"catppuccin-macchiato",
"catppuccin-mocha",
"zenburn",
"high-contrast-dark",
"high-contrast-light",
]);
});
});
2 changes: 2 additions & 0 deletions src/opentui/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export const HUNK_DIFF_THEME_NAMES = [
"catppuccin-macchiato",
"catppuccin-mocha",
"zenburn",
"high-contrast-dark",
"high-contrast-light",
] as const;

export type HunkDiffThemeName = (typeof HUNK_DIFF_THEME_NAMES)[number];
6 changes: 6 additions & 0 deletions src/ui/lib/ui-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ describe("ui helpers", () => {
"Catppuccin Macchiato",
"Catppuccin Mocha",
"Zenburn",
"High Contrast Dark",
"High Contrast Light",
]);
expect(
menus.theme.some(
Expand Down Expand Up @@ -263,6 +265,8 @@ describe("ui helpers", () => {
"Catppuccin Macchiato",
"Catppuccin Mocha",
"Zenburn",
"High Contrast Dark",
"High Contrast Light",
"My Theme",
]);
expect(
Expand Down Expand Up @@ -537,6 +541,8 @@ describe("ui helpers", () => {
expect(custom.syntaxColors.keyword).toBe("#123456");
expect(missingCustom.id).toBe("graphite");
expect(resolveTheme("ember", null).syntaxStyle).toBeDefined();
expect(resolveTheme("high-contrast-dark", null).syntaxStyle).toBeDefined();
expect(resolveTheme("high-contrast-light", null).syntaxStyle).toBeDefined();
expect(custom.syntaxStyle).toBeDefined();
expect(resolveTheme("catppuccin-latte", null).syntaxStyle).toBeDefined();
expect(resolveTheme("catppuccin-frappe", null).syntaxStyle).toBeDefined();
Expand Down
35 changes: 35 additions & 0 deletions src/ui/themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,41 @@ describe("themes", () => {
});
});

test("resolves high-contrast variants by theme id with strong text contrast", () => {
const dark = resolveTheme("high-contrast-dark", null);
const light = resolveTheme("high-contrast-light", null);

expect(dark.id).toBe("high-contrast-dark");
expect(dark.label).toBe("High Contrast Dark");
expect(dark.appearance).toBe("dark");
expect(dark.background).toBe("#000000");
expect(dark.text).toBe("#ffffff");
expect(hexColorDistance(dark.text, dark.background)).toBeGreaterThan(700);
expect(hexColorDistance(dark.addedContentBg, dark.contextBg)).toBeGreaterThan(
hexColorDistance(dark.addedBg, dark.contextBg),
);
expect(hexColorDistance(dark.removedContentBg, dark.contextBg)).toBeGreaterThan(
hexColorDistance(dark.removedBg, dark.contextBg),
);
expect(hexColorDistance(dark.addedContentBg, dark.addedBg)).toBeGreaterThanOrEqual(90);
expect(hexColorDistance(dark.removedContentBg, dark.removedBg)).toBeGreaterThanOrEqual(90);

expect(light.id).toBe("high-contrast-light");
expect(light.label).toBe("High Contrast Light");
expect(light.appearance).toBe("light");
expect(light.background).toBe("#ffffff");
expect(light.text).toBe("#000000");
expect(hexColorDistance(light.text, light.background)).toBeGreaterThan(700);
expect(hexColorDistance(light.addedContentBg, light.contextBg)).toBeGreaterThan(
hexColorDistance(light.addedBg, light.contextBg),
);
expect(hexColorDistance(light.removedContentBg, light.contextBg)).toBeGreaterThan(
hexColorDistance(light.removedBg, light.contextBg),
);
expect(hexColorDistance(light.addedContentBg, light.addedBg)).toBeGreaterThanOrEqual(90);
expect(hexColorDistance(light.removedContentBg, light.removedBg)).toBeGreaterThanOrEqual(90);
});

test("withTransparentBackground only swaps painted background fields", () => {
const theme = resolveTheme("graphite", null);
const transparent = withTransparentBackground(theme);
Expand Down
3 changes: 3 additions & 0 deletions src/ui/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "./themes/catppuccin";
import { EMBER_THEME } from "./themes/ember";
import { GRAPHITE_THEME } from "./themes/graphite";
import { HIGH_CONTRAST_DARK_THEME, HIGH_CONTRAST_LIGHT_THEME } from "./themes/highContrast";
import { MIDNIGHT_THEME } from "./themes/midnight";
import { PAPER_THEME } from "./themes/paper";
import { withLazySyntaxStyle } from "./themes/syntax";
Expand All @@ -29,6 +30,8 @@ export const THEMES: AppTheme[] = [
CATPPUCCIN_MACCHIATO_THEME,
CATPPUCCIN_MOCHA_THEME,
ZENBURN_THEME,
HIGH_CONTRAST_DARK_THEME,
HIGH_CONTRAST_LIGHT_THEME,
];

/** Return the built-in theme by id so config-defined themes can inherit from it. */
Expand Down
108 changes: 108 additions & 0 deletions src/ui/themes/highContrast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { withLazySyntaxStyle } from "./syntax";
import type { AppTheme } from "./types";

/** High-contrast dark theme with black surfaces and bright semantic accents. */
export const HIGH_CONTRAST_DARK_THEME: AppTheme = withLazySyntaxStyle(
{
id: "high-contrast-dark",
label: "High Contrast Dark",
appearance: "dark",
background: "#000000",
panel: "#050505",
panelAlt: "#101010",
border: "#8a8a8a",
accent: "#ffffff",
accentMuted: "#707070",
text: "#ffffff",
muted: "#c4c4c4",
addedBg: "#00381e",
removedBg: "#4a0009",
movedAddedBg: "#003a52",
movedRemovedBg: "#3d145f",
contextBg: "#0b0b0b",
addedContentBg: "#008a45",
removedContentBg: "#a00016",
contextContentBg: "#171717",
addedSignColor: "#00ff66",
removedSignColor: "#ff5f6d",
lineNumberBg: "#000000",
lineNumberFg: "#d0d0d0",
selectedHunk: "#005f87",
badgeAdded: "#00ff66",
badgeRemoved: "#ff5f6d",
badgeNeutral: "#ffffff",
fileNew: "#00ff66",
fileDeleted: "#ff5f6d",
fileRenamed: "#ffd75f",
fileModified: "#d787ff",
fileUntracked: "#5fd7ff",
noteBorder: "#d787ff",
noteBackground: "#1e1028",
noteTitleBackground: "#3b1457",
noteTitleText: "#ffffff",
},
{
default: "#ffffff",
keyword: "#5fd7ff",
string: "#87ff87",
comment: "#c4c4c4",
number: "#ffd75f",
function: "#ffffff",
property: "#87d7ff",
type: "#d787ff",
punctuation: "#e4e4e4",
},
);

/** High-contrast light theme with white surfaces and strong semantic colors. */
export const HIGH_CONTRAST_LIGHT_THEME: AppTheme = withLazySyntaxStyle(
{
id: "high-contrast-light",
label: "High Contrast Light",
appearance: "light",
background: "#ffffff",
panel: "#fafafa",
panelAlt: "#f0f0f0",
border: "#000000",
accent: "#000000",
accentMuted: "#b0b0b0",
text: "#000000",
muted: "#404040",
addedBg: "#d7ffd7",
removedBg: "#ffd7d7",
movedAddedBg: "#d7f2ff",
movedRemovedBg: "#f0ddff",
contextBg: "#ffffff",
addedContentBg: "#8cff8c",
removedContentBg: "#ff8f8f",
contextContentBg: "#f5f5f5",
addedSignColor: "#006b1f",
removedSignColor: "#a00016",
lineNumberBg: "#f0f0f0",
lineNumberFg: "#202020",
selectedHunk: "#ffd400",
badgeAdded: "#006b1f",
badgeRemoved: "#a00016",
badgeNeutral: "#000000",
fileNew: "#006b1f",
fileDeleted: "#a00016",
fileRenamed: "#6f4d00",
fileModified: "#5f0087",
fileUntracked: "#005f87",
noteBorder: "#5f0087",
noteBackground: "#f1ddff",
noteTitleBackground: "#dcb8ff",
noteTitleText: "#000000",
},
{
default: "#000000",
keyword: "#005f87",
string: "#006b1f",
comment: "#404040",
number: "#6f4d00",
function: "#000000",
property: "#005f87",
type: "#5f0087",
punctuation: "#404040",
},
);