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