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:
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user