@textui/core 0.3.0 → 0.5.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/src/util/text.ts CHANGED
@@ -20,7 +20,7 @@ const segmenter =
20
20
  * code, file names, labels, key hints. Every character is then one grapheme
21
21
  * one cell wide, and none of the Unicode machinery below has anything to do.
22
22
  */
23
- function isAscii(text: string): boolean {
23
+ export function isAscii(text: string): boolean {
24
24
  for (let i = 0; i < text.length; i++) {
25
25
  const c = text.charCodeAt(i);
26
26
  if (c < 0x20 || c > 0x7e) return false;
@@ -152,14 +152,42 @@ export function graphemeWidth(cluster: string): number {
152
152
  return 1;
153
153
  }
154
154
 
155
+ /**
156
+ * Widths already worked out, for strings that are not plain ASCII.
157
+ *
158
+ * The same strings are measured over and over. A frame measures the tree, lays
159
+ * it out and paints it - which is three walks over the same text - and then
160
+ * the next frame does all three again for content that has not changed. Nearly
161
+ * every row in a terminal application carries one glyph among its ASCII, a box
162
+ * rule or a bullet or an icon, and a single non-ASCII character sends the whole
163
+ * string down the slow path: an array of one-character strings, and a width
164
+ * classification for each of them. That was a quarter of the frame in a long
165
+ * transcript, spent proving the same sentences were the same width they were a
166
+ * thirtieth of a second ago.
167
+ *
168
+ * Only the slow path is remembered. ASCII is answered by `.length` before the
169
+ * map is even consulted, which is cheaper than the lookup would be, so the
170
+ * cache holds only what it saves anything on.
171
+ */
172
+ const widths = new Map<string, number>();
173
+ const WIDTH_CACHE = 8192;
174
+
155
175
  /** Width of a string in terminal cells. */
156
176
  export function stringWidth(text: string): number {
157
177
  if (text === '') return 0;
158
178
  // Fast path: pure ASCII printable.
159
179
  if (isAscii(text)) return text.length;
160
180
 
181
+ const seen = widths.get(text);
182
+ if (seen !== undefined) return seen;
183
+
161
184
  let w = 0;
162
185
  for (const g of graphemes(text)) w += graphemeWidth(g);
186
+ // Emptied rather than evicted one at a time: a terminal's vocabulary of
187
+ // strings is small and slow-moving, so the cap is only ever reached by
188
+ // something streaming new text, and for that the whole map is stale.
189
+ if (widths.size >= WIDTH_CACHE) widths.clear();
190
+ widths.set(text, w);
163
191
  return w;
164
192
  }
165
193
 
@@ -414,10 +442,33 @@ export function stripAnsi(text: string): string {
414
442
  return text.replace(ANSI_RE, '');
415
443
  }
416
444
 
417
- /** Remove control characters that would corrupt the frame. Keeps newlines. */
445
+ /*
446
+ * Test copies, without the `g` flag.
447
+ *
448
+ * `test` on a global regular expression advances its `lastIndex` and starts
449
+ * the next call from there, so the same string tested twice answers yes then
450
+ * no. Asking is a different question from replacing and gets its own pattern.
451
+ */
452
+ // eslint-disable-next-line no-control-regex
453
+ const ANSI_TEST = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\))/;
454
+ // eslint-disable-next-line no-control-regex
455
+ const CONTROL_TEST = /[\x00-\x08\x0B-\x1F\x7F]/;
456
+ // eslint-disable-next-line no-control-regex
457
+ const CONTROL_RE = /[\x00-\x08\x0B-\x1F\x7F]/g;
458
+
459
+ /**
460
+ * Remove control characters that would corrupt the frame. Keeps newlines.
461
+ *
462
+ * Two regular expressions over every string on screen, every frame, and for
463
+ * almost all of them the answer is the string it was given. `test` before
464
+ * `replace` is one scan that stops at the first match instead of two that
465
+ * always run to the end and allocate a copy each - and it returns the original
466
+ * string, so the callers that follow it get a cache hit on the width they
467
+ * already worked out rather than an equal-but-different string.
468
+ */
418
469
  export function sanitize(text: string): string {
419
- // eslint-disable-next-line no-control-regex
420
- return stripAnsi(text).replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '');
470
+ if (ANSI_TEST.test(text)) return stripAnsi(text).replace(CONTROL_RE, '');
471
+ return CONTROL_TEST.test(text) ? text.replace(CONTROL_RE, '') : text;
421
472
  }
422
473
 
423
474
  /** Repeat a grapheme to exactly `width` cells (wide chars land short). */