diff --git a/CHANGELOG.md b/CHANGELOG.md index 530a7fb2..c457b2dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file. ### Added +- Added a `--transparent-background` flag and `transparent_background` config option for translucent terminal setups. + ### Changed ### Fixed diff --git a/README.md b/README.md index b13db42a..5685dd24 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,11 @@ exclude_untracked = false line_numbers = true wrap_lines = false agent_notes = false +transparent_background = false ``` `exclude_untracked` affects Git working-tree `hunk diff` sessions only. +`transparent_background` can also be written as `transparentBackground`. Custom themes can inherit from any built-in base theme and override only the colors you care about: diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 402f1ac9..29eadd56 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -83,6 +83,7 @@ describe("parseCli", () => { "--wrap", "--no-hunk-headers", "--agent-notes", + "--transparent-background", "--watch", ]); @@ -99,6 +100,25 @@ describe("parseCli", () => { wrapLines: true, hunkHeaders: false, agentNotes: true, + transparentBackground: true, + }, + }); + }); + + test("parses transparent background toggles", async () => { + const transparent = await parseCli(["bun", "hunk", "diff", "--transparent-background"]); + const opaque = await parseCli(["bun", "hunk", "diff", "--no-transparent-background"]); + + expect(transparent).toMatchObject({ + kind: "vcs", + options: { + transparentBackground: true, + }, + }); + expect(opaque).toMatchObject({ + kind: "vcs", + options: { + transparentBackground: false, }, }); }); diff --git a/src/core/cli.ts b/src/core/cli.ts index 6844e29c..1452a718 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -59,6 +59,7 @@ function buildCommonOptions( agentContext?: string; pager?: boolean; watch?: boolean; + transparentBackground?: boolean; }, argv: string[], ): CommonOptions { @@ -73,6 +74,11 @@ function buildCommonOptions( wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"), hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"), agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"), + transparentBackground: resolveBooleanFlag( + argv, + "--transparent-background", + "--no-transparent-background", + ), }; } @@ -90,7 +96,9 @@ function applyCommonOptions(command: Command) { .option("--hunk-headers", "show hunk metadata rows") .option("--no-hunk-headers", "hide hunk metadata rows") .option("--agent-notes", "show agent notes by default") - .option("--no-agent-notes", "hide agent notes by default"); + .option("--no-agent-notes", "hide agent notes by default") + .option("--transparent-background", "let terminal background show through Hunk surfaces") + .option("--no-transparent-background", "paint Hunk surfaces with the active theme"); } /** Attach auto-refresh support to review commands that can reopen their source input. */ @@ -152,6 +160,8 @@ function renderCliHelp() { " --wrap / --no-wrap wrap or truncate long diff lines", " --hunk-headers / --no-hunk-headers show or hide hunk metadata rows", " --agent-notes / --no-agent-notes show or hide agent notes by default", + " --transparent-background / --no-transparent-background", + " let terminal background show through Hunk surfaces", " --theme named theme override", "", "Git diff options:", diff --git a/src/core/config.test.ts b/src/core/config.test.ts index a0085d48..464bc519 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -58,6 +58,7 @@ describe("config resolution", () => { [ 'theme = "graphite"', "line_numbers = false", + "transparentBackground = true", "", "[patch]", 'mode = "split"', @@ -87,6 +88,7 @@ describe("config resolution", () => { wrapLines: true, hunkHeaders: false, agentNotes: true, + transparentBackground: true, }); }); @@ -210,6 +212,33 @@ describe("config resolution", () => { ).toThrow('Expected a [custom_theme] table when config selects theme = "custom".'); }); + test("accepts transparent background config and CLI overrides", () => { + const home = createTempDir("hunk-config-home-"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync(join(home, ".config", "hunk", "config.toml"), "transparent_background = true\n"); + + const cwd = createTempDir("hunk-config-cwd-"); + const configured = resolveConfiguredCliInput( + { + kind: "vcs", + staged: false, + options: {}, + }, + { cwd, env: { HOME: home } }, + ); + const overridden = resolveConfiguredCliInput( + { + kind: "vcs", + staged: false, + options: { transparentBackground: false }, + }, + { cwd, env: { HOME: home } }, + ); + + expect(configured.input.options.transparentBackground).toBe(true); + expect(overridden.input.options.transparentBackground).toBe(false); + }); + test("defaults unspecified themes to graphite, including piped pager-style patch input", () => { const home = createTempDir("hunk-config-home-"); const cwd = createTempDir("hunk-config-cwd-"); diff --git a/src/core/config.ts b/src/core/config.ts index 0c8c93ed..63204748 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -233,6 +233,9 @@ function readConfigPreferences(source: Record): CommonOptions { hunkHeaders: normalizeBoolean(source.hunk_headers), agentNotes: normalizeBoolean(source.agent_notes), copyDecorations: normalizeBoolean(source.copy_decorations), + transparentBackground: + normalizeBoolean(source.transparentBackground) ?? + normalizeBoolean(source.transparent_background), }; } @@ -252,6 +255,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders, agentNotes: overrides.agentNotes ?? base.agentNotes, copyDecorations: overrides.copyDecorations ?? base.copyDecorations, + transparentBackground: overrides.transparentBackground ?? base.transparentBackground, }; } @@ -316,6 +320,7 @@ export function resolveConfiguredCliInput( hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders, agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes, copyDecorations: DEFAULT_VIEW_PREFERENCES.copyDecorations, + transparentBackground: false, }; if (userConfigPath) { @@ -344,6 +349,7 @@ export function resolveConfiguredCliInput( hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders, agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations, + transparentBackground: resolvedOptions.transparentBackground ?? false, }; if (resolvedOptions.theme === "custom" && !resolvedCustomTheme) { diff --git a/src/core/types.ts b/src/core/types.ts index 0924ff2e..f34eaa75 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -84,6 +84,7 @@ export interface CommonOptions { hunkHeaders?: boolean; agentNotes?: boolean; copyDecorations?: boolean; + transparentBackground?: boolean; } export interface CustomSyntaxColorsConfig { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index b2f40184..1cc55402 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -28,7 +28,7 @@ import { fileRowId } from "./lib/ids"; import { openSelectedFileInEditor } from "./lib/openInEditor"; import { resolveResponsiveLayout } from "./lib/responsive"; import { resizeSidebarWidth } from "./lib/sidebar"; -import { availableThemes, resolveTheme } from "./themes"; +import { availableThemes, resolveTheme, withTransparentBackground } from "./themes"; type FocusArea = "files" | "filter" | "note"; type ActiveAddNoteTarget = ActiveAddNoteAffordance & { fileId: string }; @@ -138,7 +138,17 @@ export function App({ () => availableThemes(bootstrap.customTheme), [bootstrap.customTheme], ); - const activeTheme = resolveTheme(themeId, detectedThemeMode ?? null, bootstrap.customTheme); + const baseTheme = useMemo( + () => resolveTheme(themeId, detectedThemeMode ?? null, bootstrap.customTheme), + [themeId, detectedThemeMode, bootstrap.customTheme], + ); + const activeTheme = useMemo( + () => + bootstrap.input.options.transparentBackground + ? withTransparentBackground(baseTheme) + : baseTheme, + [baseTheme, bootstrap.input.options.transparentBackground], + ); const review = useReviewController({ files: bootstrap.changeset.files }); const filteredFiles = review.visibleFiles; const selectedFile = review.selectedFile; diff --git a/src/ui/diff/pierre.test.ts b/src/ui/diff/pierre.test.ts index de665585..35b6c4e4 100644 --- a/src/ui/diff/pierre.test.ts +++ b/src/ui/diff/pierre.test.ts @@ -13,7 +13,7 @@ import { resolveSplitPaneWidths } from "./codeColumns"; import { renderCodeOnlyPlannedRowText, renderDecoratedPlannedRowText } from "./renderRows"; import { buildReviewRenderPlan } from "./reviewRenderPlan"; import { measureTextWidth } from "../lib/text"; -import { resolveTheme } from "../themes"; +import { TRANSPARENT_BACKGROUND, resolveTheme, withTransparentBackground } from "../themes"; function createDiffFile(): DiffFile { const metadata = parseDiffFromFile( @@ -146,6 +146,28 @@ describe("Pierre diff rows", () => { ).toBe(true); }); + test("keeps word-diff highlight backgrounds transparent in transparent mode", async () => { + const file = createDiffFile(); + const theme = withTransparentBackground(resolveTheme("midnight", null)); + const highlighted = await loadHighlightedDiff(file); + const rows = buildSplitRows(file, highlighted, theme); + const changedRow = rows.find( + (row) => + row.type === "split-line" && row.left.kind === "deletion" && row.right.kind === "addition", + ); + + expect(changedRow).toBeDefined(); + if (!changedRow || changedRow.type !== "split-line") { + throw new Error("Expected a split-line change row"); + } + + const removedWordSpan = changedRow.left.spans.find((span) => span.text.includes("41")); + const addedWordSpan = changedRow.right.spans.find((span) => span.text.includes("42")); + + expect(removedWordSpan?.bg).toBe(TRANSPARENT_BACKGROUND); + expect(addedWordSpan?.bg).toBe(TRANSPARENT_BACKGROUND); + }); + test("builds stacked rows with separate deletion and addition lines", () => { const file = createDiffFile(); const theme = resolveTheme("paper", null); diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts index cffa8aa6..acdbb14e 100644 --- a/src/ui/diff/pierre.ts +++ b/src/ui/diff/pierre.ts @@ -11,7 +11,7 @@ import { formatHunkHeader } from "../../core/hunkHeader"; import type { DiffFile } from "../../core/types"; import { blendHex, hexColorDistance } from "../lib/color"; import { sanitizeTerminalLine } from "../../lib/terminalText"; -import type { AppTheme } from "../themes"; +import { TRANSPARENT_BACKGROUND, type AppTheme } from "../themes"; import { expandDiffTabs } from "./codeColumns"; const PIERRE_THEME = { @@ -223,18 +223,49 @@ function strengthenWordDiffBg(lineBg: string, signColor: string) { return strongestCandidate; } +/** Return whether a theme color can safely participate in RGB distance and blend math. */ +function isHexThemeColor(color: string) { + return /^#[0-9a-f]{6}$/i.test(color); +} + +/** Resolve one word-diff background without turning transparent surfaces into black blends. */ +function resolveWordDiffHighlightBg(contentBg: string, lineBg: string, signColor: string) { + if (contentBg === TRANSPARENT_BACKGROUND || lineBg === TRANSPARENT_BACKGROUND) { + return contentBg; + } + + if (!isHexThemeColor(contentBg) || !isHexThemeColor(lineBg)) { + return contentBg; + } + + return hexColorDistance(contentBg, lineBg) >= MIN_WORD_DIFF_BG_DISTANCE + ? contentBg + : strengthenWordDiffBg(lineBg, signColor); +} + /** Resolve the inline word-diff background, strengthening theme colors that are too subtle to see. */ function wordDiffHighlightBg(kind: SplitLineCell["kind"], theme: AppTheme) { - let cached = wordDiffBackgroundCache.get(theme.id); + const cacheKey = [ + theme.id, + theme.addedBg, + theme.addedContentBg, + theme.removedBg, + theme.removedContentBg, + theme.contextContentBg, + theme.panelAlt, + ].join(":"); + let cached = wordDiffBackgroundCache.get(cacheKey); if (!cached) { - const addition = - hexColorDistance(theme.addedContentBg, theme.addedBg) >= MIN_WORD_DIFF_BG_DISTANCE - ? theme.addedContentBg - : strengthenWordDiffBg(theme.addedBg, theme.addedSignColor); - const deletion = - hexColorDistance(theme.removedContentBg, theme.removedBg) >= MIN_WORD_DIFF_BG_DISTANCE - ? theme.removedContentBg - : strengthenWordDiffBg(theme.removedBg, theme.removedSignColor); + const addition = resolveWordDiffHighlightBg( + theme.addedContentBg, + theme.addedBg, + theme.addedSignColor, + ); + const deletion = resolveWordDiffHighlightBg( + theme.removedContentBg, + theme.removedBg, + theme.removedSignColor, + ); cached = { addition, @@ -242,7 +273,7 @@ function wordDiffHighlightBg(kind: SplitLineCell["kind"], theme: AppTheme) { deletion, empty: theme.panelAlt, }; - wordDiffBackgroundCache.set(theme.id, cached); + wordDiffBackgroundCache.set(cacheKey, cached); } return cached[kind]; diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts index 659cfc2c..627bd7e4 100644 --- a/src/ui/themes.test.ts +++ b/src/ui/themes.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import { blendHex, hexColorDistance } from "./lib/color"; -import { CATPPUCCIN_PALETTES, resolveTheme } from "./themes"; +import { + CATPPUCCIN_PALETTES, + resolveTheme, + TRANSPARENT_BACKGROUND, + withTransparentBackground, +} from "./themes"; describe("themes", () => { test("resolves Catppuccin Latte and Mocha by theme id", () => { @@ -90,4 +95,33 @@ describe("themes", () => { punctuation: CATPPUCCIN_PALETTES.mocha.overlay2, }); }); + + test("withTransparentBackground only swaps painted background fields", () => { + const theme = resolveTheme("graphite", null); + const transparent = withTransparentBackground(theme); + + expect(transparent).toMatchObject({ + background: TRANSPARENT_BACKGROUND, + panel: TRANSPARENT_BACKGROUND, + panelAlt: TRANSPARENT_BACKGROUND, + addedBg: TRANSPARENT_BACKGROUND, + removedBg: TRANSPARENT_BACKGROUND, + contextBg: TRANSPARENT_BACKGROUND, + addedContentBg: TRANSPARENT_BACKGROUND, + removedContentBg: TRANSPARENT_BACKGROUND, + contextContentBg: TRANSPARENT_BACKGROUND, + lineNumberBg: TRANSPARENT_BACKGROUND, + selectedHunk: TRANSPARENT_BACKGROUND, + noteBackground: TRANSPARENT_BACKGROUND, + noteTitleBackground: TRANSPARENT_BACKGROUND, + }); + expect(transparent.id).toBe(theme.id); + expect(transparent.label).toBe(theme.label); + expect(transparent.text).toBe(theme.text); + expect(transparent.muted).toBe(theme.muted); + expect(transparent.addedSignColor).toBe(theme.addedSignColor); + expect(transparent.removedSignColor).toBe(theme.removedSignColor); + expect(transparent.syntaxColors).toBe(theme.syntaxColors); + expect(theme.background).not.toBe(TRANSPARENT_BACKGROUND); + }); }); diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 56bc57fa..0d326843 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -11,6 +11,8 @@ import type { AppTheme, ThemeBase } from "./themes/types"; export { CATPPUCCIN_PALETTES } from "./themes/catppuccin"; export type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types"; +export const TRANSPARENT_BACKGROUND = "transparent"; + export const THEMES: AppTheme[] = [ GRAPHITE_THEME, MIDNIGHT_THEME, @@ -110,3 +112,23 @@ export function resolveTheme( return fallbackTheme(); } + +/** Return a copy of a theme whose painted surfaces allow the terminal background through. */ +export function withTransparentBackground(theme: AppTheme): AppTheme { + return { + ...theme, + background: TRANSPARENT_BACKGROUND, + panel: TRANSPARENT_BACKGROUND, + panelAlt: TRANSPARENT_BACKGROUND, + addedBg: TRANSPARENT_BACKGROUND, + removedBg: TRANSPARENT_BACKGROUND, + contextBg: TRANSPARENT_BACKGROUND, + addedContentBg: TRANSPARENT_BACKGROUND, + removedContentBg: TRANSPARENT_BACKGROUND, + contextContentBg: TRANSPARENT_BACKGROUND, + lineNumberBg: TRANSPARENT_BACKGROUND, + selectedHunk: TRANSPARENT_BACKGROUND, + noteBackground: TRANSPARENT_BACKGROUND, + noteTitleBackground: TRANSPARENT_BACKGROUND, + }; +}