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": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
@@ -2073,6 +2074,15 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/@xterm/addon-fit": {
|
||||||
"version": "0.10.0",
|
"version": "0.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
interface TerminalViewProps {
|
interface TerminalViewProps {
|
||||||
@@ -29,6 +30,24 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
|||||||
const fit = new FitAddon();
|
const fit = new FitAddon();
|
||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
if (containerRef.current) term.open(containerRef.current);
|
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();
|
fit.fit();
|
||||||
|
|
||||||
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
// 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();
|
resizeDisposable.dispose();
|
||||||
dataDisposable.dispose();
|
dataDisposable.dispose();
|
||||||
ws?.close();
|
ws?.close();
|
||||||
|
canvas?.dispose();
|
||||||
term.dispose();
|
term.dispose();
|
||||||
};
|
};
|
||||||
}, [runId, wsBaseUrl]);
|
}, [runId, wsBaseUrl]);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const openMock = vi.fn();
|
|||||||
const disposeMock = vi.fn();
|
const disposeMock = vi.fn();
|
||||||
const wheelHandlerMock = vi.fn();
|
const wheelHandlerMock = vi.fn();
|
||||||
const keyHandlerMock = vi.fn();
|
const keyHandlerMock = vi.fn();
|
||||||
|
const loadAddonMock = vi.fn();
|
||||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||||
const termState = {
|
const termState = {
|
||||||
modes: { mouseTrackingMode: "none" as "none" | "x10" | "vt200" | "drag" | "any" },
|
modes: { mouseTrackingMode: "none" as "none" | "x10" | "vt200" | "drag" | "any" },
|
||||||
@@ -50,7 +51,7 @@ vi.mock("@xterm/xterm", () => ({
|
|||||||
return { dispose: vi.fn() };
|
return { dispose: vi.fn() };
|
||||||
},
|
},
|
||||||
dispose: disposeMock,
|
dispose: disposeMock,
|
||||||
loadAddon: vi.fn(),
|
loadAddon: loadAddonMock,
|
||||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||||
attachCustomKeyEventHandler: keyHandlerMock,
|
attachCustomKeyEventHandler: keyHandlerMock,
|
||||||
})),
|
})),
|
||||||
@@ -58,6 +59,9 @@ vi.mock("@xterm/xterm", () => ({
|
|||||||
vi.mock("@xterm/addon-fit", () => ({
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@xterm/addon-canvas", () => ({
|
||||||
|
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
static instances: MockWebSocket[] = [];
|
static instances: MockWebSocket[] = [];
|
||||||
@@ -94,6 +98,7 @@ describe("TerminalView", () => {
|
|||||||
writeMock.mockClear();
|
writeMock.mockClear();
|
||||||
wheelHandlerMock.mockClear();
|
wheelHandlerMock.mockClear();
|
||||||
keyHandlerMock.mockClear();
|
keyHandlerMock.mockClear();
|
||||||
|
loadAddonMock.mockClear();
|
||||||
openMock.mockClear();
|
openMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,6 +184,18 @@ describe("TerminalView", () => {
|
|||||||
expect(preventPlainTab).not.toHaveBeenCalled();
|
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)", () => {
|
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
|
// 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
|
// next row's left margin, and tmux never repaints a row it believes
|
||||||
|
|||||||
Reference in New Issue
Block a user