Skip to content

Commit b6ef353

Browse files
committed
feat(render): Render block elements as fillRect instead of font glyphs
The Block Elements range U+2580..U+259F (half blocks, eighths blocks, shading, quadrants) was being drawn through the browser's font, like any other codepoint. The font's rasterization of these glyphs doesn't quite fill the cell box — typically a sub-pixel gap on one or two edges. In half-block image renderings (pixterm/ansimage, ntcharts-picture's glyph mode, chafa), where adjacent cells carry different colors, those gaps line up into a 1-device-px grid overlaid on the image. Visible at integer dpr (e.g. dpr=1); at dpr=2 the gap gets antialiased away, which is why the artifact has the surprising "sticky" property of appearing only on windows opened on a non-retina display. Native terminals (Ghostty, kitty, alacritty) draw block elements programmatically as filled rectangles for exactly this reason. Do the same: a renderBlockElement helper handles the whole U+2580..U+259F range using the cell's existing fillStyle, returning true if the codepoint is a block element so renderCellText skips the fillText call. Eighths blocks are computed from codepoint arithmetic (n/8 of the cell box). Shading uses globalAlpha modulation. Quadrants use a codepoint-to-bitmap map. Restricted to simple cells (grapheme_len === 0); cells with combining marks fall back to fillText so block-element-as-base-with-combiners still renders correctly. Signed-off-by: Evan Wies <evan@neomantra.net>
1 parent 4a0ae7c commit b6ef353

1 file changed

Lines changed: 125 additions & 1 deletion

File tree

lib/renderer.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,21 @@ export class CanvasRenderer {
827827
// Simple cell - single codepoint
828828
char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null
829829
}
830-
this.ctx.fillText(char, textX, textY);
830+
831+
// Block elements (U+2580..U+259F) draw as fillRect using the same
832+
// fillStyle. The browser's font rasterization of these glyphs leaves
833+
// sub-pixel gaps that line up into a visible cell grid in half-block
834+
// image renderings (ansimage, pixterm) at dpr=1; native terminals
835+
// (Ghostty, kitty, alacritty) draw them programmatically for the same
836+
// reason. Only takes the fast path for simple (non-grapheme) cells.
837+
if (
838+
cell.grapheme_len === 0 &&
839+
this.renderBlockElement(cell.codepoint, cellX, cellY, cellWidth)
840+
) {
841+
// handled by renderBlockElement
842+
} else {
843+
this.ctx.fillText(char, textX, textY);
844+
}
831845

832846
// Reset alpha
833847
if (cell.flags & CellFlags.FAINT) {
@@ -1011,6 +1025,116 @@ export class CanvasRenderer {
10111025
return canvas;
10121026
}
10131027

1028+
/**
1029+
* Render a Block Elements codepoint (U+2580..U+259F) as fillRect(s) in
1030+
* the current fillStyle. Returns true if the codepoint is a handled
1031+
* block element; false to fall through to fillText.
1032+
*
1033+
* Drawing block elements through the font produces ~1-device-px gaps
1034+
* at cell edges at integer dpr because the rasterized glyph doesn't
1035+
* exactly fill the cell box. In half-block image renderings (ansimage,
1036+
* pixterm) those gaps line up into a visible cell grid. Native
1037+
* terminals draw block elements programmatically for the same reason.
1038+
*
1039+
* The eighths blocks (U+2581..U+2587 lower; U+2589..U+258F left) and
1040+
* full block (U+2588) are stripes of n/8 of the cell. Shading blocks
1041+
* (U+2591..U+2593) modulate globalAlpha for 25/50/75% fill. Quadrant
1042+
* blocks (U+2596..U+259F) split the cell into a 2x2 grid and fill
1043+
* some subset.
1044+
*/
1045+
private renderBlockElement(
1046+
codepoint: number,
1047+
cellX: number,
1048+
cellY: number,
1049+
cellWidth: number
1050+
): boolean {
1051+
if (codepoint < 0x2580 || codepoint > 0x259f) return false;
1052+
1053+
const w = cellWidth;
1054+
const h = this.metrics.height;
1055+
1056+
// Upper half ▀
1057+
if (codepoint === 0x2580) {
1058+
this.ctx.fillRect(cellX, cellY, w, Math.round(h / 2));
1059+
return true;
1060+
}
1061+
1062+
// Lower n/8 blocks ▁▂▃▄▅▆▇ + full block █ (= 8/8)
1063+
if (codepoint >= 0x2581 && codepoint <= 0x2588) {
1064+
const eighths = codepoint - 0x2580;
1065+
const blockH = Math.round((h * eighths) / 8);
1066+
this.ctx.fillRect(cellX, cellY + h - blockH, w, blockH);
1067+
return true;
1068+
}
1069+
1070+
// Left n/8 blocks ▉▊▋▌▍▎▏ — eighths decreases as codepoint increases
1071+
if (codepoint >= 0x2589 && codepoint <= 0x258f) {
1072+
const eighths = 0x2590 - codepoint;
1073+
const blockW = Math.round((w * eighths) / 8);
1074+
this.ctx.fillRect(cellX, cellY, blockW, h);
1075+
return true;
1076+
}
1077+
1078+
// Right half ▐
1079+
if (codepoint === 0x2590) {
1080+
const left = Math.round(w / 2);
1081+
this.ctx.fillRect(cellX + left, cellY, w - left, h);
1082+
return true;
1083+
}
1084+
1085+
// Shading ░▒▓ — modulate globalAlpha against current fillStyle
1086+
if (codepoint >= 0x2591 && codepoint <= 0x2593) {
1087+
const alphaForShade = [0.25, 0.5, 0.75][codepoint - 0x2591];
1088+
const prev = this.ctx.globalAlpha;
1089+
this.ctx.globalAlpha = prev * alphaForShade;
1090+
this.ctx.fillRect(cellX, cellY, w, h);
1091+
this.ctx.globalAlpha = prev;
1092+
return true;
1093+
}
1094+
1095+
// Upper 1/8 ▔
1096+
if (codepoint === 0x2594) {
1097+
this.ctx.fillRect(cellX, cellY, w, Math.round(h / 8));
1098+
return true;
1099+
}
1100+
1101+
// Right 1/8 ▕
1102+
if (codepoint === 0x2595) {
1103+
const left = Math.round((w * 7) / 8);
1104+
this.ctx.fillRect(cellX + left, cellY, w - left, h);
1105+
return true;
1106+
}
1107+
1108+
// Quadrants ▖▗▘▙▚▛▜▝▞▟ at U+2596..U+259F. Bitmap of which corners
1109+
// (UL, UR, LL, LR) are filled per codepoint.
1110+
const QUAD_UL = 0b1000;
1111+
const QUAD_UR = 0b0100;
1112+
const QUAD_LL = 0b0010;
1113+
const QUAD_LR = 0b0001;
1114+
const quadMap: Record<number, number> = {
1115+
0x2596: QUAD_LL,
1116+
0x2597: QUAD_LR,
1117+
0x2598: QUAD_UL,
1118+
0x2599: QUAD_UL | QUAD_LL | QUAD_LR,
1119+
0x259a: QUAD_UL | QUAD_LR,
1120+
0x259b: QUAD_UL | QUAD_UR | QUAD_LL,
1121+
0x259c: QUAD_UL | QUAD_UR | QUAD_LR,
1122+
0x259d: QUAD_UR,
1123+
0x259e: QUAD_UR | QUAD_LL,
1124+
0x259f: QUAD_UR | QUAD_LL | QUAD_LR,
1125+
};
1126+
const quads = quadMap[codepoint];
1127+
if (quads === undefined) return false;
1128+
const halfW = Math.round(w / 2);
1129+
const halfH = Math.round(h / 2);
1130+
if (quads & QUAD_UL) this.ctx.fillRect(cellX, cellY, halfW, halfH);
1131+
if (quads & QUAD_UR) this.ctx.fillRect(cellX + halfW, cellY, w - halfW, halfH);
1132+
if (quads & QUAD_LL) this.ctx.fillRect(cellX, cellY + halfH, halfW, h - halfH);
1133+
if (quads & QUAD_LR)
1134+
this.ctx.fillRect(cellX + halfW, cellY + halfH, w - halfW, h - halfH);
1135+
return true;
1136+
}
1137+
10141138
/**
10151139
* Substitute a cell's text rendering with a slice of a kitty graphics
10161140
* image. Called from renderCellText when the cell's codepoint is

0 commit comments

Comments
 (0)