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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

- Added Catppuccin Latte and Mocha as built-in themes.

### Changed

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion 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
theme = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-mocha
mode = "auto" # auto, split, stack
vcs = "git" # git, jj
exclude_untracked = false
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"` | `"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-mocha"` | `"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
9 changes: 8 additions & 1 deletion src/opentui/HunkDiffView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ describe("OpenTUI public components", () => {
});

test("exports the documented built-in theme names", () => {
expect(HUNK_DIFF_THEME_NAMES).toEqual(["graphite", "midnight", "paper", "ember"]);
expect(HUNK_DIFF_THEME_NAMES).toEqual([
"graphite",
"midnight",
"paper",
"ember",
"catppuccin-latte",
"catppuccin-mocha",
]);
});
});
9 changes: 8 additions & 1 deletion src/opentui/themes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
export const HUNK_DIFF_THEME_NAMES = ["graphite", "midnight", "paper", "ember"] as const;
export const HUNK_DIFF_THEME_NAMES = [
"graphite",
"midnight",
"paper",
"ember",
"catppuccin-latte",
"catppuccin-mocha",
] as const;

export type HunkDiffThemeName = (typeof HUNK_DIFF_THEME_NAMES)[number];
4 changes: 2 additions & 2 deletions src/ui/diff/pierre.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ describe("Pierre diff rows", () => {
test("remaps Pierre markdown reds and greens away from diff-semantic hues", async () => {
const file = createMarkdownDiffFile();

for (const themeId of ["midnight", "paper"] as const) {
for (const themeId of ["midnight", "paper", "catppuccin-latte", "catppuccin-mocha"] as const) {
const theme = resolveTheme(themeId, null);
const highlighted = await loadHighlightedDiff(file, theme.appearance);
const rows = buildStackRows(file, highlighted, theme).filter(
Expand Down Expand Up @@ -229,7 +229,7 @@ describe("Pierre diff rows", () => {
const file = createMarkdownDiffFile();
const highlighted = await loadHighlightedDiff(file, "dark");

for (const themeId of ["graphite", "midnight", "ember"] as const) {
for (const themeId of ["graphite", "midnight", "ember", "catppuccin-mocha"] as const) {
const theme = resolveTheme(themeId, null);
const rows = buildStackRows(file, highlighted, theme).filter(
(row): row is Extract<DiffRow, { type: "stack-line" }> =>
Expand Down
4 changes: 3 additions & 1 deletion src/ui/lib/ui-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ describe("ui helpers", () => {
menus.theme
.filter((entry): entry is Extract<MenuEntry, { kind: "item" }> => entry.kind === "item")
.map((entry) => entry.label),
).toEqual(["Graphite", "Midnight", "Paper", "Ember"]);
).toEqual(["Graphite", "Midnight", "Paper", "Ember", "Catppuccin Latte", "Catppuccin Mocha"]);
expect(
menus.theme.some(
(entry) => entry.kind === "item" && entry.label === "Graphite" && entry.checked,
Expand Down Expand Up @@ -372,5 +372,7 @@ describe("ui helpers", () => {
expect(autoLight.id).toBe("paper");
expect(autoDark.id).toBe("graphite");
expect(resolveTheme("ember", null).syntaxStyle).toBeDefined();
expect(resolveTheme("catppuccin-latte", null).syntaxStyle).toBeDefined();
expect(resolveTheme("catppuccin-mocha", null).syntaxStyle).toBeDefined();
});
});
93 changes: 93 additions & 0 deletions src/ui/themes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, test } from "bun:test";
import { blendHex, hexColorDistance } from "./lib/color";
import { CATPPUCCIN_PALETTES, resolveTheme } from "./themes";

describe("themes", () => {
test("resolves Catppuccin Latte and Mocha by theme id", () => {
const latte = resolveTheme("catppuccin-latte", null);
const mocha = resolveTheme("catppuccin-mocha", null);

expect(latte.id).toBe("catppuccin-latte");
expect(latte.label).toBe("Catppuccin Latte");
expect(latte.appearance).toBe("light");
expect(mocha.id).toBe("catppuccin-mocha");
expect(mocha.label).toBe("Catppuccin Mocha");
expect(mocha.appearance).toBe("dark");
});

test("keeps official Catppuccin sentinel colors in source", () => {
expect(CATPPUCCIN_PALETTES.latte.base).toBe("#eff1f5");
expect(CATPPUCCIN_PALETTES.latte.mauve).toBe("#8839ef");
expect(CATPPUCCIN_PALETTES.latte.green).toBe("#40a02b");
expect(CATPPUCCIN_PALETTES.latte.red).toBe("#d20f39");
expect(CATPPUCCIN_PALETTES.mocha.base).toBe("#1e1e2e");
expect(CATPPUCCIN_PALETTES.mocha.mauve).toBe("#cba6f7");
expect(CATPPUCCIN_PALETTES.mocha.green).toBe("#a6e3a1");
expect(CATPPUCCIN_PALETTES.mocha.red).toBe("#f38ba8");
});

test("derives Catppuccin diff backgrounds from official semantic tokens", () => {
const latte = resolveTheme("catppuccin-latte", null);
const mocha = resolveTheme("catppuccin-mocha", null);

expect(latte.addedBg).toBe(blendHex(CATPPUCCIN_PALETTES.latte.green, latte.contextBg, 0.15));
expect(latte.removedBg).toBe(blendHex(CATPPUCCIN_PALETTES.latte.red, latte.contextBg, 0.15));
expect(latte.addedContentBg).toBe(
blendHex(CATPPUCCIN_PALETTES.latte.green, latte.contextBg, 0.25),
);
expect(latte.removedContentBg).toBe(
blendHex(CATPPUCCIN_PALETTES.latte.red, latte.contextBg, 0.25),
);
expect(mocha.addedBg).toBe(blendHex(CATPPUCCIN_PALETTES.mocha.green, mocha.contextBg, 0.15));
expect(mocha.removedBg).toBe(blendHex(CATPPUCCIN_PALETTES.mocha.red, mocha.contextBg, 0.15));
expect(mocha.addedContentBg).toBe(
blendHex(CATPPUCCIN_PALETTES.mocha.green, mocha.contextBg, 0.25),
);
expect(mocha.removedContentBg).toBe(
blendHex(CATPPUCCIN_PALETTES.mocha.red, mocha.contextBg, 0.25),
);
});

test("keeps Catppuccin add and remove rows semantically distinct", () => {
for (const theme of [
resolveTheme("catppuccin-latte", null),
resolveTheme("catppuccin-mocha", null),
]) {
expect(theme.addedBg).not.toBe(theme.removedBg);
expect(hexColorDistance(theme.addedBg, theme.contextBg)).toBeGreaterThan(0);
expect(hexColorDistance(theme.removedBg, theme.contextBg)).toBeGreaterThan(0);
expect(hexColorDistance(theme.addedContentBg, theme.contextBg)).toBeGreaterThan(
hexColorDistance(theme.addedBg, theme.contextBg),
);
expect(hexColorDistance(theme.removedContentBg, theme.contextBg)).toBeGreaterThan(
hexColorDistance(theme.removedBg, theme.contextBg),
);
}
});

test("maps Catppuccin syntax roles to documented editor tokens", () => {
const latte = resolveTheme("catppuccin-latte", null);
const mocha = resolveTheme("catppuccin-mocha", null);

expect(latte.syntaxColors).toMatchObject({
keyword: CATPPUCCIN_PALETTES.latte.mauve,
string: CATPPUCCIN_PALETTES.latte.green,
comment: CATPPUCCIN_PALETTES.latte.overlay2,
number: CATPPUCCIN_PALETTES.latte.peach,
function: CATPPUCCIN_PALETTES.latte.blue,
property: CATPPUCCIN_PALETTES.latte.blue,
type: CATPPUCCIN_PALETTES.latte.yellow,
punctuation: CATPPUCCIN_PALETTES.latte.overlay2,
});
expect(mocha.syntaxColors).toMatchObject({
keyword: CATPPUCCIN_PALETTES.mocha.mauve,
string: CATPPUCCIN_PALETTES.mocha.green,
comment: CATPPUCCIN_PALETTES.mocha.overlay2,
number: CATPPUCCIN_PALETTES.mocha.peach,
function: CATPPUCCIN_PALETTES.mocha.blue,
property: CATPPUCCIN_PALETTES.mocha.blue,
type: CATPPUCCIN_PALETTES.mocha.yellow,
punctuation: CATPPUCCIN_PALETTES.mocha.overlay2,
});
});
});
156 changes: 156 additions & 0 deletions src/ui/themes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RGBA, SyntaxStyle, type ThemeMode } from "@opentui/core";
import { blendHex } from "./lib/color";

export interface AppTheme {
id: string;
Expand Down Expand Up @@ -51,6 +52,99 @@ type SyntaxColors = {
punctuation: string;
};

type CatppuccinPalette = {
rosewater: string;
flamingo: string;
pink: string;
mauve: string;
red: string;
maroon: string;
peach: string;
yellow: string;
green: string;
teal: string;
sky: string;
sapphire: string;
blue: string;
lavender: string;
text: string;
subtext1: string;
subtext0: string;
overlay2: string;
overlay1: string;
overlay0: string;
surface2: string;
surface1: string;
surface0: string;
base: string;
mantle: string;
crust: string;
};

// Source: https://github.com/catppuccin/palette/blob/main/palette.json
// Cross-check reference: https://catppuccin.com/palette/
// Semantic guidance: https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md
export const CATPPUCCIN_PALETTES = {
latte: {
rosewater: "#dc8a78",
flamingo: "#dd7878",
pink: "#ea76cb",
mauve: "#8839ef",
red: "#d20f39",
maroon: "#e64553",
peach: "#fe640b",
yellow: "#df8e1d",
green: "#40a02b",
teal: "#179299",
sky: "#04a5e5",
sapphire: "#209fb5",
blue: "#1e66f5",
lavender: "#7287fd",
text: "#4c4f69",
subtext1: "#5c5f77",
subtext0: "#6c6f85",
overlay2: "#7c7f93",
overlay1: "#8c8fa1",
overlay0: "#9ca0b0",
surface2: "#acb0be",
surface1: "#bcc0cc",
surface0: "#ccd0da",
base: "#eff1f5",
mantle: "#e6e9ef",
crust: "#dce0e8",
},
mocha: {
rosewater: "#f5e0dc",
flamingo: "#f2cdcd",
pink: "#f5c2e7",
mauve: "#cba6f7",
red: "#f38ba8",
maroon: "#eba0ac",
peach: "#fab387",
yellow: "#f9e2af",
green: "#a6e3a1",
teal: "#94e2d5",
sky: "#89dceb",
sapphire: "#74c7ec",
blue: "#89b4fa",
lavender: "#b4befe",
text: "#cdd6f4",
subtext1: "#bac2de",
subtext0: "#a6adc8",
overlay2: "#9399b2",
overlay1: "#7f849c",
overlay0: "#6c7086",
surface2: "#585b70",
surface1: "#45475a",
surface0: "#313244",
base: "#1e1e2e",
mantle: "#181825",
crust: "#11111b",
},
} as const satisfies Record<"latte" | "mocha", CatppuccinPalette>;

type CatppuccinFlavor = keyof typeof CATPPUCCIN_PALETTES;

/** Build the syntax palette OpenTUI should use for in-terminal code rendering. */
function createSyntaxStyle(colors: SyntaxColors) {
return SyntaxStyle.fromStyles({
Expand Down Expand Up @@ -87,6 +181,66 @@ function withLazySyntaxStyle(
};
}

/** Map official Catppuccin palette tokens into Hunk's semantic theme slots. */
function createCatppuccinTheme(flavor: CatppuccinFlavor) {
const palette = CATPPUCCIN_PALETTES[flavor];
const label = flavor === "latte" ? "Catppuccin Latte" : "Catppuccin Mocha";
Comment thread
mfkd marked this conversation as resolved.
const appearance: AppTheme["appearance"] = flavor === "latte" ? "light" : "dark";
const panel = flavor === "latte" ? palette.base : palette.mantle;
const panelAlt = flavor === "latte" ? palette.mantle : palette.base;
const contextBg = palette.base;

return withLazySyntaxStyle(
{
id: `catppuccin-${flavor}`,
label,
appearance,
background: palette.crust,
panel,
panelAlt,
border: palette.surface1,
accent: palette.mauve,
accentMuted: blendHex(palette.mauve, panel, 0.2),
text: palette.text,
muted: palette.subtext0,
addedBg: blendHex(palette.green, contextBg, 0.15),
removedBg: blendHex(palette.red, contextBg, 0.15),
contextBg,
addedContentBg: blendHex(palette.green, contextBg, 0.25),
removedContentBg: blendHex(palette.red, contextBg, 0.25),
contextContentBg: contextBg,
addedSignColor: palette.green,
removedSignColor: palette.red,
lineNumberBg: palette.mantle,
lineNumberFg: palette.overlay1,
selectedHunk: blendHex(palette.overlay2, contextBg, 0.25),
badgeAdded: palette.green,
badgeRemoved: palette.red,
badgeNeutral: palette.overlay2,
fileNew: palette.green,
fileDeleted: palette.red,
fileRenamed: palette.yellow,
fileModified: palette.mauve,
fileUntracked: palette.sky,
noteBorder: palette.mauve,
noteBackground: blendHex(palette.mauve, panel, 0.12),
noteTitleBackground: blendHex(palette.mauve, panel, 0.22),
noteTitleText: palette.text,
},
{
default: palette.text,
keyword: palette.mauve,
string: palette.green,
comment: palette.overlay2,
number: palette.peach,
function: palette.blue,
property: palette.blue,
type: palette.yellow,
punctuation: palette.overlay2,
},
);
}

export const THEMES: AppTheme[] = [
withLazySyntaxStyle(
{
Expand Down Expand Up @@ -284,6 +438,8 @@ export const THEMES: AppTheme[] = [
punctuation: "#a17d69",
},
),
createCatppuccinTheme("latte"),
createCatppuccinTheme("mocha"),
];

/** Resolve a named theme, including explicit terminal-background auto mode. */
Expand Down