fix(run): draw the lane console through the canvas renderer
Scrolling a console left stray characters glued to the left edge of blank lines. Copying an affected line yields clean text, so the buffer was always correct — only the painting drifted, which no CSS can correct. xterm's default DOM renderer aligns each row with a single `letter-spacing` value. That holds only while every glyph comes from one monospace font, and TerminalView passed no fontFamily, so xterm used its default `courier-new, courier, monospace`: Vietnamese diacritics and CJK fall through to a fallback face whose advance width differs, glyphs shove their neighbours along, and the row's tail spills onto the row below. The canvas renderer draws every cell at a fixed position, so a wide glyph can no longer displace its neighbours. Loaded best-effort — a browser that cannot hand back a 2D context keeps the DOM renderer rather than losing the terminal entirely. Ruled out first, each by replaying the real byte stream into xterm.js and diffing against `tmux capture-pane`: wrapped Vietnamese at the margin, mixed CJK, wheel scrolling through copy-mode, and a live 131->95->131 resize all render byte-identically at matched widths. client 448/448, tsc clean.
This commit is contained in:
Generated
+10
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
@@ -2073,6 +2074,15 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-canvas": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
|
||||
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
interface TerminalViewProps {
|
||||
@@ -29,6 +30,24 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
if (containerRef.current) term.open(containerRef.current);
|
||||
|
||||
// Draw each cell at a fixed position instead of letting the browser lay the
|
||||
// row out as text. xterm's default DOM renderer aligns a row with a single
|
||||
// `letter-spacing`, which only holds while every glyph comes from one
|
||||
// monospace font — Vietnamese diacritics and CJK fall through to a fallback
|
||||
// face whose advance width differs, so glyphs shove their neighbours along
|
||||
// and the row's tail spills onto the row below. The buffer is correct
|
||||
// throughout (copying the line yields clean text); only the painting drifts,
|
||||
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
|
||||
// context keeps the DOM renderer rather than losing the terminal.
|
||||
let canvas: CanvasAddon | null = null;
|
||||
try {
|
||||
canvas = new CanvasAddon();
|
||||
term.loadAddon(canvas);
|
||||
} catch {
|
||||
canvas = null;
|
||||
}
|
||||
|
||||
fit.fit();
|
||||
|
||||
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
||||
@@ -160,6 +179,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
canvas?.dispose();
|
||||
term.dispose();
|
||||
};
|
||||
}, [runId, wsBaseUrl]);
|
||||
|
||||
@@ -16,6 +16,7 @@ const openMock = vi.fn();
|
||||
const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
const termState = {
|
||||
modes: { mouseTrackingMode: "none" as "none" | "x10" | "vt200" | "drag" | "any" },
|
||||
@@ -50,7 +51,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
dispose: disposeMock,
|
||||
loadAddon: vi.fn(),
|
||||
loadAddon: loadAddonMock,
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
})),
|
||||
@@ -58,6 +59,9 @@ vi.mock("@xterm/xterm", () => ({
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@xterm/addon-canvas", () => ({
|
||||
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })),
|
||||
}));
|
||||
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = [];
|
||||
@@ -94,6 +98,7 @@ describe("TerminalView", () => {
|
||||
writeMock.mockClear();
|
||||
wheelHandlerMock.mockClear();
|
||||
keyHandlerMock.mockClear();
|
||||
loadAddonMock.mockClear();
|
||||
openMock.mockClear();
|
||||
});
|
||||
|
||||
@@ -179,6 +184,18 @@ describe("TerminalView", () => {
|
||||
expect(preventPlainTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders through the canvas addon, not xterm's DOM renderer", () => {
|
||||
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||
// fallback face with a different advance, so glyphs shove their neighbours
|
||||
// and the row's tail spills onto the row below — buffer clean, painting
|
||||
// wrong. The canvas renderer draws each cell at a fixed position instead.
|
||||
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
||||
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
||||
// next row's left margin, and tmux never repaints a row it believes
|
||||
|
||||
Reference in New Issue
Block a user