fix(tui): normalize tabs for terminal output (#6697)
This commit is contained in:
@@ -274,14 +274,35 @@ export function visibleWidth(str: string): number {
|
|||||||
* Normalize text for terminal output without changing logical editor content.
|
* Normalize text for terminal output without changing logical editor content.
|
||||||
* Some terminals render precomposed Thai/Lao AM vowels inconsistently during
|
* Some terminals render precomposed Thai/Lao AM vowels inconsistently during
|
||||||
* differential repaint. Their compatibility decompositions have the same cell
|
* differential repaint. Their compatibility decompositions have the same cell
|
||||||
* width but avoid stale-cell artifacts in terminal renderers.
|
* width but avoid stale-cell artifacts in terminal renderers. Visible tabs are
|
||||||
|
* expanded to the fixed width used by layout so terminal tab stops cannot wrap
|
||||||
|
* a logical line, while tabs inside terminal string sequences stay untouched.
|
||||||
*/
|
*/
|
||||||
const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
|
const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
|
||||||
const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
|
const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
|
||||||
|
|
||||||
export function normalizeTerminalOutput(str: string): string {
|
export function normalizeTerminalOutput(str: string): string {
|
||||||
if (!THAI_LAO_AM_REGEX.test(str)) return str;
|
let normalized = str;
|
||||||
return str.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2"));
|
if (THAI_LAO_AM_REGEX.test(normalized)) {
|
||||||
|
normalized = normalized.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) =>
|
||||||
|
char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!normalized.includes("\t")) return normalized;
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
let i = 0;
|
||||||
|
while (i < normalized.length) {
|
||||||
|
const ansi = extractAnsiCode(normalized, i);
|
||||||
|
if (ansi) {
|
||||||
|
result += ansi.code;
|
||||||
|
i += ansi.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result += normalized[i] === "\t" ? " " : normalized[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,37 @@
|
|||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { extractSegments, sliceWithWidth, visibleWidth } from "../src/utils.ts";
|
import { type Component, TUI } from "../src/tui.ts";
|
||||||
|
import { extractSegments, normalizeTerminalOutput, sliceWithWidth, visibleWidth } from "../src/utils.ts";
|
||||||
|
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||||
|
|
||||||
|
class FullViewportContent implements Component {
|
||||||
|
render(width: number): string[] {
|
||||||
|
return ["base 0", "base 1", "base 2"].map((line) => line.padEnd(width));
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CapturingVirtualTerminal extends VirtualTerminal {
|
||||||
|
private output = "";
|
||||||
|
|
||||||
|
override write(data: string): void {
|
||||||
|
this.output += data;
|
||||||
|
super.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutput(): string {
|
||||||
|
return this.output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TabStatusOverlay implements Component {
|
||||||
|
render(): string[] {
|
||||||
|
return ["\tX"];
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
describe("tab width accounting", () => {
|
describe("tab width accounting", () => {
|
||||||
it("keeps slice helper widths consistent with visible width", () => {
|
it("keeps slice helper widths consistent with visible width", () => {
|
||||||
@@ -25,4 +56,32 @@ describe("tab width accounting", () => {
|
|||||||
assert.strictEqual(tabFits.beforeWidth, 11);
|
assert.strictEqual(tabFits.beforeWidth, 11);
|
||||||
assert.strictEqual(visibleWidth(tabFits.before), tabFits.beforeWidth);
|
assert.strictEqual(visibleWidth(tabFits.before), tabFits.beforeWidth);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps tabs inside terminal control sequences byte-identical", () => {
|
||||||
|
const controlSequences = [
|
||||||
|
"\x1b]8;;https://example.test/a\tb\x07",
|
||||||
|
"\x1b]0;window\ttitle\x1b\\",
|
||||||
|
"\x1b_payload\tdata\x1b\\",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const controlSequence of controlSequences) {
|
||||||
|
assert.strictEqual(normalizeTerminalOutput(`${controlSequence}label\ttext`), `${controlSequence}label text`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps tab-containing overlays on one physical terminal row", async () => {
|
||||||
|
const terminal = new CapturingVirtualTerminal(16, 3);
|
||||||
|
const tui = new TUI(terminal);
|
||||||
|
tui.addChild(new FullViewportContent());
|
||||||
|
tui.showOverlay(new TabStatusOverlay(), { width: 4, row: 1, col: 4 });
|
||||||
|
tui.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await terminal.waitForRender();
|
||||||
|
assert.deepStrictEqual(terminal.getViewport(), ["base 0 ", "base X ", "base 2 "]);
|
||||||
|
assert.ok(!terminal.getOutput().includes("\t"));
|
||||||
|
} finally {
|
||||||
|
tui.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user