linegauge 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -12
- package/dist/index.d.ts +31 -5
- package/dist/index.js +31 -5
- package/dist/slice.d.ts +10 -0
- package/dist/slice.js +100 -0
- package/dist/strip.d.ts +26 -0
- package/dist/strip.js +62 -0
- package/dist/style.d.ts +71 -0
- package/dist/style.js +276 -0
- package/dist/truncate.d.ts +12 -0
- package/dist/truncate.js +42 -0
- package/dist/widest.d.ts +6 -0
- package/dist/widest.js +25 -0
- package/dist/width.d.ts +45 -0
- package/dist/width.js +226 -0
- package/dist/wrap.d.ts +12 -0
- package/dist/wrap.js +271 -0
- package/package.json +29 -3
package/dist/style.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The style stack — the part of this package that every cutting operation shares.
|
|
8
|
+
*
|
|
9
|
+
* `wrap`, `slice` and `truncate` are one algorithm wearing three names: walk the string,
|
|
10
|
+
* keep a stack of the SGR parameters a terminal currently has open, emit graphemes while
|
|
11
|
+
* inside the range you were asked for, and at every cut emit the closer for whatever is
|
|
12
|
+
* open and re-emit the openers on resume. The twelve incumbents write that three times
|
|
13
|
+
* between them — `wrap-ansi`, `slice-ansi` and `cli-truncate` each carry their own copy —
|
|
14
|
+
* and disagree at the edges, which is most of the reason this package exists.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `wrap.ts` when `slice` arrived, with wrap-ansi's own suite as the check:
|
|
17
|
+
* this module is a move, and `wrap.test.ts` grades every case against the real `wrap-ansi`,
|
|
18
|
+
* so a mistake in the extraction is a red suite rather than a subtle divergence.
|
|
19
|
+
*
|
|
20
|
+
* It is not a published subpath. `linegauge/wrap` and `linegauge/slice` each reach it, and
|
|
21
|
+
* R8 keeps them from reaching each other.
|
|
22
|
+
*/
|
|
23
|
+
export const ESC = '\u001B';
|
|
24
|
+
export const BELL = '\u0007';
|
|
25
|
+
/** The single-byte C1 form of `ESC [`, which a terminal accepts and a suite will send. */
|
|
26
|
+
const C1_CSI = '\u009B';
|
|
27
|
+
export const CSI = '[';
|
|
28
|
+
export const OSC = ']';
|
|
29
|
+
export const SGR_TERMINATOR = 'm';
|
|
30
|
+
const SGR_RESET = 0;
|
|
31
|
+
const SGR_RESET_FOREGROUND = 39;
|
|
32
|
+
const SGR_RESET_BACKGROUND = 49;
|
|
33
|
+
const SGR_RESET_UNDERLINE_COLOR = 59;
|
|
34
|
+
const SGR_FOREGROUND_EXTENDED = 38;
|
|
35
|
+
const SGR_BACKGROUND_EXTENDED = 48;
|
|
36
|
+
const SGR_UNDERLINE_COLOR_EXTENDED = 58;
|
|
37
|
+
const SGR_COLOR_MODE_RGB = 2;
|
|
38
|
+
const SGR_COLOR_MODE_256 = 5;
|
|
39
|
+
const FOREGROUND_FIRST = 30;
|
|
40
|
+
const FOREGROUND_LAST = 37;
|
|
41
|
+
const FOREGROUND_BRIGHT_FIRST = 90;
|
|
42
|
+
const FOREGROUND_BRIGHT_LAST = 97;
|
|
43
|
+
const BACKGROUND_FIRST = 40;
|
|
44
|
+
const BACKGROUND_LAST = 47;
|
|
45
|
+
const BACKGROUND_BRIGHT_FIRST = 100;
|
|
46
|
+
const BACKGROUND_BRIGHT_LAST = 107;
|
|
47
|
+
/** How many columns a tab advances to the next stop. */
|
|
48
|
+
export const TAB_SIZE = 8;
|
|
49
|
+
/** `38;5;n` — the code, the mode, and one index. */
|
|
50
|
+
const COLOR_256_PARTS = 3;
|
|
51
|
+
/** `38;2;r;g;b` — the code, the mode, and three components. */
|
|
52
|
+
const COLOR_RGB_PARTS = 3;
|
|
53
|
+
/** `38:2::r:g:b` carries a colour space between the mode and the components. */
|
|
54
|
+
const COLON_RGB_WITH_SPACE = 6;
|
|
55
|
+
export const ESCAPES = new Set([ESC, C1_CSI]);
|
|
56
|
+
const ESCAPE_CHARACTERS = [...ESCAPES].join('');
|
|
57
|
+
const CSI_INTRODUCER = `(?:${ESC}\\${CSI}|${C1_CSI})`;
|
|
58
|
+
const CSI_PARAMETERS = '[0-?]*[ -/]*[@-~]';
|
|
59
|
+
const SGR_PARAMETERS = `(?<sgr>[0-9;:]*)${SGR_TERMINATOR}`;
|
|
60
|
+
const OSC_TERMINATOR = `(?:${BELL}|${ESC}\\\\)`;
|
|
61
|
+
const OSC_PAYLOAD = String.raw `[^\u0000-\u001F\u007F-\u009F]*`;
|
|
62
|
+
/** `OSC 8 ; params ; URI ST` — a hyperlink, whose URI is tracked so a row can reopen it. */
|
|
63
|
+
const LINK_PARAMETERS = String.raw `8;(?<parameters>[^;\u0000-\u001F\u007F-\u009F]*);(?<uri>${OSC_PAYLOAD})${OSC_TERMINATOR}`;
|
|
64
|
+
// Deliberately not a terminal emulator: semicolon-delimited SGR, colon-delimited extended
|
|
65
|
+
// colour and OSC 8 links are understood; every other complete CSI or OSC command is carried
|
|
66
|
+
// through as an opaque zero-width unit, and anything that only looks like an introducer
|
|
67
|
+
// stays plain text. `y` (sticky), so a match is anchored where the scan asked.
|
|
68
|
+
export const ANSI_ESCAPE = new RegExp(`${CSI_INTRODUCER}(?:${SGR_PARAMETERS}|${CSI_PARAMETERS})|${ESC}\\${OSC}(?:${LINK_PARAMETERS}|${OSC_PAYLOAD}${OSC_TERMINATOR})`, 'y');
|
|
69
|
+
const ESCAPE_INTRODUCER = new RegExp(`[${ESCAPE_CHARACTERS}]`, 'g');
|
|
70
|
+
export const ROW_BOUNDARY = new RegExp(`[\\n${ESCAPE_CHARACTERS}]`, 'g');
|
|
71
|
+
/** Every printable ASCII character is its own cluster of width one — skip the segmenter. */
|
|
72
|
+
export const ASCII_PRINTABLE = /^[ -~]*$/;
|
|
73
|
+
/**
|
|
74
|
+
* Which SGR code closes which modifier. This is ECMA-48, not any library's table — bold
|
|
75
|
+
* opens with 1 and closes with 22 wherever you read it — so it lives here rather than
|
|
76
|
+
* being imported, which keeps `wrap()` free of `roundel/chalk` and takes 18 KB off every
|
|
77
|
+
* subpath that wraps. The colour families close with 39, 49 and 59 and are handled by name
|
|
78
|
+
* above, before this map is consulted.
|
|
79
|
+
*/
|
|
80
|
+
export const MODIFIER_CLOSE = new Map([
|
|
81
|
+
[1, 22],
|
|
82
|
+
[2, 22],
|
|
83
|
+
[3, 23],
|
|
84
|
+
[4, 24],
|
|
85
|
+
[7, 27],
|
|
86
|
+
[8, 28],
|
|
87
|
+
[9, 29],
|
|
88
|
+
[53, 55],
|
|
89
|
+
]);
|
|
90
|
+
export const MODIFIER_CLOSE_CODES = new Set(MODIFIER_CLOSE.values());
|
|
91
|
+
export const segmenter = new Intl.Segmenter();
|
|
92
|
+
export const sgr = (code) => `${ESC}${CSI}${code}${SGR_TERMINATOR}`;
|
|
93
|
+
export const hyperlink = (url, parameters = '') => `${ESC}${OSC}8;${parameters};${url}${BELL}`;
|
|
94
|
+
/** The complete escape sequence starting at `index`, or nothing when none starts there. */
|
|
95
|
+
export function matchEscape(string, index) {
|
|
96
|
+
if (!ESCAPES.has(string[index] ?? ''))
|
|
97
|
+
return undefined;
|
|
98
|
+
ANSI_ESCAPE.lastIndex = index;
|
|
99
|
+
return ANSI_ESCAPE.exec(string) ?? undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Walk a string as alternating runs of plain text and complete escape sequences. A
|
|
103
|
+
* character that looks like an introducer but starts no valid sequence stays plain text.
|
|
104
|
+
*/
|
|
105
|
+
export function forEachSegment(string, onPlainText, onEscape = () => undefined) {
|
|
106
|
+
let plainStart = 0;
|
|
107
|
+
let index = 0;
|
|
108
|
+
while (index < string.length) {
|
|
109
|
+
ESCAPE_INTRODUCER.lastIndex = index;
|
|
110
|
+
const introducer = ESCAPE_INTRODUCER.exec(string);
|
|
111
|
+
if (introducer === null)
|
|
112
|
+
break;
|
|
113
|
+
const escape = matchEscape(string, introducer.index);
|
|
114
|
+
if (escape === undefined) {
|
|
115
|
+
index = introducer.index + 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (introducer.index > plainStart)
|
|
119
|
+
onPlainText(string.slice(plainStart, introducer.index));
|
|
120
|
+
onEscape(escape[0]);
|
|
121
|
+
index = introducer.index + escape[0].length;
|
|
122
|
+
plainStart = index;
|
|
123
|
+
}
|
|
124
|
+
if (plainStart < string.length)
|
|
125
|
+
onPlainText(string.slice(plainStart));
|
|
126
|
+
}
|
|
127
|
+
const isDigits = (value) => /^\d+$/.test(value);
|
|
128
|
+
/** `38:5:9` and `38:2::r:g:b` — the colon form, which carries its arguments in one parameter. */
|
|
129
|
+
function colonColorToken(parameter) {
|
|
130
|
+
const parts = parameter.split(':');
|
|
131
|
+
const code = Number.parseInt(parts[0] ?? '', 10);
|
|
132
|
+
const mode = Number.parseInt(parts[1] ?? '', 10);
|
|
133
|
+
if (![SGR_FOREGROUND_EXTENDED, SGR_BACKGROUND_EXTENDED, SGR_UNDERLINE_COLOR_EXTENDED].includes(code))
|
|
134
|
+
return undefined;
|
|
135
|
+
if (mode === SGR_COLOR_MODE_256 && parts.length === COLOR_256_PARTS && isDigits(parts[2] ?? '')) {
|
|
136
|
+
return { code, open: parameter, hasArguments: true };
|
|
137
|
+
}
|
|
138
|
+
if (mode !== SGR_COLOR_MODE_RGB)
|
|
139
|
+
return undefined;
|
|
140
|
+
const withSpace = parts.length === COLON_RGB_WITH_SPACE;
|
|
141
|
+
const components = withSpace ? parts.slice(3) : parts.slice(2);
|
|
142
|
+
const colorSpace = withSpace ? parts[2] : undefined;
|
|
143
|
+
if (components.length === COLOR_RGB_PARTS && components.every(isDigits) && (colorSpace === undefined || /^\d*$/.test(colorSpace))) {
|
|
144
|
+
return { code, open: parameter, hasArguments: true };
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
/** One extended-colour parameter run, `38;5;n` or `38;2;r;g;b`, or nothing if malformed. */
|
|
149
|
+
function extendedColorToken(code, parameters, index) {
|
|
150
|
+
const mode = Number.parseInt(parameters[index + 1] ?? '', 10);
|
|
151
|
+
const first = Number.parseInt(parameters[index + 2] ?? '', 10);
|
|
152
|
+
if (mode === SGR_COLOR_MODE_256 && Number.isFinite(first)) {
|
|
153
|
+
return { token: { code, open: [code, mode, first].join(';'), hasArguments: true }, consumed: 2 };
|
|
154
|
+
}
|
|
155
|
+
const green = Number.parseInt(parameters[index + 3] ?? '', 10);
|
|
156
|
+
const blue = Number.parseInt(parameters[index + 4] ?? '', 10);
|
|
157
|
+
if (mode === SGR_COLOR_MODE_RGB && Number.isFinite(first) && Number.isFinite(green) && Number.isFinite(blue)) {
|
|
158
|
+
return { token: { code, open: [code, mode, first, green, blue].join(';'), hasArguments: true }, consumed: 4 };
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
const isExtendedColor = (code) => code === SGR_FOREGROUND_EXTENDED || code === SGR_BACKGROUND_EXTENDED || code === SGR_UNDERLINE_COLOR_EXTENDED;
|
|
163
|
+
export function sgrTokens(parameters) {
|
|
164
|
+
const parts = parameters.split(';');
|
|
165
|
+
const tokens = [];
|
|
166
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
167
|
+
const parameter = parts[index] ?? '';
|
|
168
|
+
if (parameter.includes(':')) {
|
|
169
|
+
const token = colonColorToken(parameter);
|
|
170
|
+
if (token !== undefined)
|
|
171
|
+
tokens.push(token);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const code = parameter === '' ? SGR_RESET : Number.parseInt(parameter, 10);
|
|
175
|
+
if (!Number.isFinite(code))
|
|
176
|
+
continue;
|
|
177
|
+
if (isExtendedColor(code)) {
|
|
178
|
+
if (index + 1 >= parts.length)
|
|
179
|
+
break;
|
|
180
|
+
const extended = extendedColorToken(code, parts, index);
|
|
181
|
+
if (extended === undefined)
|
|
182
|
+
break;
|
|
183
|
+
tokens.push(extended.token);
|
|
184
|
+
index += extended.consumed;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
tokens.push({ code, open: String(code), hasArguments: false });
|
|
188
|
+
}
|
|
189
|
+
return tokens;
|
|
190
|
+
}
|
|
191
|
+
function removeFamily(active, family) {
|
|
192
|
+
const at = active.findIndex((style) => style.family === family);
|
|
193
|
+
if (at !== -1)
|
|
194
|
+
active.splice(at, 1);
|
|
195
|
+
}
|
|
196
|
+
function colorStyle(token) {
|
|
197
|
+
const { code, open, hasArguments } = token;
|
|
198
|
+
if ((code >= FOREGROUND_FIRST && code <= FOREGROUND_LAST) || (code >= FOREGROUND_BRIGHT_FIRST && code <= FOREGROUND_BRIGHT_LAST) || (code === SGR_FOREGROUND_EXTENDED && hasArguments)) {
|
|
199
|
+
return { family: 'foreground', open, close: SGR_RESET_FOREGROUND };
|
|
200
|
+
}
|
|
201
|
+
if ((code >= BACKGROUND_FIRST && code <= BACKGROUND_LAST) || (code >= BACKGROUND_BRIGHT_FIRST && code <= BACKGROUND_BRIGHT_LAST) || (code === SGR_BACKGROUND_EXTENDED && hasArguments)) {
|
|
202
|
+
return { family: 'background', open, close: SGR_RESET_BACKGROUND };
|
|
203
|
+
}
|
|
204
|
+
if (code === SGR_UNDERLINE_COLOR_EXTENDED && hasArguments) {
|
|
205
|
+
return { family: 'underlineColor', open, close: SGR_RESET_UNDERLINE_COLOR };
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
/** True when the code closed something rather than opening it. */
|
|
210
|
+
function applyResetCode(code, active) {
|
|
211
|
+
if (code === SGR_RESET) {
|
|
212
|
+
active.length = 0;
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (code === SGR_RESET_FOREGROUND) {
|
|
216
|
+
removeFamily(active, 'foreground');
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (code === SGR_RESET_BACKGROUND) {
|
|
220
|
+
removeFamily(active, 'background');
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
if (code === SGR_RESET_UNDERLINE_COLOR) {
|
|
224
|
+
removeFamily(active, 'underlineColor');
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
if (MODIFIER_CLOSE_CODES.has(code)) {
|
|
228
|
+
// One close code can end several modifiers — `22` ends both bold and dim.
|
|
229
|
+
for (let index = active.length - 1; index >= 0; index -= 1) {
|
|
230
|
+
const style = active[index];
|
|
231
|
+
if (style !== undefined && style.family.startsWith('modifier-') && style.close === code)
|
|
232
|
+
active.splice(index, 1);
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
export function applyToken(token, active) {
|
|
239
|
+
if (applyResetCode(token.code, active))
|
|
240
|
+
return;
|
|
241
|
+
const color = colorStyle(token);
|
|
242
|
+
if (color !== undefined) {
|
|
243
|
+
removeFamily(active, color.family);
|
|
244
|
+
active.push(color);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const close = MODIFIER_CLOSE.get(token.code);
|
|
248
|
+
if (close !== undefined && close !== SGR_RESET) {
|
|
249
|
+
const family = `modifier-${token.code}`;
|
|
250
|
+
removeFamily(active, family);
|
|
251
|
+
active.push({ family, open: token.open, close });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export const applyParameters = (parameters, active) => {
|
|
255
|
+
for (const token of sgrTokens(parameters))
|
|
256
|
+
applyToken(token, active);
|
|
257
|
+
};
|
|
258
|
+
const applyResets = (parameters, active) => {
|
|
259
|
+
for (const { code } of sgrTokens(parameters))
|
|
260
|
+
applyResetCode(code, active);
|
|
261
|
+
};
|
|
262
|
+
/** A row that opens with its own resets should not have them undone by the reopening. */
|
|
263
|
+
export function applyLeadingResets(string, startIndex, active) {
|
|
264
|
+
let index = startIndex;
|
|
265
|
+
while (index < string.length) {
|
|
266
|
+
const match = matchEscape(string, index);
|
|
267
|
+
if (match === undefined)
|
|
268
|
+
break;
|
|
269
|
+
if (match.groups?.['sgr'] !== undefined)
|
|
270
|
+
applyResets(match.groups['sgr'], active);
|
|
271
|
+
index += match[0].length;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export const closingSequence = (active) => [...active].reverse().map((style) => sgr(style.close)).join('');
|
|
275
|
+
export const openingSequence = (active) => active.map((style) => sgr(style.open)).join('');
|
|
276
|
+
//# sourceMappingURL=style.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
export interface TruncateOptions {
|
|
7
|
+
/** Which end loses characters. Default `'end'`. */
|
|
8
|
+
position?: 'start' | 'middle' | 'end';
|
|
9
|
+
/** The mark that says something was removed. Measured, not assumed. Default `'\u2026'`. */
|
|
10
|
+
ellipsis?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function truncate(string: string, columns: number, options?: TruncateOptions): string;
|
package/dist/truncate.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* R6 — shorten a styled string to `columns` display columns, with the ellipsis **inside**
|
|
8
|
+
* the budget.
|
|
9
|
+
*
|
|
10
|
+
* That last clause is the whole value and the off-by-one every hand-rolled truncator gets
|
|
11
|
+
* wrong: `truncate(s, 10)` must return something ten columns wide, not ten columns plus an
|
|
12
|
+
* ellipsis. `cli-truncate` exists as a package (36 M/wk) essentially to get this right, and
|
|
13
|
+
* pays `slice-ansi` and `string-width` to do it.
|
|
14
|
+
*
|
|
15
|
+
* The ellipsis is measured with `width`, not assumed to be one column: a caller who passes
|
|
16
|
+
* `"..."` has spent three, and a caller who passes an emoji has spent two.
|
|
17
|
+
*/
|
|
18
|
+
import { slice } from './slice.js';
|
|
19
|
+
import { width } from './width.js';
|
|
20
|
+
export function truncate(string, columns, options = {}) {
|
|
21
|
+
const { position = 'end', ellipsis = '\u2026' } = options;
|
|
22
|
+
if (columns <= 0)
|
|
23
|
+
return '';
|
|
24
|
+
const total = width(string);
|
|
25
|
+
if (total <= columns)
|
|
26
|
+
return string;
|
|
27
|
+
const mark = width(ellipsis);
|
|
28
|
+
// No room for both. The ellipsis alone is the most informative thing that fits, and when
|
|
29
|
+
// even that does not fit the honest answer is nothing rather than a cut-up ellipsis.
|
|
30
|
+
if (mark >= columns)
|
|
31
|
+
return mark === columns ? ellipsis : '';
|
|
32
|
+
const keep = columns - mark;
|
|
33
|
+
if (position === 'start')
|
|
34
|
+
return ellipsis + slice(string, total - keep);
|
|
35
|
+
if (position === 'end')
|
|
36
|
+
return slice(string, 0, keep) + ellipsis;
|
|
37
|
+
// Middle: the left half rounds up, so an odd budget spends its extra column on the text
|
|
38
|
+
// the reader meets first.
|
|
39
|
+
const left = Math.ceil(keep / 2);
|
|
40
|
+
return slice(string, 0, left) + ellipsis + slice(string, total - (keep - left));
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=truncate.js.map
|
package/dist/widest.d.ts
ADDED
package/dist/widest.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* R7 — the widest of many lines, in one pass, allocating nothing per line.
|
|
8
|
+
*
|
|
9
|
+
* Every caller that draws a box or a table needs this, and every one of them writes
|
|
10
|
+
* `Math.max(...lines.map(width))` — which builds an array it throws away and blows the call
|
|
11
|
+
* stack on a large enough table, because a spread is an argument list and V8 stops somewhere
|
|
12
|
+
* around 125,000. Taking an `Iterable` also means a generator works, so a caller measuring a
|
|
13
|
+
* file does not have to hold it.
|
|
14
|
+
*/
|
|
15
|
+
import { width } from './width.js';
|
|
16
|
+
export function widest(lines) {
|
|
17
|
+
let max = 0;
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const measured = width(line);
|
|
20
|
+
if (measured > max)
|
|
21
|
+
max = measured;
|
|
22
|
+
}
|
|
23
|
+
return max;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=widest.js.map
|
package/dist/width.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Columns a string of *plain* text occupies — no escape scan. The wrapper below has
|
|
3
|
+
* already split its input into text runs and complete sequences, so rescanning would only
|
|
4
|
+
* give a malformed sequence a second chance to be mistaken for one.
|
|
5
|
+
*/
|
|
6
|
+
export declare function measure(text: string, ambiguousIsWide?: boolean): number;
|
|
7
|
+
/**
|
|
8
|
+
* What `width` accepts beyond the string. Graded against `string-width`'s own suite, so the
|
|
9
|
+
* names and the defaults are its names and its defaults, not ours.
|
|
10
|
+
*/
|
|
11
|
+
export interface WidthOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Treat East Asian Ambiguous characters as one column. **Default `true`** — the incumbent's
|
|
14
|
+
* default, and right for a Latin terminal: `±`, `×`, `÷`, the box-drawing set, Greek and
|
|
15
|
+
* Cyrillic all occupy one column there.
|
|
16
|
+
*
|
|
17
|
+
* `false` for a CJK context, where a terminal using a CJK font renders the same characters
|
|
18
|
+
* two columns wide. Nothing can detect which a terminal is doing, which is exactly why this
|
|
19
|
+
* is the caller's decision and not ours.
|
|
20
|
+
*/
|
|
21
|
+
ambiguousIsNarrow?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Count escape sequences as the characters they are made of instead of removing them.
|
|
24
|
+
*
|
|
25
|
+
* Off by default, which is what every caller measuring styled output wants. On, the escape
|
|
26
|
+
* byte itself is still non-printing — `\u001B[31m` measures 4, the `[31m` a terminal would
|
|
27
|
+
* have swallowed.
|
|
28
|
+
*/
|
|
29
|
+
countAnsiEscapeCodes?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* How many terminal columns `input` occupies once its escape sequences are removed.
|
|
33
|
+
*
|
|
34
|
+
* **Non-strings measure 0.** `string-width` has always answered `0` for a number, `null` or
|
|
35
|
+
* `undefined` rather than throwing, and callers rely on it — a width function is usually
|
|
36
|
+
* reached with whatever a template produced. Three cases of its suite grade exactly this, and
|
|
37
|
+
* the check is `typeof` rather than a truthiness test so that `0` and `false` are not quietly
|
|
38
|
+
* treated as strings that happen to be empty.
|
|
39
|
+
*/
|
|
40
|
+
export declare function width(input: string, options?: WidthOptions): number;
|
|
41
|
+
/**
|
|
42
|
+
* Lines a string occupies in a terminal `columns` wide — the measurement the frame loop
|
|
43
|
+
* actually asks for. An empty line still occupies one.
|
|
44
|
+
*/
|
|
45
|
+
export declare function lineCount(text: string, columns: number): number;
|
package/dist/width.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display width of a string in terminal columns (R7).
|
|
3
|
+
*
|
|
4
|
+
* A column count is the one measurement the output stack cannot avoid: a spinner has to
|
|
5
|
+
* know how many lines its frame occupied before it can erase them, and a box has to know
|
|
6
|
+
* where its right edge falls. `string-width` does this in four packages; here it is one
|
|
7
|
+
* function over `Intl.Segmenter` and a range table, because a package this size is not
|
|
8
|
+
* worth a dependency tree (U5).
|
|
9
|
+
*
|
|
10
|
+
* The rules, in the order a cluster meets them:
|
|
11
|
+
* 1. ANSI and other control sequences are not printed — `strip()`, which is a local scan
|
|
12
|
+
* rather than `util.stripVTControlCharacters`: that one leaves the colon form of an
|
|
13
|
+
* extended colour behind, and this function answered 15 for a three-column string
|
|
14
|
+
* because of it. See `strip.ts` for the measurement.
|
|
15
|
+
* 2. A grapheme cluster made only of ignorable, control, mark or surrogate code points
|
|
16
|
+
* occupies no column.
|
|
17
|
+
* 3. An RGI emoji sequence is two columns, however many code points it is made of.
|
|
18
|
+
* 4. Otherwise the East Asian Width of the cluster's first visible code point, plus the
|
|
19
|
+
* halfwidth-and-fullwidth forms trailing it in the same cluster (a dakuten).
|
|
20
|
+
*
|
|
21
|
+
* Ambiguous-width characters are counted narrow, which is what a terminal does unless it
|
|
22
|
+
* has been told it is rendering an East Asian locale. `string-width` makes that an option;
|
|
23
|
+
* nothing above this function has ever needed the other answer, so it is not one here.
|
|
24
|
+
*/
|
|
25
|
+
import { strip } from './strip.js';
|
|
26
|
+
/**
|
|
27
|
+
* East Asian Wide and Fullwidth, as sorted `[low, high]` pairs flattened into one array —
|
|
28
|
+
* Unicode 17's W and F categories, merged where they touch. Generated from the same
|
|
29
|
+
* `EastAsianWidth.txt` derivation everyone uses; a binary search over 122 ranges is the
|
|
30
|
+
* whole lookup.
|
|
31
|
+
*/
|
|
32
|
+
const WIDE = [
|
|
33
|
+
0x1100, 0x115F, 0x231A, 0x231B, 0x2329, 0x232A, 0x23E9, 0x23EC, 0x23F0, 0x23F0,
|
|
34
|
+
0x23F3, 0x23F3, 0x25FD, 0x25FE, 0x2614, 0x2615, 0x2630, 0x2637, 0x2648, 0x2653,
|
|
35
|
+
0x267F, 0x267F, 0x268A, 0x268F, 0x2693, 0x2693, 0x26A1, 0x26A1, 0x26AA, 0x26AB,
|
|
36
|
+
0x26BD, 0x26BE, 0x26C4, 0x26C5, 0x26CE, 0x26CE, 0x26D4, 0x26D4, 0x26EA, 0x26EA,
|
|
37
|
+
0x26F2, 0x26F3, 0x26F5, 0x26F5, 0x26FA, 0x26FA, 0x26FD, 0x26FD, 0x2705, 0x2705,
|
|
38
|
+
0x270A, 0x270B, 0x2728, 0x2728, 0x274C, 0x274C, 0x274E, 0x274E, 0x2753, 0x2755,
|
|
39
|
+
0x2757, 0x2757, 0x2795, 0x2797, 0x27B0, 0x27B0, 0x27BF, 0x27BF, 0x2B1B, 0x2B1C,
|
|
40
|
+
0x2B50, 0x2B50, 0x2B55, 0x2B55, 0x2E80, 0x2E99, 0x2E9B, 0x2EF3, 0x2F00, 0x2FD5,
|
|
41
|
+
0x2FF0, 0x303E, 0x3041, 0x3096, 0x3099, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E,
|
|
42
|
+
0x3190, 0x31E5, 0x31EF, 0x321E, 0x3220, 0x3247, 0x3250, 0xA48C, 0xA490, 0xA4C6,
|
|
43
|
+
0xA960, 0xA97C, 0xAC00, 0xD7A3, 0xF900, 0xFAFF, 0xFE10, 0xFE19, 0xFE30, 0xFE52,
|
|
44
|
+
0xFE54, 0xFE66, 0xFE68, 0xFE6B, 0xFF01, 0xFF60, 0xFFE0, 0xFFE6, 0x16FE0, 0x16FE4,
|
|
45
|
+
0x16FF0, 0x16FF1, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF, 0x18D08, 0x1AFF0, 0x1AFF3,
|
|
46
|
+
0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150, 0x1B152,
|
|
47
|
+
0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1D300, 0x1D356, 0x1D360, 0x1D376,
|
|
48
|
+
0x1F004, 0x1F004, 0x1F0CF, 0x1F0CF, 0x1F18E, 0x1F18E, 0x1F191, 0x1F19A, 0x1F200, 0x1F202,
|
|
49
|
+
0x1F210, 0x1F23B, 0x1F240, 0x1F248, 0x1F250, 0x1F251, 0x1F260, 0x1F265, 0x1F300, 0x1F320,
|
|
50
|
+
0x1F32D, 0x1F335, 0x1F337, 0x1F37C, 0x1F37E, 0x1F393, 0x1F3A0, 0x1F3CA, 0x1F3CF, 0x1F3D3,
|
|
51
|
+
0x1F3E0, 0x1F3F0, 0x1F3F4, 0x1F3F4, 0x1F3F8, 0x1F43E, 0x1F440, 0x1F440, 0x1F442, 0x1F4FC,
|
|
52
|
+
0x1F4FF, 0x1F53D, 0x1F54B, 0x1F54E, 0x1F550, 0x1F567, 0x1F57A, 0x1F57A, 0x1F595, 0x1F596,
|
|
53
|
+
0x1F5A4, 0x1F5A4, 0x1F5FB, 0x1F64F, 0x1F680, 0x1F6C5, 0x1F6CC, 0x1F6CC, 0x1F6D0, 0x1F6D2,
|
|
54
|
+
0x1F6D5, 0x1F6D7, 0x1F6DC, 0x1F6DF, 0x1F6EB, 0x1F6EC, 0x1F6F4, 0x1F6FC, 0x1F7E0, 0x1F7EB,
|
|
55
|
+
0x1F7F0, 0x1F7F0, 0x1F90C, 0x1F93A, 0x1F93C, 0x1F945, 0x1F947, 0x1F9FF, 0x1FA70, 0x1FA7C,
|
|
56
|
+
0x1FA80, 0x1FA89, 0x1FA8F, 0x1FAC6, 0x1FACE, 0x1FADC, 0x1FADF, 0x1FAE9, 0x1FAF0, 0x1FAF8,
|
|
57
|
+
0x20000, 0x2FFFD, 0x30000, 0x3FFFD,
|
|
58
|
+
];
|
|
59
|
+
const NARROW = 1;
|
|
60
|
+
const WIDE_COLUMNS = 2;
|
|
61
|
+
/** Half the flat array is lows, so a step over pairs. */
|
|
62
|
+
const PAIR = 2;
|
|
63
|
+
/**
|
|
64
|
+
* East Asian **Ambiguous** — the characters a terminal renders one column wide in a Latin
|
|
65
|
+
* context and two in a CJK one. `±`, `×`, `÷`, the box-drawing set, Greek and Cyrillic.
|
|
66
|
+
*
|
|
67
|
+
* Unlike WIDE above, this table is **generated**, by `scripts/generate-ambiguous.mjs`: 179
|
|
68
|
+
* ranges is past the size where transcribing a text file by hand is honest work. The sweep
|
|
69
|
+
* reads `get-east-asian-width`, already a devDependency because the suite grades against it,
|
|
70
|
+
* and the result is committed — no dependency at run time, and a Unicode update produces a
|
|
71
|
+
* reviewable diff rather than a silent drift. `--check` fails when the two disagree.
|
|
72
|
+
*/
|
|
73
|
+
const AMBIGUOUS = [
|
|
74
|
+
0x00A1, 0x00A1, 0x00A4, 0x00A4, 0x00A7, 0x00A8, 0x00AA, 0x00AA, 0x00AD, 0x00AE,
|
|
75
|
+
0x00B0, 0x00B4, 0x00B6, 0x00BA, 0x00BC, 0x00BF, 0x00C6, 0x00C6, 0x00D0, 0x00D0,
|
|
76
|
+
0x00D7, 0x00D8, 0x00DE, 0x00E1, 0x00E6, 0x00E6, 0x00E8, 0x00EA, 0x00EC, 0x00ED,
|
|
77
|
+
0x00F0, 0x00F0, 0x00F2, 0x00F3, 0x00F7, 0x00FA, 0x00FC, 0x00FC, 0x00FE, 0x00FE,
|
|
78
|
+
0x0101, 0x0101, 0x0111, 0x0111, 0x0113, 0x0113, 0x011B, 0x011B, 0x0126, 0x0127,
|
|
79
|
+
0x012B, 0x012B, 0x0131, 0x0133, 0x0138, 0x0138, 0x013F, 0x0142, 0x0144, 0x0144,
|
|
80
|
+
0x0148, 0x014B, 0x014D, 0x014D, 0x0152, 0x0153, 0x0166, 0x0167, 0x016B, 0x016B,
|
|
81
|
+
0x01CE, 0x01CE, 0x01D0, 0x01D0, 0x01D2, 0x01D2, 0x01D4, 0x01D4, 0x01D6, 0x01D6,
|
|
82
|
+
0x01D8, 0x01D8, 0x01DA, 0x01DA, 0x01DC, 0x01DC, 0x0251, 0x0251, 0x0261, 0x0261,
|
|
83
|
+
0x02C4, 0x02C4, 0x02C7, 0x02C7, 0x02C9, 0x02CB, 0x02CD, 0x02CD, 0x02D0, 0x02D0,
|
|
84
|
+
0x02D8, 0x02DB, 0x02DD, 0x02DD, 0x02DF, 0x02DF, 0x0300, 0x036F, 0x0391, 0x03A1,
|
|
85
|
+
0x03A3, 0x03A9, 0x03B1, 0x03C1, 0x03C3, 0x03C9, 0x0401, 0x0401, 0x0410, 0x044F,
|
|
86
|
+
0x0451, 0x0451, 0x2010, 0x2010, 0x2013, 0x2016, 0x2018, 0x2019, 0x201C, 0x201D,
|
|
87
|
+
0x2020, 0x2022, 0x2024, 0x2027, 0x2030, 0x2030, 0x2032, 0x2033, 0x2035, 0x2035,
|
|
88
|
+
0x203B, 0x203B, 0x203E, 0x203E, 0x2074, 0x2074, 0x207F, 0x207F, 0x2081, 0x2084,
|
|
89
|
+
0x20AC, 0x20AC, 0x2103, 0x2103, 0x2105, 0x2105, 0x2109, 0x2109, 0x2113, 0x2113,
|
|
90
|
+
0x2116, 0x2116, 0x2121, 0x2122, 0x2126, 0x2126, 0x212B, 0x212B, 0x2153, 0x2154,
|
|
91
|
+
0x215B, 0x215E, 0x2160, 0x216B, 0x2170, 0x2179, 0x2189, 0x2189, 0x2190, 0x2199,
|
|
92
|
+
0x21B8, 0x21B9, 0x21D2, 0x21D2, 0x21D4, 0x21D4, 0x21E7, 0x21E7, 0x2200, 0x2200,
|
|
93
|
+
0x2202, 0x2203, 0x2207, 0x2208, 0x220B, 0x220B, 0x220F, 0x220F, 0x2211, 0x2211,
|
|
94
|
+
0x2215, 0x2215, 0x221A, 0x221A, 0x221D, 0x2220, 0x2223, 0x2223, 0x2225, 0x2225,
|
|
95
|
+
0x2227, 0x222C, 0x222E, 0x222E, 0x2234, 0x2237, 0x223C, 0x223D, 0x2248, 0x2248,
|
|
96
|
+
0x224C, 0x224C, 0x2252, 0x2252, 0x2260, 0x2261, 0x2264, 0x2267, 0x226A, 0x226B,
|
|
97
|
+
0x226E, 0x226F, 0x2282, 0x2283, 0x2286, 0x2287, 0x2295, 0x2295, 0x2299, 0x2299,
|
|
98
|
+
0x22A5, 0x22A5, 0x22BF, 0x22BF, 0x2312, 0x2312, 0x2460, 0x24E9, 0x24EB, 0x254B,
|
|
99
|
+
0x2550, 0x2573, 0x2580, 0x258F, 0x2592, 0x2595, 0x25A0, 0x25A1, 0x25A3, 0x25A9,
|
|
100
|
+
0x25B2, 0x25B3, 0x25B6, 0x25B7, 0x25BC, 0x25BD, 0x25C0, 0x25C1, 0x25C6, 0x25C8,
|
|
101
|
+
0x25CB, 0x25CB, 0x25CE, 0x25D1, 0x25E2, 0x25E5, 0x25EF, 0x25EF, 0x2605, 0x2606,
|
|
102
|
+
0x2609, 0x2609, 0x260E, 0x260F, 0x261C, 0x261C, 0x261E, 0x261E, 0x2640, 0x2640,
|
|
103
|
+
0x2642, 0x2642, 0x2660, 0x2661, 0x2663, 0x2665, 0x2667, 0x266A, 0x266C, 0x266D,
|
|
104
|
+
0x266F, 0x266F, 0x269E, 0x269F, 0x26BF, 0x26BF, 0x26C6, 0x26CD, 0x26CF, 0x26D3,
|
|
105
|
+
0x26D5, 0x26E1, 0x26E3, 0x26E3, 0x26E8, 0x26E9, 0x26EB, 0x26F1, 0x26F4, 0x26F4,
|
|
106
|
+
0x26F6, 0x26F9, 0x26FB, 0x26FC, 0x26FE, 0x26FF, 0x273D, 0x273D, 0x2776, 0x277F,
|
|
107
|
+
0x2B56, 0x2B59, 0x3248, 0x324F, 0xE000, 0xF8FF, 0xFE00, 0xFE0F, 0xFFFD, 0xFFFD,
|
|
108
|
+
0x1F100, 0x1F10A, 0x1F110, 0x1F12D, 0x1F130, 0x1F169, 0x1F170, 0x1F18D, 0x1F18F, 0x1F190,
|
|
109
|
+
0x1F19B, 0x1F1AC, 0xE0100, 0xE01EF, 0xF0000, 0xFFFFD, 0x100000, 0x10FFFD,
|
|
110
|
+
];
|
|
111
|
+
/** Binary search over a flat `[low, high]` table. Both tables are laid out for this. */
|
|
112
|
+
function inTable(table, codePoint) {
|
|
113
|
+
let low = 0;
|
|
114
|
+
let high = table.length / PAIR - 1;
|
|
115
|
+
while (low <= high) {
|
|
116
|
+
const mid = (low + high) >> 1;
|
|
117
|
+
const start = table[mid * PAIR] ?? 0;
|
|
118
|
+
const end = table[mid * PAIR + 1] ?? 0;
|
|
119
|
+
if (codePoint < start)
|
|
120
|
+
high = mid - 1;
|
|
121
|
+
else if (codePoint > end)
|
|
122
|
+
low = mid + 1;
|
|
123
|
+
else
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
function isWide(codePoint) {
|
|
129
|
+
return inTable(WIDE, codePoint);
|
|
130
|
+
}
|
|
131
|
+
function isAmbiguous(codePoint) {
|
|
132
|
+
return inTable(AMBIGUOUS, codePoint);
|
|
133
|
+
}
|
|
134
|
+
// `v`-mode properties: the whole point of using them is that Node ships the tables.
|
|
135
|
+
const ZERO_WIDTH_CLUSTER = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
|
|
136
|
+
const LEADING_NON_PRINTING = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
|
|
137
|
+
const RGI_EMOJI = /^\p{RGI_Emoji}$/v;
|
|
138
|
+
/** The Halfwidth and Fullwidth Forms block, which a cluster can carry after its base. */
|
|
139
|
+
const FORMS_FIRST = 0xff00;
|
|
140
|
+
const FORMS_LAST = 0xffef;
|
|
141
|
+
const segmenter = new Intl.Segmenter();
|
|
142
|
+
/** Columns a cluster's trailing fullwidth forms add — `ガ` is a base plus a wide mark. */
|
|
143
|
+
function trailingForms(cluster) {
|
|
144
|
+
let extra = 0;
|
|
145
|
+
for (const character of [...cluster].slice(1)) {
|
|
146
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
147
|
+
if (codePoint >= FORMS_FIRST && codePoint <= FORMS_LAST)
|
|
148
|
+
extra += isWide(codePoint) ? WIDE_COLUMNS : NARROW;
|
|
149
|
+
}
|
|
150
|
+
return extra;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Columns a string of *plain* text occupies — no escape scan. The wrapper below has
|
|
154
|
+
* already split its input into text runs and complete sequences, so rescanning would only
|
|
155
|
+
* give a malformed sequence a second chance to be mistaken for one.
|
|
156
|
+
*/
|
|
157
|
+
export function measure(text, ambiguousIsWide = false) {
|
|
158
|
+
let columns = 0;
|
|
159
|
+
for (const { segment } of segmenter.segment(text)) {
|
|
160
|
+
if (ZERO_WIDTH_CLUSTER.test(segment))
|
|
161
|
+
continue;
|
|
162
|
+
if (RGI_EMOJI.test(segment)) {
|
|
163
|
+
columns += WIDE_COLUMNS;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const codePoint = segment.replace(LEADING_NON_PRINTING, '').codePointAt(0) ?? 0;
|
|
167
|
+
const wide = isWide(codePoint) || (ambiguousIsWide && isAmbiguous(codePoint));
|
|
168
|
+
columns += wide ? WIDE_COLUMNS : NARROW;
|
|
169
|
+
columns += trailingForms(segment);
|
|
170
|
+
}
|
|
171
|
+
return columns;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Printable ASCII is one column per code unit, and nothing that makes `measure` correct can
|
|
175
|
+
* change that answer: there are no escape sequences, no combining marks and no emoji between
|
|
176
|
+
* 0x20 and 0x7E. `countAnsiEscapeCodes` cannot change it either — `ESC` is 0x1B, below the
|
|
177
|
+
* range, so a string this accepts has no escapes to count.
|
|
178
|
+
*
|
|
179
|
+
* It is not a micro-optimisation. `widest` over many lines is one `Intl.Segmenter` walk per
|
|
180
|
+
* line, and `truncate.test.ts`'s 200,000-line case — the one proving `widest` survives where
|
|
181
|
+
* `Math.max(...)` throws — timed out at five seconds without this. ASCII is the common line.
|
|
182
|
+
*/
|
|
183
|
+
function asciiColumns(text) {
|
|
184
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
185
|
+
// `codePointAt` over `charCodeAt` (Interlace unicode-safety rule, and it is the right
|
|
186
|
+
// call): on a surrogate pair this returns the whole code point, which is above 0x7E and
|
|
187
|
+
// bails to the full path. `charCodeAt` would have seen a lone high surrogate instead.
|
|
188
|
+
// `?? 0` cannot mislead — 0 is below 0x20, so an out-of-range index also bails.
|
|
189
|
+
const code = text.codePointAt(i) ?? 0;
|
|
190
|
+
if (code < 0x20 || code > 0x7e)
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
return text.length;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* How many terminal columns `input` occupies once its escape sequences are removed.
|
|
197
|
+
*
|
|
198
|
+
* **Non-strings measure 0.** `string-width` has always answered `0` for a number, `null` or
|
|
199
|
+
* `undefined` rather than throwing, and callers rely on it — a width function is usually
|
|
200
|
+
* reached with whatever a template produced. Three cases of its suite grade exactly this, and
|
|
201
|
+
* the check is `typeof` rather than a truthiness test so that `0` and `false` are not quietly
|
|
202
|
+
* treated as strings that happen to be empty.
|
|
203
|
+
*/
|
|
204
|
+
export function width(input, options = {}) {
|
|
205
|
+
if (typeof input !== 'string' || input === '')
|
|
206
|
+
return 0;
|
|
207
|
+
const ascii = asciiColumns(input);
|
|
208
|
+
if (ascii !== undefined)
|
|
209
|
+
return ascii;
|
|
210
|
+
// `strip`, not `node:util`'s: Node's scanner stops at the first colon in the ITU T.416
|
|
211
|
+
// sub-parameter form (`ESC[38:2::255:0:0m`), which chalk and wrap-ansi both emit — it
|
|
212
|
+
// measured 15 where string-width says 3. The fast path above never reaches here with an
|
|
213
|
+
// escape in it, so the two fixes are disjoint: 0x1B is below its 0x20 floor.
|
|
214
|
+
return measure(options.countAnsiEscapeCodes === true ? input : strip(input), options.ambiguousIsNarrow === false);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Lines a string occupies in a terminal `columns` wide — the measurement the frame loop
|
|
218
|
+
* actually asks for. An empty line still occupies one.
|
|
219
|
+
*/
|
|
220
|
+
export function lineCount(text, columns) {
|
|
221
|
+
let count = 0;
|
|
222
|
+
for (const line of strip(text).split('\n'))
|
|
223
|
+
count += Math.max(1, Math.ceil(width(line) / columns));
|
|
224
|
+
return count;
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=width.js.map
|
package/dist/wrap.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The visible width of a string, escape sequences ignored. */
|
|
2
|
+
export declare function visibleWidth(string: string): number;
|
|
3
|
+
export interface WrapOptions {
|
|
4
|
+
/** Trim leading and trailing whitespace from each row. Default true. */
|
|
5
|
+
trim?: boolean;
|
|
6
|
+
/** Break a word longer than `columns` rather than let it overflow. Default false. */
|
|
7
|
+
hard?: boolean;
|
|
8
|
+
/** Break on any character rather than at word boundaries. Default true. */
|
|
9
|
+
wordWrap?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Wrap `string` to `columns`, keeping its ANSI intact and each row self-contained. */
|
|
12
|
+
export declare function wrap(string: string, columns: number, options?: WrapOptions): string;
|