File size: 3,255 Bytes
67d18ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | /**
* Display-width helpers for TUI rendering.
*
* Terminal cells are monospace but East-Asian characters, fullwidth forms and
* most emoji occupy 2 cells while combining marks occupy 0 — naive `.length`
* math misaligns box borders the moment a log line contains CJK text (and
* proxy errors regularly do, since upstream bodies are passed through).
* `displayWidth` implements the common subset of wcwidth; width errors on
* exotic content are cosmetic only (a mis-truncated log line), never fatal.
*/
/** CSI sequences, OSC sequences, and remaining two-byte ESC sequences. */
const ANSI_RE = /\x1b(?:\[[0-9;?<=>! ]*[A-Za-z~@`\\]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_=>])/g;
/** Ranges whose code points occupy two terminal cells. */
const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf],
[0x4e00, 0x9fff], [0xa000, 0xa4c6], [0xa960, 0xa97c], [0xac00, 0xd7a3],
[0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe52], [0xfe54, 0xfe66],
[0xfe68, 0xfe6b], [0xff01, 0xff60], [0xffe0, 0xffe6],
[0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f900, 0x1f9ff],
[0x20000, 0x2fffd], [0x30000, 0x3fffd],
];
/** Ranges whose code points occupy zero cells (combining marks, ZW*, variation selectors). */
const ZERO_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x0300, 0x036f], [0x200b, 0x200f], [0x20d0, 0x20ff], [0xfe00, 0xfe0f],
];
function inRanges(cp: number, ranges: ReadonlyArray<readonly [number, number]>): boolean {
let lo = 0;
let hi = ranges.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const [a, b] = ranges[mid]!;
if (cp < a) hi = mid - 1;
else if (cp > b) lo = mid + 1;
else return true;
}
return false;
}
function codePointWidth(cp: number): number {
if (cp === 0) return 0;
if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0;
if (inRanges(cp, ZERO_RANGES)) return 0;
if (inRanges(cp, WIDE_RANGES)) return 2;
return 1;
}
/** Remove ANSI escape sequences so the result can be measured / compared. */
export function stripAnsi(s: string): string {
return s.replace(ANSI_RE, "");
}
/** Display width in terminal cells (ANSI escape sequences count as 0). */
export function displayWidth(s: string): number {
let w = 0;
for (const ch of stripAnsi(s)) {
w += codePointWidth(ch.codePointAt(0)!);
}
return w;
}
/**
* Truncate to `max` display cells, appending `…` when anything was cut.
* ANSI sequences are stripped first — callers that want color re-wrap the
* result themselves.
*/
export function truncateToWidth(s: string, max: number, ellipsis = "…"): string {
if (max <= 0) return "";
const plain = stripAnsi(s);
if (displayWidth(plain) <= max) return plain;
const ellWidth = displayWidth(ellipsis);
let w = 0;
let out = "";
for (const ch of plain) {
const cw = codePointWidth(ch.codePointAt(0)!);
if (w + cw > max - ellWidth) break;
out += ch;
w += cw;
}
return out + ellipsis;
}
/** Pad with spaces (display-width aware) so the string occupies exactly `width` cells. */
export function padEndWidth(s: string, width: number): string {
const pad = width - displayWidth(s);
return pad > 0 ? s + " ".repeat(pad) : s;
}
|