diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c46ab..ee674ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable user-visible changes to Hunk are documented in this file. ### Fixed +- Balanced Pierre word-level highlights so split-view inline changes stay visible without overpowering the surrounding diff row. - Smoothed mouse-wheel review scrolling so small diffs stay precise while sustained wheel gestures still speed up. - Fixed Shift+mouse-wheel horizontal scrolling so it no longer leaks a one-line vertical scroll in some terminals. diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 0cd5dc0..6307452 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -5,6 +5,7 @@ import { act, createRef, useEffect, useState, type ReactNode } from "react"; import type { AppBootstrap, DiffFile } from "../../core/types"; import { createTestGitAppBootstrap } from "../../../test/helpers/app-bootstrap"; import { createTestDiffFile as buildTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { hexColorDistance } from "../lib/color"; import { resolveTheme } from "../themes"; import { measureDiffSectionGeometry } from "../lib/diffSectionGeometry"; import { buildFileSectionLayouts, buildInStreamFileHeaderHeights } from "../lib/fileSectionLayout"; @@ -351,6 +352,44 @@ function frameHasHighlightedMarker( }); } +/** Convert captured RGBA output back into a #rrggbb color string for contrast assertions. */ +function capturedColorToHex(color: { buffer?: ArrayLike } | undefined) { + const buffer = color?.buffer; + if (!buffer || buffer[0] == null || buffer[1] == null || buffer[2] == null) { + return null; + } + + const componentToHex = (value: number) => + Math.max(0, Math.min(255, Math.round(value * 255))) + .toString(16) + .padStart(2, "0"); + + return `#${componentToHex(buffer[0])}${componentToHex(buffer[1])}${componentToHex(buffer[2])}`; +} + +/** Measure the rendered background contrast between one word-diff span and its surrounding line. */ +function renderedWordDiffBackgroundDistance( + frame: { lines: Array<{ spans: Array<{ text: string; bg?: { buffer?: ArrayLike } }> }> }, + marker: string, +) { + for (const line of frame.lines) { + const spanIndex = line.spans.findIndex((span) => span.text.includes(marker)); + if (spanIndex <= 0) { + continue; + } + + const wordBg = capturedColorToHex(line.spans[spanIndex]?.bg); + const surroundingBg = capturedColorToHex(line.spans[spanIndex - 1]?.bg); + if (!wordBg || !surroundingBg) { + continue; + } + + return hexColorDistance(wordBg, surroundingBg); + } + + return null; +} + describe("UI components", () => { test("SidebarPane renders grouped file rows with indented filenames and right-aligned stats", async () => { const theme = resolveTheme("midnight", null); @@ -1901,6 +1940,61 @@ describe("UI components", () => { expect(binaryFileFrame).toContain("Binary file skipped"); }); + test("PierreDiffView renders word-diff spans with a visibly different background in split view", async () => { + const file = createTestDiffFile( + "word-diff", + "word-diff.ts", + "export const answer = 41;\nexport const stable = true;\n", + "export const answer = 42;\nexport const stable = true;\n", + ); + const theme = resolveTheme("graphite", null); + const setup = await testRender( + , + { width: 124, height: 10 }, + ); + + try { + let removedBackgroundDistance: number | null = null; + let addedBackgroundDistance: number | null = null; + + for (let iteration = 0; iteration < 200; iteration += 1) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(0); + await setup.renderOnce(); + await Bun.sleep(0); + }); + + const frame = setup.captureSpans(); + removedBackgroundDistance = renderedWordDiffBackgroundDistance(frame, "41"); + addedBackgroundDistance = renderedWordDiffBackgroundDistance(frame, "42"); + + if ( + removedBackgroundDistance !== null && + addedBackgroundDistance !== null && + removedBackgroundDistance > 0 && + addedBackgroundDistance > 0 + ) { + break; + } + } + + expect(removedBackgroundDistance).toBeGreaterThanOrEqual(28); + expect(addedBackgroundDistance).toBeGreaterThanOrEqual(28); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("PierreDiffView reuses highlighted rows after unmounting and remounting a file section", async () => { const file = createTestDiffFile( "cache", diff --git a/src/ui/diff/pierre.test.ts b/src/ui/diff/pierre.test.ts index c67dcb2..cb64908 100644 --- a/src/ui/diff/pierre.test.ts +++ b/src/ui/diff/pierre.test.ts @@ -115,10 +115,19 @@ describe("Pierre diff rows", () => { throw new Error("Expected a split-line change row"); } - expect(changedRow.left.spans.some((span) => span.text.includes("41"))).toBe(true); - expect(changedRow.right.spans.some((span) => span.text.includes("42"))).toBe(true); - expect(changedRow.left.spans.some((span) => span.bg === theme.removedContentBg)).toBe(true); - expect(changedRow.right.spans.some((span) => span.bg === theme.addedContentBg)).toBe(true); + const removedWordSpan = changedRow.left.spans.find((span) => span.text.includes("41")); + const addedWordSpan = changedRow.right.spans.find((span) => span.text.includes("42")); + + expect(removedWordSpan).toBeDefined(); + expect(addedWordSpan).toBeDefined(); + expect(removedWordSpan?.bg).toBeDefined(); + expect(addedWordSpan?.bg).toBeDefined(); + expect(changedRow.left.spans.some((span) => span.text.includes("export") && span.bg)).toBe( + false, + ); + expect(changedRow.right.spans.some((span) => span.text.includes("export") && span.bg)).toBe( + false, + ); expect( changedRow.right.spans.some( (span) => span.text.includes("export") && typeof span.fg === "string", diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts index 7b1f2df..7044b87 100644 --- a/src/ui/diff/pierre.ts +++ b/src/ui/diff/pierre.ts @@ -7,6 +7,7 @@ import { } from "@pierre/diffs"; import { formatHunkHeader } from "../../core/hunkHeader"; import type { DiffFile } from "../../core/types"; +import { blendHex, hexColorDistance } from "../lib/color"; import type { AppTheme } from "../themes"; import { expandDiffTabs } from "./codeColumns"; @@ -165,6 +166,53 @@ const normalizedColorCache = new Map>(); // 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. const flattenedHighlightedLineCache = new WeakMap>(); +const MIN_WORD_DIFF_BG_DISTANCE = 28; +const WORD_DIFF_BLEND_STEP = 0.005; +const WORD_DIFF_MAX_BLEND = 0.2; +const wordDiffBackgroundCache = new Map>(); + +/** Blend toward the semantic sign color just enough to hit the minimum visible contrast. */ +function strengthenWordDiffBg(lineBg: string, signColor: string) { + let strongestCandidate = lineBg; + const maxSteps = Math.floor(WORD_DIFF_MAX_BLEND / WORD_DIFF_BLEND_STEP); + + for (let step = 1; step <= maxSteps; step += 1) { + const blendRatio = step * WORD_DIFF_BLEND_STEP; + const candidate = blendHex(signColor, lineBg, blendRatio); + strongestCandidate = candidate; + + if (hexColorDistance(candidate, lineBg) >= MIN_WORD_DIFF_BG_DISTANCE) { + return candidate; + } + } + + return strongestCandidate; +} + +/** 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); + 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); + + cached = { + addition, + context: theme.contextContentBg, + deletion, + empty: theme.panelAlt, + }; + wordDiffBackgroundCache.set(theme.id, cached); + } + + 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) { @@ -301,15 +349,7 @@ function makeSplitCell( const fallbackText = cleanDiffLine(rawLine); spans = fallbackText.length > 0 ? [{ text: fallbackText }] : []; } else { - spans = flattenHighlightedLine( - highlightedLine, - theme, - kind === "addition" - ? theme.addedContentBg - : kind === "deletion" - ? theme.removedContentBg - : theme.contextContentBg, - ); + spans = flattenHighlightedLine(highlightedLine, theme, wordDiffHighlightBg(kind, theme)); if (spans.length === 0) { const fallbackText = cleanDiffLine(rawLine); @@ -341,15 +381,7 @@ function makeStackCell( const fallbackText = cleanDiffLine(rawLine); spans = fallbackText.length > 0 ? [{ text: fallbackText }] : []; } else { - spans = flattenHighlightedLine( - highlightedLine, - theme, - kind === "addition" - ? theme.addedContentBg - : kind === "deletion" - ? theme.removedContentBg - : theme.contextContentBg, - ); + spans = flattenHighlightedLine(highlightedLine, theme, wordDiffHighlightBg(kind, theme)); if (spans.length === 0) { const fallbackText = cleanDiffLine(rawLine); diff --git a/src/ui/diff/renderRows.tsx b/src/ui/diff/renderRows.tsx index b26dbd5..0ef794a 100644 --- a/src/ui/diff/renderRows.tsx +++ b/src/ui/diff/renderRows.tsx @@ -7,6 +7,7 @@ import { resolveStackCellGeometry, } from "./codeColumns"; import type { DiffRow, RenderSpan, SplitLineCell, StackLineCell } from "./pierre"; +import { blendHex } from "../lib/color"; /** Clamp a label to one terminal row with an ellipsis. */ export function fitText(text: string, width: number) { @@ -79,23 +80,6 @@ function sliceSpansWindow(spans: RenderSpan[], offset: number, width: number) { }; } -/** Parse a hex color string into RGB components. */ -function hexToRgb(hex: string) { - const n = parseInt(hex.slice(1), 16); - return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff }; -} - -/** Blend a color toward a background at a given ratio (0 = bg, 1 = fg). */ -function blendHex(fg: string, bg: string, ratio: number) { - const f = hexToRgb(fg); - const b = hexToRgb(bg); - const mix = (a: number, z: number) => Math.round(z + (a - z) * ratio); - const r = mix(f.r, b.r); - const g = mix(f.g, b.g); - const bl = mix(f.b, b.b); - return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, "0")}`; -} - const INACTIVE_RAIL_BLEND = 0.35; /** Dim a rail color for inactive hunks by blending toward the panel background. */ diff --git a/src/ui/lib/color.ts b/src/ui/lib/color.ts new file mode 100644 index 0000000..d668322 --- /dev/null +++ b/src/ui/lib/color.ts @@ -0,0 +1,40 @@ +/** One parsed RGB triplet from a #rrggbb hex color. */ +interface RgbColor { + r: number; + g: number; + b: number; +} + +/** Parse a #rrggbb color into RGB components. Falls back to black for invalid input. */ +function hexToRgb(hex: string): RgbColor { + const normalized = /^#?[0-9a-f]{6}$/i.test(hex) ? hex.replace(/^#/, "") : "000000"; + const value = parseInt(normalized, 16); + return { + r: (value >> 16) & 0xff, + g: (value >> 8) & 0xff, + b: value & 0xff, + }; +} + +/** Blend one foreground color toward a background color at a fixed ratio. */ +export function blendHex(fg: string, bg: string, ratio: number) { + const foreground = hexToRgb(fg); + const background = hexToRgb(bg); + const mix = (front: number, back: number) => + Math.max(0, Math.min(255, Math.round(back + (front - back) * ratio))); + + return `#${( + (mix(foreground.r, background.r) << 16) | + (mix(foreground.g, background.g) << 8) | + mix(foreground.b, background.b) + ) + .toString(16) + .padStart(6, "0")}`; +} + +/** Measure how visually separated two #rrggbb colors are using channel deltas. */ +export function hexColorDistance(left: string, right: string) { + const a = hexToRgb(left); + const b = hexToRgb(right); + return Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b); +}