@oh-my-pi/pi-tui 17.3.8 → 17.4.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/utils.ts CHANGED
@@ -225,9 +225,7 @@ export function getSegmenter(): Intl.Segmenter {
225
225
  // added back so width matches the native truncate/slice/wrap helpers.
226
226
  const OSC66_SPAN_REGEX = /\x1b\]66;([^;]*);([\s\S]*?)(?:\x07|\x1b\\)/g;
227
227
  const OSC66_PREFIX = "\x1b]66;";
228
- const ESC = "\x1b";
229
- const TAB = "\t";
230
- const LONG_WIDTH_FAST_PATH_MIN = 128;
228
+ const PRINTABLE_ASCII_REGEX = /^[\u0020-\u007e]*$/;
231
229
 
232
230
  // Pin Bun.stringWidth semantics to the native width engine and guard against Bun
233
231
  // default drift: strip ANSI/OSC (don't count escape bytes) and treat
@@ -243,8 +241,6 @@ const STRING_WIDTH_OPTS = { countAnsiEscapeCodes: false, ambiguousIsNarrow: true
243
241
  // `setHangulCompatibilityJamoWidth`; mirror the same correction here so the TS
244
242
  // width stays in parity with the native truncate/slice/wrap model — and so the
245
243
  // hardware cursor column lands on the actual glyph during Korean IME input.
246
- const HANGUL_COMPAT_JAMO_REGEX = /[\u3131-\u318e]/;
247
- const HANGUL_COMPAT_JAMO_GLOBAL_REGEX = /[\u3131-\u318e]/g;
248
244
  const HANGUL_FILLER_CODE_POINT = 0x3164;
249
245
  // `Bun.stringWidth` counts every code point in the Compatibility Jamo block as
250
246
  // 2 cells (even the U+3164 filler that `unicode-width` treats as zero-width).
@@ -274,19 +270,28 @@ function hangulCompatibilityJamoTargetWidth(): 1 | 2 | null {
274
270
  // crates/pi-natives/src/text.rs, including the rule that the zero-width filler
275
271
  // (U+3164) is never widened past the narrow correction (a wide terminal still
276
272
  // renders it at its Unicode width of 0).
277
- function correctHangulCompatibilityJamoWidth(width: number, str: string): number {
278
- if (!HANGUL_COMPAT_JAMO_REGEX.test(str)) return width;
273
+ function correctHangulCompatibilityJamoWidth(
274
+ width: number,
275
+ compatibilityJamoCount: number,
276
+ fillerCount: number,
277
+ ): number {
278
+ if (compatibilityJamoCount === 0) return width;
279
279
  const target = hangulCompatibilityJamoTargetWidth();
280
- let corrected = width;
281
- HANGUL_COMPAT_JAMO_GLOBAL_REGEX.lastIndex = 0;
282
- for (let m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str); m !== null; m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str)) {
283
- const unicodeWidth = m[0].codePointAt(0) === HANGUL_FILLER_CODE_POINT ? 0 : 2;
284
- const finalWidth = target === null || (unicodeWidth === 0 && target > 1) ? unicodeWidth : target;
285
- corrected += finalWidth - HANGUL_COMPAT_JAMO_BUN_WIDTH;
286
- }
287
- return corrected;
280
+ return target === 1 ? width - compatibilityJamoCount : width - fillerCount * HANGUL_COMPAT_JAMO_BUN_WIDTH;
288
281
  }
289
282
 
283
+ // Terminal redraws re-measure the same visible lines every frame, usually as
284
+ // the same string objects (JSC caches their hashes, so repeat lookups are
285
+ // O(1) — cheaper than even the ASCII fast scan). Strings longer than the
286
+ // length gate skip the cache entirely: hashing them costs as much as measuring
287
+ // them, and retaining them would pin large render buffers. Worst-case
288
+ // retention is MAX * MAX_LEN UTF-16 units (~2 MiB); cleared when the width
289
+ // configuration epoch changes.
290
+ const VISIBLE_WIDTH_CACHE_MAX = 2048;
291
+ const VISIBLE_WIDTH_CACHE_MAX_LEN = 512;
292
+ const visibleWidthCache = new Map<string, number>();
293
+ let visibleWidthCacheEpoch = widthConfigEpoch;
294
+
290
295
  /**
291
296
  * Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
292
297
  *
@@ -296,65 +301,51 @@ function correctHangulCompatibilityJamoWidth(width: number, str: string): number
296
301
  */
297
302
  export function visibleWidth(str: string): number {
298
303
  if (!str) return 0;
299
-
300
- // Long non-escape text is faster through Bun's native scanner than through
301
- // a JS printable-ASCII prepass. Escape-bearing strings stay on the scanner
302
- // below so CSI/OSC-heavy render output can still bail out at the first ESC.
303
- if (str.length >= LONG_WIDTH_FAST_PATH_MIN && !str.includes(ESC)) {
304
- let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
305
- let tabCount = 0;
306
- for (let tabIndex = str.indexOf(TAB); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
307
- tabCount++;
304
+ const cacheable = str.length <= VISIBLE_WIDTH_CACHE_MAX_LEN;
305
+ if (cacheable) {
306
+ if (visibleWidthCacheEpoch !== widthConfigEpoch) {
307
+ visibleWidthCache.clear();
308
+ visibleWidthCacheEpoch = widthConfigEpoch;
308
309
  }
309
- if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
310
- return correctHangulCompatibilityJamoWidth(width, str);
310
+ const cached = visibleWidthCache.get(str);
311
+ if (cached !== undefined) return cached;
311
312
  }
312
313
 
313
- let tabCount = 0;
314
- let i = 0;
315
- for (; i < str.length; i++) {
316
- const code = str.charCodeAt(i);
317
- if (code < 0x20 || code > 0x7e) {
318
- if (code === 0x09) {
319
- tabCount++;
320
- continue;
321
- }
322
- break;
314
+ // This regex compiles to a native ASCII scan, cheaper than Bun's width
315
+ // scanner for the overwhelmingly common source-code path.
316
+ if (PRINTABLE_ASCII_REGEX.test(str)) {
317
+ if (cacheable) {
318
+ if (visibleWidthCache.size >= VISIBLE_WIDTH_CACHE_MAX) visibleWidthCache.clear();
319
+ visibleWidthCache.set(str, str.length);
323
320
  }
324
- }
325
- if (i === str.length) {
326
- return tabCount === 0 ? str.length : str.length + tabCount * (DEFAULT_TAB_WIDTH - 1);
321
+ return str.length;
327
322
  }
328
323
 
329
- if (tabCount === 0) {
330
- let tabIndex = str.indexOf(TAB, i + 1);
331
- if (tabIndex !== -1) {
332
- tabCount = 1;
333
- for (tabIndex = str.indexOf(TAB, tabIndex + 1); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
334
- tabCount++;
335
- }
336
- }
337
- } else {
338
- for (let tabIndex = str.indexOf(TAB, i + 1); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
324
+ let tabCount = 0;
325
+ let compatibilityJamoCount = 0;
326
+ let fillerCount = 0;
327
+ let hasEsc = false;
328
+ for (let i = 0; i < str.length; i++) {
329
+ const code = str.charCodeAt(i);
330
+ if (code === 0x09) {
339
331
  tabCount++;
332
+ } else if (code === 0x1b) {
333
+ hasEsc = true;
334
+ } else if (code >= 0x3131 && code <= 0x318e) {
335
+ compatibilityJamoCount++;
336
+ if (code === HANGUL_FILLER_CODE_POINT) fillerCount++;
340
337
  }
341
338
  }
342
339
 
343
- // `Bun.stringWidth` is a JSC builtin (no per-call N-API number box, unlike
344
- // the native scanner that traps under Bun 1.3.x GC/N-API load). It strips
345
- // CSI/OSC to zero cells and shares the native engine's UAX#11 width tables.
346
340
  let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
347
341
  if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
348
342
 
349
- // OSC 66: add back each stripped span as `scale * (explicit w ?? payload
350
- // width)`. Matched rather than replaced to avoid reallocating the string.
351
- if (str.includes(OSC66_PREFIX, i)) {
343
+ if (hasEsc && str.includes(OSC66_PREFIX)) {
352
344
  OSC66_SPAN_REGEX.lastIndex = 0;
353
345
  for (let m = OSC66_SPAN_REGEX.exec(str); m !== null; m = OSC66_SPAN_REGEX.exec(str)) {
354
346
  let scale = 1;
355
347
  let explicit: number | undefined;
356
348
  for (const part of m[1].split(":")) {
357
- // metadata keys are single chars, e.g. `s=2`, `w=5`
358
349
  if (part.indexOf("=") !== 1) continue;
359
350
  const value = Number.parseInt(part.slice(2), 10);
360
351
  if (!Number.isFinite(value)) continue;
@@ -368,11 +359,15 @@ export function visibleWidth(str: string): number {
368
359
  }
369
360
  }
370
361
 
371
- return correctHangulCompatibilityJamoWidth(width, str);
362
+ width = correctHangulCompatibilityJamoWidth(width, compatibilityJamoCount, fillerCount);
363
+ if (cacheable) {
364
+ if (visibleWidthCache.size >= VISIBLE_WIDTH_CACHE_MAX) visibleWidthCache.clear();
365
+ visibleWidthCache.set(str, width);
366
+ }
367
+ return width;
372
368
  }
373
369
 
374
370
  /**
375
- * True when a row carries a Kitty OSC 66 text-sizing span (`\x1b]66;…`).
376
371
  * Scaled spans must bypass wrapping/padding and, when scaled up, reserve the
377
372
  * terminal rows their multicell glyphs flow into.
378
373
  */