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;
}