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 { interface ResolvedCellStyle {
key: string; key: string;
style: TextStyle; style: TextStyle;
foregroundColor: string;
} }
interface ResolvedCellColors { interface ResolvedCellColors {
@@ -227,7 +228,7 @@ export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyl
}); });
const cachedStyle = styleCache.get(key); const cachedStyle = styleCache.get(key);
if (cachedStyle) { if (cachedStyle) {
return { key, style: cachedStyle }; return { key, style: cachedStyle, foregroundColor: colors.foreground };
} }
const style = createTextStyle({ const style = createTextStyle({
@@ -237,7 +238,7 @@ export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyl
flags, flags,
}); });
styleCache.set(key, style); 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, type ViewStyle,
} from "react-native"; } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import Svg, { G, Path } from "react-native-svg";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import { import {
@@ -16,6 +17,7 @@ import {
DEFAULT_TERMINAL_THEME, DEFAULT_TERMINAL_THEME,
NATIVE_TERMINAL_SELECTION_COLORS, NATIVE_TERMINAL_SELECTION_COLORS,
} from "./colors"; } from "./colors";
import type { TerminalGlyphRect } from "./terminal-custom-glyph";
import { resolveNativeTerminalFontFamily } from "./font.native"; import { resolveNativeTerminalFontFamily } from "./font.native";
import type { TerminalViewportState } from "./headless-terminal-state"; import type { TerminalViewportState } from "./headless-terminal-state";
import { import {
@@ -24,7 +26,13 @@ import {
resolveTerminalCursorOffset, resolveTerminalCursorOffset,
type TerminalGridCellMetrics, type TerminalGridCellMetrics,
} from "./terminal-grid-metrics"; } 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"; import type { TerminalSelectionRange } from "./terminal-selection";
const MEASURE_TEXT = "mmmmmmmmmm"; const MEASURE_TEXT = "mmmmmmmmmm";
@@ -109,9 +117,100 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
return ( return (
<View style={runStyle}> <View style={runStyle}>
<Text numberOfLines={1} style={runTextStyle}> {run.renderKind === "custom-glyph" ? (
{run.text} <TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
</Text> ) : (
<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> </View>
); );
} }
@@ -337,6 +436,15 @@ const styles = StyleSheet.create({
run: { run: {
overflow: "hidden", overflow: "hidden",
}, },
customGlyphRun: {
flex: 1,
position: "relative",
},
customGlyphSvg: {
left: 0,
position: "absolute",
top: 0,
},
rowText: { rowText: {
includeFontPadding: false, includeFontPadding: false,
margin: 0, margin: 0,
@@ -79,4 +79,42 @@ describe("terminal row model", () => {
]); ]);
expect(rows[0].hash).not.toBe(buildRows({ grid, resolver })[0].hash); 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 { TerminalCell } from "@getpaseo/protocol/messages";
import type { TerminalCellStyleResolver } from "./colors"; import type { TerminalCellStyleResolver } from "./colors";
import { resolveTerminalCustomGlyph, type TerminalCustomGlyph } from "./terminal-custom-glyph";
import type { TerminalSelectionRange } from "./terminal-selection"; import type { TerminalSelectionRange } from "./terminal-selection";
const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [ const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [
@@ -17,12 +18,30 @@ const WIDE_CHAR_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x1f300, 0x1faff], [0x1f300, 0x1faff],
]; ];
export interface TerminalRun { interface TerminalRunBase {
key: string; key: string;
text: string; text: string;
cellCount: number; cellCount: number;
styleKey: string; styleKey: string;
style: TextStyle; 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 { export interface TerminalRowModel {
@@ -83,22 +102,47 @@ function appendRun(input: {
cellCount: number; cellCount: number;
styleKey: string; styleKey: string;
style: TextStyle; style: TextStyle;
foregroundColor: string;
customGlyph: TerminalCustomGlyph | null;
col: number; col: number;
}): void { }): void {
const renderKind = input.customGlyph ? "custom-glyph" : "text";
const previousRun = input.runs[input.runs.length - 1]; 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.text += input.text;
previousRun.cellCount += input.cellCount; previousRun.cellCount += input.cellCount;
if (previousRun.renderKind === "custom-glyph" && input.customGlyph) {
previousRun.glyphs.push({
key: `${input.col}:${input.text}`,
offset,
glyph: input.customGlyph,
});
}
return; return;
} }
input.runs.push({ const baseRun: TerminalRunBase = {
key: `${input.col}:${input.styleKey}`, key: `${input.col}:${input.styleKey}`,
text: input.text, text: input.text,
cellCount: input.cellCount, cellCount: input.cellCount,
styleKey: input.styleKey, styleKey: input.styleKey,
style: input.style, 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: { function cellIntersectsSelection(input: {
@@ -149,9 +193,11 @@ function buildRowModel(input: {
const text = cell.char || " "; const text = cell.char || " ";
const resolvedStyle = input.resolver.resolve(cell); const resolvedStyle = input.resolver.resolve(cell);
const cellCount = terminalCellCount(input.cells, col, text); const cellCount = terminalCellCount(input.cells, col, text);
const customGlyph = resolveTerminalCustomGlyph(text);
const selection = input.selection; const selection = input.selection;
let styleKey = resolvedStyle.key; let styleKey = resolvedStyle.key;
let style = resolvedStyle.style; let style = resolvedStyle.style;
let foregroundColor = resolvedStyle.foregroundColor;
if ( if (
selection && selection &&
cellIntersectsSelection({ cellIntersectsSelection({
@@ -167,6 +213,7 @@ function buildRowModel(input: {
foregroundColor: selection.foregroundColor, foregroundColor: selection.foregroundColor,
backgroundColor: selection.backgroundColor, backgroundColor: selection.backgroundColor,
}); });
foregroundColor = selection.foregroundColor;
} }
appendRun({ appendRun({
runs, runs,
@@ -174,11 +221,14 @@ function buildRowModel(input: {
cellCount, cellCount,
styleKey, styleKey,
style, style,
foregroundColor,
customGlyph,
col, col,
}); });
hash = hashStringPart(hash, text); hash = hashStringPart(hash, text);
hash = hashStringPart(hash, String(cellCount)); hash = hashStringPart(hash, String(cellCount));
hash = hashStringPart(hash, styleKey); hash = hashStringPart(hash, styleKey);
hash = hashStringPart(hash, customGlyph ? "custom-glyph" : "text");
if (cellCount > 1) { if (cellCount > 1) {
col += 1; col += 1;
@@ -276,6 +276,26 @@ describe("native terminal selection", () => {
expect(copied).toEqual(["COPY_OK_123"]); 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 () => { it("copies known text after it has scrolled into retained history", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 16, scrollbackLines: 20 }); const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 16, scrollbackLines: 20 });
await terminal.write("KEEP_THIS_LINE\r\n"); await terminal.write("KEEP_THIS_LINE\r\n");