fix(app): render terminal art at exact cell geometry

Native font glyph bounds do not reliably fill terminal cells, and long box-drawing runs accumulate advance drift. Paint block and box glyphs from cell geometry while preserving the original Unicode cell model for selection and copying.
This commit is contained in:
Mohamed Boudra
2026-08-04 11:00:07 +02:00
parent 32705cb3f2
commit dc6f13e31e
7 changed files with 434 additions and 10 deletions
@@ -39,6 +39,7 @@ const FLAG_STRIKETHROUGH = 1 << 5;
interface ResolvedCellStyle {
key: string;
style: TextStyle;
foregroundColor: string;
}
interface ResolvedCellColors {
@@ -227,7 +228,7 @@ export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyl
});
const cachedStyle = styleCache.get(key);
if (cachedStyle) {
return { key, style: cachedStyle };
return { key, style: cachedStyle, foregroundColor: colors.foreground };
}
const style = createTextStyle({
@@ -237,7 +238,7 @@ export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyl
flags,
});
styleCache.set(key, style);
return { key, style };
return { key, style, foregroundColor: colors.foreground };
},
};
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { resolveTerminalCustomGlyph } from "./terminal-custom-glyph";
describe("native terminal custom glyphs", () => {
it("fills the complete cell for the full block used by the Claude mascot", () => {
expect(resolveTerminalCustomGlyph("█")).toEqual({
kind: "rects",
rects: [{ x: 0, y: 0, width: 1, height: 1 }],
});
});
it("draws every Claude mascot block from exact cell fractions", () => {
const mascot = ["▐▛███▜▌", "▝▜█████▛▘", "▘▘ ▝▝"];
const customGlyphs = mascot
.join("")
.replaceAll(" ", "")
.split("")
.map(resolveTerminalCustomGlyph);
expect(customGlyphs).not.toContain(null);
expect(resolveTerminalCustomGlyph("▐")).toEqual({
kind: "rects",
rects: [{ x: 0.5, y: 0, width: 0.5, height: 1 }],
});
expect(resolveTerminalCustomGlyph("▛")).toEqual({
kind: "rects",
rects: [
{ x: 0, y: 0, width: 0.5, height: 0.5 },
{ x: 0.5, y: 0, width: 0.5, height: 0.5 },
{ x: 0, y: 0.5, width: 0.5, height: 0.5 },
],
});
expect(resolveTerminalCustomGlyph("▝")).toEqual({
kind: "rects",
rects: [{ x: 0.5, y: 0, width: 0.5, height: 0.5 }],
});
});
it("connects the rounded Claude welcome frame to every cell edge", () => {
expect(resolveTerminalCustomGlyph("─")).toEqual({
kind: "path",
path: "M0,.5 L1,.5",
strokeWidth: 1,
});
expect(resolveTerminalCustomGlyph("│")).toEqual({
kind: "path",
path: "M.5,0 L.5,1",
strokeWidth: 1,
});
expect(resolveTerminalCustomGlyph("╭")).toEqual({
kind: "path",
path: "M.5,1 L.5,.75 Q.5,.5 .75,.5 L1,.5",
strokeWidth: 1,
});
expect(resolveTerminalCustomGlyph("╮")).toEqual({
kind: "path",
path: "M0,.5 L.25,.5 Q.5,.5 .5,.75 L.5,1",
strokeWidth: 1,
});
expect(resolveTerminalCustomGlyph("╰")).toEqual({
kind: "path",
path: "M.5,0 L.5,.25 Q.5,.5 .75,.5 L1,.5",
strokeWidth: 1,
});
expect(resolveTerminalCustomGlyph("╯")).toEqual({
kind: "path",
path: "M0,.5 L.25,.5 Q.5,.5 .5,.25 L.5,0",
strokeWidth: 1,
});
});
it("keeps ordinary terminal text on the font renderer", () => {
expect(resolveTerminalCustomGlyph("C")).toBeNull();
expect(resolveTerminalCustomGlyph(" ")).toBeNull();
});
it("covers common light and heavy terminal ASCII-art frames", () => {
const frames = [
"┌────┬────┐│ │ │├────┼────┤└────┴────┘",
"┏━━━━┳━━━━┓┃ ┃ ┃┣━━━━╋━━━━┫┗━━━━┻━━━━┛",
];
for (const char of frames.join("").replaceAll(" ", "")) {
expect(resolveTerminalCustomGlyph(char), `missing custom glyph ${char}`).not.toBeNull();
}
});
});
@@ -0,0 +1,119 @@
export interface TerminalGlyphRect {
x: number;
y: number;
width: number;
height: number;
}
export interface TerminalRectGlyph {
kind: "rects";
rects: readonly TerminalGlyphRect[];
}
export interface TerminalPathGlyph {
kind: "path";
path: string;
strokeWidth: number;
}
export type TerminalCustomGlyph = TerminalRectGlyph | TerminalPathGlyph;
const LIGHT_BOX_GLYPHS: Readonly<Record<string, TerminalPathGlyph>> = {
"─": path("M0,.5 L1,.5"),
"│": path("M.5,0 L.5,1"),
"┌": path("M.5,1 L.5,.5 L1,.5"),
"┐": path("M0,.5 L.5,.5 L.5,1"),
"└": path("M.5,0 L.5,.5 L1,.5"),
"┘": path("M0,.5 L.5,.5 L.5,0"),
"├": path("M.5,0 L.5,1 M.5,.5 L1,.5"),
"┤": path("M.5,0 L.5,1 M.5,.5 L0,.5"),
"┬": path("M0,.5 L1,.5 M.5,.5 L.5,1"),
"┴": path("M0,.5 L1,.5 M.5,.5 L.5,0"),
"┼": path("M0,.5 L1,.5 M.5,0 L.5,1"),
"╭": path("M.5,1 L.5,.75 Q.5,.5 .75,.5 L1,.5"),
"╮": path("M0,.5 L.25,.5 Q.5,.5 .5,.75 L.5,1"),
"╰": path("M.5,0 L.5,.25 Q.5,.5 .75,.5 L1,.5"),
"╯": path("M0,.5 L.25,.5 Q.5,.5 .5,.25 L.5,0"),
};
const HEAVY_BOX_GLYPHS: Readonly<Record<string, TerminalPathGlyph>> = {
"━": path("M0,.5 L1,.5", 3),
"┃": path("M.5,0 L.5,1", 3),
"┏": path("M.5,1 L.5,.5 L1,.5", 3),
"┓": path("M0,.5 L.5,.5 L.5,1", 3),
"┗": path("M.5,0 L.5,.5 L1,.5", 3),
"┛": path("M0,.5 L.5,.5 L.5,0", 3),
"┣": path("M.5,0 L.5,1 M.5,.5 L1,.5", 3),
"┫": path("M.5,0 L.5,1 M.5,.5 L0,.5", 3),
"┳": path("M0,.5 L1,.5 M.5,.5 L.5,1", 3),
"┻": path("M0,.5 L1,.5 M.5,.5 L.5,0", 3),
"╋": path("M0,.5 L1,.5 M.5,0 L.5,1", 3),
};
const BLOCK_GLYPHS: Readonly<Record<string, TerminalRectGlyph>> = {
"▀": rects(rect(0, 0, 1, 0.5)),
"▁": rects(rect(0, 0.875, 1, 0.125)),
"▂": rects(rect(0, 0.75, 1, 0.25)),
"▃": rects(rect(0, 0.625, 1, 0.375)),
"▄": rects(rect(0, 0.5, 1, 0.5)),
"▅": rects(rect(0, 0.375, 1, 0.625)),
"▆": rects(rect(0, 0.25, 1, 0.75)),
"▇": rects(rect(0, 0.125, 1, 0.875)),
"█": rects(rect(0, 0, 1, 1)),
"▉": rects(rect(0, 0, 0.875, 1)),
"▊": rects(rect(0, 0, 0.75, 1)),
"▋": rects(rect(0, 0, 0.625, 1)),
"▌": rects(rect(0, 0, 0.5, 1)),
"▍": rects(rect(0, 0, 0.375, 1)),
"▎": rects(rect(0, 0, 0.25, 1)),
"▏": rects(rect(0, 0, 0.125, 1)),
"▐": rects(rect(0.5, 0, 0.5, 1)),
"▔": rects(rect(0, 0, 1, 0.125)),
"▕": rects(rect(0.875, 0, 0.125, 1)),
"▖": rects(lowerLeft()),
"▗": rects(lowerRight()),
"▘": rects(upperLeft()),
"▙": rects(upperLeft(), lowerLeft(), lowerRight()),
"▚": rects(upperLeft(), lowerRight()),
"▛": rects(upperLeft(), upperRight(), lowerLeft()),
"▜": rects(upperLeft(), upperRight(), lowerRight()),
"▝": rects(upperRight()),
"▞": rects(upperRight(), lowerLeft()),
"▟": rects(upperRight(), lowerLeft(), lowerRight()),
};
export function resolveTerminalCustomGlyph(char: string): TerminalCustomGlyph | null {
return LIGHT_BOX_GLYPHS[char] ?? HEAVY_BOX_GLYPHS[char] ?? BLOCK_GLYPHS[char] ?? null;
}
export function isTerminalCustomGlyph(char: string): boolean {
return resolveTerminalCustomGlyph(char) !== null;
}
function path(pathData: string, strokeWidth = 1): TerminalPathGlyph {
return { kind: "path", path: pathData, strokeWidth };
}
function rect(x: number, y: number, width: number, height: number): TerminalGlyphRect {
return { x, y, width, height };
}
function rects(...values: TerminalGlyphRect[]): TerminalRectGlyph {
return { kind: "rects", rects: values };
}
function upperLeft(): TerminalGlyphRect {
return rect(0, 0, 0.5, 0.5);
}
function upperRight(): TerminalGlyphRect {
return rect(0.5, 0, 0.5, 0.5);
}
function lowerLeft(): TerminalGlyphRect {
return rect(0, 0.5, 0.5, 0.5);
}
function lowerRight(): TerminalGlyphRect {
return rect(0.5, 0.5, 0.5, 0.5);
}
@@ -9,6 +9,7 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import Svg, { G, Path } from "react-native-svg";
import type { ITheme } from "@xterm/xterm";
import {
@@ -16,6 +17,7 @@ import {
DEFAULT_TERMINAL_THEME,
NATIVE_TERMINAL_SELECTION_COLORS,
} from "./colors";
import type { TerminalGlyphRect } from "./terminal-custom-glyph";
import { resolveNativeTerminalFontFamily } from "./font.native";
import type { TerminalViewportState } from "./headless-terminal-state";
import {
@@ -24,7 +26,13 @@ import {
resolveTerminalCursorOffset,
type TerminalGridCellMetrics,
} from "./terminal-grid-metrics";
import { buildRows, type TerminalRowModel, type TerminalRun } from "./terminal-row-model";
import {
buildRows,
type TerminalCustomGlyphCell,
type TerminalCustomGlyphRun,
type TerminalRowModel,
type TerminalRun,
} from "./terminal-row-model";
import type { TerminalSelectionRange } from "./terminal-selection";
const MEASURE_TEXT = "mmmmmmmmmm";
@@ -109,9 +117,100 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
return (
<View style={runStyle}>
<Text numberOfLines={1} style={runTextStyle}>
{run.text}
</Text>
{run.renderKind === "custom-glyph" ? (
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
) : (
<Text numberOfLines={1} style={runTextStyle}>
{run.text}
</Text>
)}
</View>
);
}
interface TerminalGridCustomGlyphRunProps {
run: TerminalCustomGlyphRun;
cellWidth: number;
cellHeight: number;
}
interface TerminalGridCustomGlyphRectProps {
cell: TerminalCustomGlyphCell;
rect: TerminalGlyphRect;
cellWidth: number;
cellHeight: number;
foregroundColor: string;
}
function TerminalGridCustomGlyphRect({
cell,
rect,
cellWidth,
cellHeight,
foregroundColor,
}: TerminalGridCustomGlyphRectProps) {
const style = useMemo<ViewStyle>(
() => ({
position: "absolute",
backgroundColor: foregroundColor,
left: (cell.offset + rect.x) * cellWidth,
top: rect.y * cellHeight,
width: rect.width * cellWidth,
height: rect.height * cellHeight,
}),
[cell.offset, cellHeight, cellWidth, foregroundColor, rect],
);
return <View style={style} />;
}
function TerminalGridCustomGlyphRun({
run,
cellWidth,
cellHeight,
}: TerminalGridCustomGlyphRunProps) {
const opacity = typeof run.style.opacity === "number" ? run.style.opacity : 1;
return (
<View style={[styles.customGlyphRun, { opacity }]}>
{run.glyphs.map((cell) =>
cell.glyph.kind === "rects"
? cell.glyph.rects.map((rect) => (
<TerminalGridCustomGlyphRect
key={`${cell.key}:${rect.x}:${rect.y}:${rect.width}:${rect.height}`}
cell={cell}
rect={rect}
cellWidth={cellWidth}
cellHeight={cellHeight}
foregroundColor={run.foregroundColor}
/>
))
: null,
)}
<Svg
height={cellHeight}
pointerEvents="none"
preserveAspectRatio="none"
style={styles.customGlyphSvg}
viewBox={`0 0 ${run.cellCount} 1`}
width={run.cellCount * cellWidth}
>
<G fill="none" stroke={run.foregroundColor}>
{run.glyphs.map((cell) =>
cell.glyph.kind === "path" ? (
<Path
d={cell.glyph.path}
key={cell.key}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth={cell.glyph.strokeWidth}
transform={`translate(${cell.offset} 0)`}
vectorEffect="non-scaling-stroke"
/>
) : null,
)}
</G>
</Svg>
</View>
);
}
@@ -337,6 +436,15 @@ const styles = StyleSheet.create({
run: {
overflow: "hidden",
},
customGlyphRun: {
flex: 1,
position: "relative",
},
customGlyphSvg: {
left: 0,
position: "absolute",
top: 0,
},
rowText: {
includeFontPadding: false,
margin: 0,
@@ -79,4 +79,42 @@ describe("terminal row model", () => {
]);
expect(rows[0].hash).not.toBe(buildRows({ grid, resolver })[0].hash);
});
test("splits Claude box drawing from font text at exact terminal columns", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const frame = "╭─ Claude Code ───╮";
const [row] = buildRows({
grid: [frame.split("").map((char) => cell(char, { fg: 1, fgMode: 1 }))],
resolver,
});
expect(
row.runs.map((run) => ({
text: run.text,
cellCount: run.cellCount,
renderKind: run.renderKind,
})),
).toEqual([
{ text: "╭─", cellCount: 2, renderKind: "custom-glyph" },
{ text: " Claude Code ", cellCount: 13, renderKind: "text" },
{ text: "───╮", cellCount: 4, renderKind: "custom-glyph" },
]);
});
test("keeps Claude mascot glyphs custom while preserving copied row text", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const mascot = "▐▛███▜▌ Claude Code";
const [row] = buildRows({
grid: [mascot.split("").map((char) => cell(char, { fg: 1, fgMode: 1 }))],
resolver,
});
expect(row.runs.map((run) => [run.text, run.renderKind])).toEqual([
["▐▛███▜▌", "custom-glyph"],
[" Claude Code", "text"],
]);
expect(row.runs.map((run) => run.text).join("")).toBe(mascot);
});
});
@@ -2,6 +2,7 @@ import type { TextStyle } from "react-native";
import type { TerminalCell } from "@getpaseo/protocol/messages";
import type { TerminalCellStyleResolver } from "./colors";
import { resolveTerminalCustomGlyph, type TerminalCustomGlyph } from "./terminal-custom-glyph";
import type { TerminalSelectionRange } from "./terminal-selection";
const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [
@@ -17,12 +18,30 @@ const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x1f300, 0x1faff],
];
export interface TerminalRun {
interface TerminalRunBase {
key: string;
text: string;
cellCount: number;
styleKey: string;
style: TextStyle;
foregroundColor: string;
}
export interface TerminalTextRun extends TerminalRunBase {
renderKind: "text";
}
export interface TerminalCustomGlyphRun extends TerminalRunBase {
renderKind: "custom-glyph";
glyphs: TerminalCustomGlyphCell[];
}
export type TerminalRun = TerminalCustomGlyphRun | TerminalTextRun;
export interface TerminalCustomGlyphCell {
key: string;
offset: number;
glyph: TerminalCustomGlyph;
}
export interface TerminalRowModel {
@@ -83,22 +102,47 @@ function appendRun(input: {
cellCount: number;
styleKey: string;
style: TextStyle;
foregroundColor: string;
customGlyph: TerminalCustomGlyph | null;
col: number;
}): void {
const renderKind = input.customGlyph ? "custom-glyph" : "text";
const previousRun = input.runs[input.runs.length - 1];
if (previousRun && previousRun.styleKey === input.styleKey) {
if (
previousRun &&
previousRun.styleKey === input.styleKey &&
previousRun.renderKind === renderKind
) {
const offset = previousRun.cellCount;
previousRun.text += input.text;
previousRun.cellCount += input.cellCount;
if (previousRun.renderKind === "custom-glyph" && input.customGlyph) {
previousRun.glyphs.push({
key: `${input.col}:${input.text}`,
offset,
glyph: input.customGlyph,
});
}
return;
}
input.runs.push({
const baseRun: TerminalRunBase = {
key: `${input.col}:${input.styleKey}`,
text: input.text,
cellCount: input.cellCount,
styleKey: input.styleKey,
style: input.style,
});
foregroundColor: input.foregroundColor,
};
if (input.customGlyph) {
input.runs.push({
...baseRun,
renderKind: "custom-glyph",
glyphs: [{ key: `${input.col}:${input.text}`, offset: 0, glyph: input.customGlyph }],
});
return;
}
input.runs.push({ ...baseRun, renderKind: "text" });
}
function cellIntersectsSelection(input: {
@@ -149,9 +193,11 @@ function buildRowModel(input: {
const text = cell.char || " ";
const resolvedStyle = input.resolver.resolve(cell);
const cellCount = terminalCellCount(input.cells, col, text);
const customGlyph = resolveTerminalCustomGlyph(text);
const selection = input.selection;
let styleKey = resolvedStyle.key;
let style = resolvedStyle.style;
let foregroundColor = resolvedStyle.foregroundColor;
if (
selection &&
cellIntersectsSelection({
@@ -167,6 +213,7 @@ function buildRowModel(input: {
foregroundColor: selection.foregroundColor,
backgroundColor: selection.backgroundColor,
});
foregroundColor = selection.foregroundColor;
}
appendRun({
runs,
@@ -174,11 +221,14 @@ function buildRowModel(input: {
cellCount,
styleKey,
style,
foregroundColor,
customGlyph,
col,
});
hash = hashStringPart(hash, text);
hash = hashStringPart(hash, String(cellCount));
hash = hashStringPart(hash, styleKey);
hash = hashStringPart(hash, customGlyph ? "custom-glyph" : "text");
if (cellCount > 1) {
col += 1;
@@ -276,6 +276,26 @@ describe("native terminal selection", () => {
expect(copied).toEqual(["COPY_OK_123"]);
});
it("copies custom-drawn Claude glyphs as their original Unicode text", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 });
const art = "╭─ Claude ─╮\r\n│ ▐▛███▜▌ │\r\n╰──────────╯";
await terminal.write(art);
const bounds = terminal.getBufferBounds();
const firstRow = rowWithText(terminal, "╭─ Claude ─╮");
const lastRow = rowWithText(terminal, "╰──────────╯");
expect(
extractTerminalSelectedText({
terminal,
selection: {
start: { row: firstRow, col: 0 },
end: { row: lastRow, col: 11 },
coordinateEpoch: bounds.coordinateEpoch,
},
}),
).toBe(art.replaceAll("\r", ""));
});
it("copies known text after it has scrolled into retained history", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 16, scrollbackLines: 20 });
await terminal.write("KEEP_THIS_LINE\r\n");