Skip to content

Commit ac5e521

Browse files
committed
feat(ui): hatch empty split diff cells
1 parent 059dd13 commit ac5e521

5 files changed

Lines changed: 117 additions & 23 deletions

File tree

.changeset/split-empty-hatch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hunkdiff": patch
3+
---
4+
5+
In split view, render the side with no equivalent code as a dim diagonal hatch (``) over a faint wash instead of a flat grey filler block, so absent regions read as faded-out without a heavy background.

src/ui/diff/renderRows.tsx

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import type { DiffRow, RenderSpan, SplitLineCell, StackLineCell } from "./pierre
1111
import {
1212
diffRailMarker,
1313
dimRailColor,
14+
EMPTY_CELL_HATCH_GLYPH,
15+
emptyHatchBg,
16+
emptyHatchColor,
1417
neutralRailColor,
1518
selectionHighlightBg,
1619
splitCellPalette,
@@ -849,6 +852,23 @@ function applySelectionPrefix<P extends { bg: string }>(prefix: P, theme: AppThe
849852
};
850853
}
851854

855+
/**
856+
* Render the diagonal-hatch content for a split cell that has no line on this side.
857+
*
858+
* Returns a single fixed-width span of {@link EMPTY_CELL_HATCH_GLYPH} so an absent region reads as
859+
* "nothing here" without painting a flat filler background.
860+
*/
861+
function renderEmptyHatch(width: number, theme: AppTheme, keyPrefix: string) {
862+
if (width <= 0) {
863+
return null;
864+
}
865+
return (
866+
<span key={`${keyPrefix}:hatch`} fg={emptyHatchColor(theme)} bg={emptyHatchBg(theme)}>
867+
{EMPTY_CELL_HATCH_GLYPH.repeat(width)}
868+
</span>
869+
);
870+
}
871+
852872
/** Render one split-view cell as prefix + gutter + content spans. */
853873
function renderSplitCell(
854874
cell: SplitLineCell,
@@ -902,16 +922,18 @@ function renderSplitCell(
902922
<span key={`${keyPrefix}:gutter`} fg={palette.numberColor} bg={palette.gutterBg}>
903923
{gutterText}
904924
</span>
905-
{renderInlineSpans(
906-
cell.spans,
907-
contentWidth,
908-
theme.syntaxColors.default,
909-
palette.contentBg,
910-
`${keyPrefix}:content`,
911-
contentOffset,
912-
selected ? theme : undefined,
913-
localColRange,
914-
)}
925+
{cell.kind === "empty"
926+
? renderEmptyHatch(contentWidth, theme, `${keyPrefix}:content`)
927+
: renderInlineSpans(
928+
cell.spans,
929+
contentWidth,
930+
theme.syntaxColors.default,
931+
palette.contentBg,
932+
`${keyPrefix}:content`,
933+
contentOffset,
934+
selected ? theme : undefined,
935+
localColRange,
936+
)}
915937
</>
916938
);
917939
}
@@ -996,6 +1018,7 @@ function renderWrappedSplitCellLine(
9961018
selected = false,
9971019
selectionColRange?: CopySelectedRowRange,
9981020
paneOffset = 0,
1021+
isEmptyCell = false,
9991022
) {
10001023
const resolvedPalette = selected ? applySelectionPalette(palette, theme) : palette;
10011024
const resolvedPrefix = selected ? applySelectionPrefix(prefix, theme) : prefix;
@@ -1026,16 +1049,18 @@ function renderWrappedSplitCellLine(
10261049
>
10271050
{line.gutterText}
10281051
</span>
1029-
{renderInlineSpans(
1030-
line.spans,
1031-
contentWidth,
1032-
theme.syntaxColors.default,
1033-
resolvedPalette.contentBg,
1034-
`${keyPrefix}:content`,
1035-
0,
1036-
selected ? theme : undefined,
1037-
localColRange,
1038-
)}
1052+
{isEmptyCell
1053+
? renderEmptyHatch(contentWidth, theme, `${keyPrefix}:content`)
1054+
: renderInlineSpans(
1055+
line.spans,
1056+
contentWidth,
1057+
theme.syntaxColors.default,
1058+
resolvedPalette.contentBg,
1059+
`${keyPrefix}:content`,
1060+
0,
1061+
selected ? theme : undefined,
1062+
localColRange,
1063+
)}
10391064
</>
10401065
);
10411066
}
@@ -1549,6 +1574,7 @@ function renderRow(
15491574
hasLeftSelection,
15501575
hasLeftSelection ? copySelectedRowRange : undefined,
15511576
0,
1577+
row.left.kind === "empty",
15521578
)}
15531579
{renderWrappedSplitCellLine(
15541580
rightLine,
@@ -1560,6 +1586,7 @@ function renderRow(
15601586
hasRightSelection,
15611587
hasRightSelection ? copySelectedRowRange : undefined,
15621588
leftWidth,
1589+
row.right.kind === "empty",
15631590
)}
15641591
{guideOnNewSide ? (
15651592
<span key={`${row.key}:note-guide:${index}`} fg={theme.noteBorder}>

src/ui/diff/rowStyle.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,32 @@ export function diffRailMarker() {
1010
return "▌";
1111
}
1212

13+
/**
14+
* Glyph tiled across a split-view cell that has no equivalent line on this side.
15+
*
16+
* The light diagonal (U+2572, upper-left to lower-right) tiles into continuous diagonal lines,
17+
* reading as a "nothing here" hatch rather than as code. We use this direction over U+2571 so a
18+
* row of hatch does not resemble a leading `//` comment. Rendered instead of a flat filler block so
19+
* absent regions stay legible without a heavy grey background.
20+
*/
21+
export const EMPTY_CELL_HATCH_GLYPH = "╲";
22+
23+
/** Dim foreground for the empty-cell diagonal hatch, kept subtle against the panel background. */
24+
export function emptyHatchColor(theme: AppTheme) {
25+
return blendHex(theme.lineNumberFg, theme.background, 0.55);
26+
}
27+
28+
/**
29+
* Faint background tint behind the empty-cell hatch.
30+
*
31+
* A subtle muted wash under the diagonal lines reads as a faded/disabled region without the heavy
32+
* grey filler block. Kept lighter than {@link AppTheme.panelAlt} so the hatch, not the fill, carries
33+
* the "absent" cue.
34+
*/
35+
export function emptyHatchBg(theme: AppTheme) {
36+
return blendHex(theme.lineNumberFg, theme.background, 0.1);
37+
}
38+
1339
/**
1440
* Blend a base cell background toward the selection highlight color.
1541
*
@@ -91,9 +117,11 @@ export function splitCellPalette(
91117
}
92118

93119
if (kind === "empty") {
120+
// The cell renders a diagonal hatch over a faint muted wash (not a flat grey filler block),
121+
// so the gutter and content share the subtle "faded/disabled" background.
94122
return {
95-
gutterBg: theme.lineNumberBg,
96-
contentBg: theme.panelAlt,
123+
gutterBg: emptyHatchBg(theme),
124+
contentBg: emptyHatchBg(theme),
97125
signColor: theme.muted,
98126
numberColor: theme.lineNumberFg,
99127
};

src/ui/staticDiffPager.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,26 @@ describe("static diff pager", () => {
7979
expect(plain).not.toContain("▌ 1 + const value = 2;");
8080
});
8181

82+
test("hatches the empty side of a split row instead of a flat filler block", async () => {
83+
const patchText =
84+
"diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1,2 @@\n const keep = 1;\n+const added = 2;\n";
85+
86+
const plain = stripAnsi(
87+
await renderStaticDiffPager(
88+
patchText,
89+
{ mode: "split" },
90+
{ terminalColumns: 80, stderr: { write: () => true } },
91+
),
92+
);
93+
const addedRow = plain.split("\n").find((line) => line.includes("const added"));
94+
95+
expect(addedRow).toBeDefined();
96+
// The new line lands on the right; the absent left side is hatched, not left blank or filled.
97+
expect(addedRow).toContain("╲");
98+
expect(addedRow).toContain("+ const added = 2;");
99+
expect(addedRow!.indexOf("╲")).toBeLessThan(addedRow!.indexOf("const added"));
100+
});
101+
82102
test("keeps auto mode stacked in static pager output", async () => {
83103
const patchText =
84104
"diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n";

src/ui/staticDiffPager.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import {
2727
import { resolveSplitPaneWidths, resolveSplitCellGeometry } from "./diff/codeColumns";
2828
import {
2929
diffRailMarker,
30+
EMPTY_CELL_HATCH_GLYPH,
31+
emptyHatchBg,
32+
emptyHatchColor,
3033
neutralRailColor,
3134
splitCellPalette,
3235
splitGutterText,
@@ -184,11 +187,22 @@ function renderStaticSplitCell(
184187
gutterWidth,
185188
);
186189

190+
// Absent side: tile a diagonal hatch over the neutral surface instead of a flat filler block,
191+
// matching the interactive renderer's "nothing here" cue.
192+
const content =
193+
cell.kind === "empty"
194+
? colorText(
195+
EMPTY_CELL_HATCH_GLYPH.repeat(Math.max(0, contentWidth)),
196+
emptyHatchColor(theme),
197+
emptyHatchBg(theme),
198+
)
199+
: serializeSpansFixedWidth(cell.spans, palette.contentBg, contentWidth);
200+
187201
return `${colorText(marker(), railColor, theme.panel)}${colorText(
188202
gutterText,
189203
palette.numberColor,
190204
palette.gutterBg,
191-
)}${serializeSpansFixedWidth(cell.spans, palette.contentBg, contentWidth)}`;
205+
)}${content}`;
192206
}
193207

194208
/** Render one non-interactive split diff row as ANSI text. */

0 commit comments

Comments
 (0)