@textui/core 0.2.0 → 0.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.
@@ -64,6 +64,21 @@ export interface TextUIApp extends Disposable {
64
64
  stop(): Promise<void>;
65
65
  /** Force a frame now, outside the scheduler. Tests and screenshots use it. */
66
66
  flush(): void;
67
+ /**
68
+ * Render until nothing is left to render, and answer whether it went quiet.
69
+ *
70
+ * The question `flush` cannot answer. A frame settles in more than one pass -
71
+ * an effect marks something dirty, a measurement runs the layout again - so a
72
+ * program that wants one true frame has to wait rather than force one.
73
+ *
74
+ * It returns as soon as a pass finds nothing pending, so an animating
75
+ * application settles between its frames. `false` means the passes kept
76
+ * producing work until the limit ran out - a render loop that does not
77
+ * converge, rather than one that is merely busy.
78
+ */
79
+ settled(options?: {
80
+ limit?: number;
81
+ }): Promise<boolean>;
67
82
  /** The last painted frame. */
68
83
  buffer(): CellBuffer;
69
84
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/types/app.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/types/app.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,12 @@
1
1
  import type { TextWrap } from '../types/style.js';
2
+ /**
3
+ * True for a string that is entirely printable ASCII.
4
+ *
5
+ * Worth asking, because the answer is yes for very nearly all of it: source
6
+ * code, file names, labels, key hints. Every character is then one grapheme
7
+ * one cell wide, and none of the Unicode machinery below has anything to do.
8
+ */
9
+ export declare function isAscii(text: string): boolean;
2
10
  /**
3
11
  * Split into grapheme clusters. Falls back to code points without Intl.
4
12
  *
@@ -58,7 +66,16 @@ export declare function wrapModeOf(wrap: TextWrap | undefined): WrapMode;
58
66
  /** Break into lines of at most `width` cells. Honours existing newlines. */
59
67
  export declare function wrapText(text: string, width: number, mode?: WrapMode): string[];
60
68
  export declare function stripAnsi(text: string): string;
61
- /** Remove control characters that would corrupt the frame. Keeps newlines. */
69
+ /**
70
+ * Remove control characters that would corrupt the frame. Keeps newlines.
71
+ *
72
+ * Two regular expressions over every string on screen, every frame, and for
73
+ * almost all of them the answer is the string it was given. `test` before
74
+ * `replace` is one scan that stops at the first match instead of two that
75
+ * always run to the end and allocate a copy each - and it returns the original
76
+ * string, so the callers that follow it get a cache hit on the width they
77
+ * already worked out rather than an equal-but-different string.
78
+ */
62
79
  export declare function sanitize(text: string): string;
63
80
  /** Repeat a grapheme to exactly `width` cells (wide chars land short). */
64
81
  export declare function repeatToWidth(char: string, width: number): string;
@@ -1 +1 @@
1
- {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/util/text.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AA8BlD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAShD;AA8ED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAsBrD;AAED,2CAA2C;AAC3C,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAYhE;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAgC/E;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,SAAI,GAAG,MAAM,CAuB7D;AAED,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEtD,+DAA+D;AAC/D,wBAAgB,QAAQ,CACtB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,SAAM,EACd,IAAI,GAAE,YAAoB,GACzB,MAAM,CAkCR;AAED,2EAA2E;AAC3E,wBAAgB,KAAK,CACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,GAAG,QAAQ,GAAG,OAAgB,EAC3C,IAAI,SAAM,GACT,MAAM,CAQR;AAED,kEAAkE;AAClE,wBAAgB,KAAK,CACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,GAAG,QAAQ,GAAG,OAAgB,EAC3C,QAAQ,SAAM,GACb,MAAM,CAER;AAED,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAOnF;AAED,iFAAiF;AACjF,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAE/D;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,QAAiB,GAAG,MAAM,EAAE,CAuDvF;AAOD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,8EAA8E;AAC9E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG7C;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIjE"}
1
+ {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/util/text.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAelD;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAM7C;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAShD;AA8ED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAsBrD;AAsBD,2CAA2C;AAC3C,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAgBhD;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAYhE;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAgC/E;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,SAAI,GAAG,MAAM,CAuB7D;AAED,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEtD,+DAA+D;AAC/D,wBAAgB,QAAQ,CACtB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,SAAM,EACd,IAAI,GAAE,YAAoB,GACzB,MAAM,CAkCR;AAED,2EAA2E;AAC3E,wBAAgB,KAAK,CACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,GAAG,QAAQ,GAAG,OAAgB,EAC3C,IAAI,SAAM,GACT,MAAM,CAQR;AAED,kEAAkE;AAClE,wBAAgB,KAAK,CACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,GAAG,QAAQ,GAAG,OAAgB,EAC3C,QAAQ,SAAM,GACb,MAAM,CAER;AAED,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAOnF;AAED,iFAAiF;AACjF,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAE/D;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,QAAiB,GAAG,MAAM,EAAE,CAuDvF;AAOD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAgBD;;;;;;;;;GASG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG7C;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIjE"}
package/dist/util/text.js CHANGED
@@ -15,7 +15,7 @@ const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
15
15
  * code, file names, labels, key hints. Every character is then one grapheme
16
16
  * one cell wide, and none of the Unicode machinery below has anything to do.
17
17
  */
18
- function isAscii(text) {
18
+ export function isAscii(text) {
19
19
  for (let i = 0; i < text.length; i++) {
20
20
  const c = text.charCodeAt(i);
21
21
  if (c < 0x20 || c > 0x7e)
@@ -148,6 +148,25 @@ export function graphemeWidth(cluster) {
148
148
  return 2;
149
149
  return 1;
150
150
  }
151
+ /**
152
+ * Widths already worked out, for strings that are not plain ASCII.
153
+ *
154
+ * The same strings are measured over and over. A frame measures the tree, lays
155
+ * it out and paints it - which is three walks over the same text - and then
156
+ * the next frame does all three again for content that has not changed. Nearly
157
+ * every row in a terminal application carries one glyph among its ASCII, a box
158
+ * rule or a bullet or an icon, and a single non-ASCII character sends the whole
159
+ * string down the slow path: an array of one-character strings, and a width
160
+ * classification for each of them. That was a quarter of the frame in a long
161
+ * transcript, spent proving the same sentences were the same width they were a
162
+ * thirtieth of a second ago.
163
+ *
164
+ * Only the slow path is remembered. ASCII is answered by `.length` before the
165
+ * map is even consulted, which is cheaper than the lookup would be, so the
166
+ * cache holds only what it saves anything on.
167
+ */
168
+ const widths = new Map();
169
+ const WIDTH_CACHE = 8192;
151
170
  /** Width of a string in terminal cells. */
152
171
  export function stringWidth(text) {
153
172
  if (text === '')
@@ -155,9 +174,18 @@ export function stringWidth(text) {
155
174
  // Fast path: pure ASCII printable.
156
175
  if (isAscii(text))
157
176
  return text.length;
177
+ const seen = widths.get(text);
178
+ if (seen !== undefined)
179
+ return seen;
158
180
  let w = 0;
159
181
  for (const g of graphemes(text))
160
182
  w += graphemeWidth(g);
183
+ // Emptied rather than evicted one at a time: a terminal's vocabulary of
184
+ // strings is small and slow-moving, so the cap is only ever reached by
185
+ // something streaming new text, and for that the whole map is stale.
186
+ if (widths.size >= WIDTH_CACHE)
187
+ widths.clear();
188
+ widths.set(text, w);
161
189
  return w;
162
190
  }
163
191
  /** Take at most `width` cells from the front. Never splits a wide grapheme. */
@@ -405,10 +433,33 @@ const ANSI_RE =
405
433
  export function stripAnsi(text) {
406
434
  return text.replace(ANSI_RE, '');
407
435
  }
408
- /** Remove control characters that would corrupt the frame. Keeps newlines. */
436
+ /*
437
+ * Test copies, without the `g` flag.
438
+ *
439
+ * `test` on a global regular expression advances its `lastIndex` and starts
440
+ * the next call from there, so the same string tested twice answers yes then
441
+ * no. Asking is a different question from replacing and gets its own pattern.
442
+ */
443
+ // eslint-disable-next-line no-control-regex
444
+ const ANSI_TEST = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\))/;
445
+ // eslint-disable-next-line no-control-regex
446
+ const CONTROL_TEST = /[\x00-\x08\x0B-\x1F\x7F]/;
447
+ // eslint-disable-next-line no-control-regex
448
+ const CONTROL_RE = /[\x00-\x08\x0B-\x1F\x7F]/g;
449
+ /**
450
+ * Remove control characters that would corrupt the frame. Keeps newlines.
451
+ *
452
+ * Two regular expressions over every string on screen, every frame, and for
453
+ * almost all of them the answer is the string it was given. `test` before
454
+ * `replace` is one scan that stops at the first match instead of two that
455
+ * always run to the end and allocate a copy each - and it returns the original
456
+ * string, so the callers that follow it get a cache hit on the width they
457
+ * already worked out rather than an equal-but-different string.
458
+ */
409
459
  export function sanitize(text) {
410
- // eslint-disable-next-line no-control-regex
411
- return stripAnsi(text).replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '');
460
+ if (ANSI_TEST.test(text))
461
+ return stripAnsi(text).replace(CONTROL_RE, '');
462
+ return CONTROL_TEST.test(text) ? text.replace(CONTROL_RE, '') : text;
412
463
  }
413
464
  /** Repeat a grapheme to exactly `width` cells (wide chars land short). */
414
465
  export function repeatToWidth(char, width) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textui/core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Graph-mounted terminal UI runtime - store, registries, renderer, host primitives",
5
5
  "keywords": [
6
6
  "terminal",
package/src/app/app.ts CHANGED
@@ -304,6 +304,22 @@ export class App implements TextUIApp {
304
304
  this.store.set('$/layout/shell', id);
305
305
  const shell = this.shells.get(id);
306
306
  if (shell?.theme && this.themes.get(shell.theme)) this.setTheme(shell.theme);
307
+
308
+ // `root` reaches the screen two ways, and this is the moment it changes
309
+ // which. With no shell registered at boot it was never opened into `main` -
310
+ // `rootNode` wraps it directly, which is the path an application built out
311
+ // of primitives takes. A shell arriving afterwards makes `rootNode` return
312
+ // the shell instead, and the application's entire content was simply gone:
313
+ // a framed, themed, empty screen.
314
+ //
315
+ // `setRoot` has always handled both paths, and says why - "setting one and
316
+ // not the other works in exactly half of the programs that can exist". This
317
+ // is the other half of the same sentence. Opening the same key twice
318
+ // replaces the mount rather than stacking one, so no guard is needed.
319
+ if (this.options.root) {
320
+ this.surfaces.open({ surface: 'main', key: ROOT_KEY, target: this.options.root });
321
+ }
322
+
307
323
  this.buffer_.invalidate();
308
324
  this.requestRender(true);
309
325
  }
@@ -466,6 +482,44 @@ export class App implements TextUIApp {
466
482
  this.renderFrame();
467
483
  }
468
484
 
485
+ /**
486
+ * Render until there is nothing left to render.
487
+ *
488
+ * `flush` forces one frame; this is the other question - *has it finished?* -
489
+ * and there was no way to ask it. What a program wanting one true frame did
490
+ * instead was guess: every example that writes a still ended with a sleep
491
+ * loop of four milliseconds times a number somebody tried until the picture
492
+ * looked right. Eight, mostly. Four in one, twelve in another. A number too
493
+ * small does not fail; it writes a half-drawn frame.
494
+ *
495
+ * A frame settles in more than one pass by design - an effect may mark
496
+ * something dirty, and a measurement changing runs the layout again - so the
497
+ * answer is a loop rather than a flag. Each turn yields to the task queue
498
+ * first, because what has not run yet cannot have marked anything.
499
+ *
500
+ * It returns as soon as a pass finds nothing pending, so an application that
501
+ * animates settles *between* its frames - which is what makes a still of one
502
+ * possible at all, and why the answer is not "has it stopped moving".
503
+ *
504
+ * `false` is the other thing: passes that kept producing work until the
505
+ * limit ran out, which is a render loop that does not converge - an effect
506
+ * with no dependency list setting the state it reads. Worth reporting rather
507
+ * than hanging on, and worth a number rather than a promise a caller can
508
+ * wait on for ever.
509
+ */
510
+ async settled(options: { limit?: number } = {}): Promise<boolean> {
511
+ const limit = options.limit ?? 100;
512
+ for (let i = 0; i < limit; i++) {
513
+ // Deliberately not `unref`'d: this timer is what keeps the process alive
514
+ // while a still is being rendered, and one that lets it exit would leave
515
+ // the await unresolved and the frame unwritten.
516
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
517
+ if (!this.frameScheduled && !this.isDirty()) return true;
518
+ this.flush();
519
+ }
520
+ return false;
521
+ }
522
+
469
523
  /**
470
524
  * The tree the frame renders: the shell, always, when one is registered.
471
525
  *
@@ -762,7 +816,21 @@ export class App implements TextUIApp {
762
816
  walkInstances(this.root, (instance) => {
763
817
  if (position) return;
764
818
  const cursor = instance.props.cursor;
765
- if (!cursor || !instance.box) return;
819
+ /*
820
+ * Absent, not falsy.
821
+ *
822
+ * `cursor` is a column, and column 0 is a real one - it is where the
823
+ * caret sits in an empty field, and where it sits at the start of any
824
+ * field. `!cursor` treated that as "no caret here", so a text input
825
+ * published no cursor position at all until its first character was
826
+ * typed: the terminal cursor stayed hidden, and the only thing marking
827
+ * the focused control was its border colour.
828
+ *
829
+ * `true` still means "here, at offset 0", which is what the `typeof`
830
+ * below is for - so this rejects the three ways of saying nothing and
831
+ * nothing else.
832
+ */
833
+ if (cursor === undefined || cursor === null || cursor === false || !instance.box) return;
766
834
  const id = typeof instance.props.id === 'string' ? instance.props.id : instance.id;
767
835
  if (id !== focused && `${instance.id}:focus` !== focused) return;
768
836
 
@@ -887,6 +955,33 @@ export class App implements TextUIApp {
887
955
  } catch (err) {
888
956
  this.handleError(err, `input:${event.type}`);
889
957
  }
958
+
959
+ /*
960
+ * Pointer motion and the wheel are streams, and only where they got to
961
+ * means anything.
962
+ *
963
+ * A terminal reports every cell the pointer crosses and every notch of the
964
+ * wheel, and one read can carry twenty of them - so rendering per event
965
+ * lays out and paints the whole tree twenty times to draw one gesture. The
966
+ * cost is the size of the tree, which is why it is invisible on a short
967
+ * screen and, on a long transcript, is the thing being dragged trailing
968
+ * seconds behind the pointer and a wheel that goes on scrolling after the
969
+ * hand has stopped. That second one is the tell: it is not momentum, it is
970
+ * a backlog of events still being drawn one frame each.
971
+ *
972
+ * Only the drawing is coalesced. Every event is still dispatched, so the
973
+ * notches still add up and the pointer still ends where it ended; what
974
+ * they share is the one frame `handleMouse` asked for, at the animation
975
+ * ceiling rather than at whatever rate the terminal felt like reporting.
976
+ *
977
+ * Keys are the opposite and stay synchronous: every one of them is a
978
+ * separate thing a person did, and a handler closes over the props from
979
+ * its last render, so typing "ab" without a frame in between makes the
980
+ * handler for "b" see the state from before "a".
981
+ */
982
+ if (event.type === 'mouse'
983
+ && (event.action === 'move' || event.action === 'drag' || event.action === 'wheel')) return;
984
+
890
985
  if (this.running_ && this.isDirty()) this.renderFrame();
891
986
  }
892
987
 
@@ -2,6 +2,16 @@ import type { Rect } from '../types/geometry.js';
2
2
  import type { Cell, CellBuffer } from '../types/cells.js';
3
3
  import { COLOR_DEFAULT, packColor, type PackedColor } from './color.js';
4
4
 
5
+ /**
6
+ * A character no cell can hold, for the previous frame when it must not match.
7
+ *
8
+ * `put` refuses the empty string and every real glyph is one the terminal can
9
+ * print, so NUL is a value that never arrives from a render and never leaves
10
+ * one: `diffFrame` reads `chars`, never `prevChars`, so this is only ever a
11
+ * comparison and is never written out.
12
+ */
13
+ const NEVER = '\u0000';
14
+
5
15
  /** One cell as it is stored: colours packed, nothing allocated to read it. */
6
16
  export interface PackedCell {
7
17
  char: string;
@@ -238,7 +248,7 @@ export class Buffer implements CellBuffer {
238
248
 
239
249
  // A resize invalidates the previous frame entirely.
240
250
  const n = width * height;
241
- this.prevChars = new Array<string>(n).fill(' ');
251
+ this.prevChars = new Array<string>(n).fill(NEVER);
242
252
  this.prevFg = new Int32Array(n).fill(COLOR_DEFAULT);
243
253
  this.prevBg = new Int32Array(n).fill(COLOR_DEFAULT);
244
254
  this.prevAttrs = new Uint16Array(n);
@@ -282,9 +292,25 @@ export class Buffer implements CellBuffer {
282
292
  this.committed = true;
283
293
  }
284
294
 
285
- /** Force the next diff to repaint everything. After a resize or a redraw. */
295
+ /**
296
+ * Force the next diff to repaint everything. After a resize or a redraw.
297
+ *
298
+ * Both halves, and the second one is the one that was missing. Clearing
299
+ * `committed` makes `dirtyRows` return every row, but `diffFrame` still
300
+ * skips each cell that matches the previous frame - and a blank cell in the
301
+ * new frame matched the reset previous frame exactly, so it was never
302
+ * written. Under a theme with an opaque canvas nothing showed, because every
303
+ * cell carries a background colour and therefore differs anyway. Under a
304
+ * theme with a transparent one the blanks are default-on-default, and the
305
+ * terminal kept whatever the old layout had left in them: a resize scattered
306
+ * the previous frame across the new one and it never cleared.
307
+ *
308
+ * So the previous frame is filled with a character no cell can hold, which
309
+ * is a comparison that cannot come out equal.
310
+ */
286
311
  invalidate(): void {
287
312
  this.committed = false;
313
+ this.prevChars.fill(NEVER);
288
314
  }
289
315
 
290
316
  toText(rect?: Rect): string {
@@ -40,6 +40,19 @@ export interface LayoutBox {
40
40
  /** Content larger than `content`, when overflow is 'scroll'. */
41
41
  scrollSize?: Size;
42
42
 
43
+ /**
44
+ * Frame and gaps, worked out once for this box.
45
+ *
46
+ * Neither depends on the space being offered - they are the box's own
47
+ * margin, border, padding and gap - and the box is measured about four
48
+ * times a frame at different bounds, each of which was rebuilding both and
49
+ * allocating three objects to do it. Same lifetime as `measured` below, and
50
+ * for the same reason: a layout box is rebuilt from the instance tree every
51
+ * frame, so nothing here can go stale.
52
+ */
53
+ frame?: { margin: Edges; inset: Edges };
54
+ gaps?: { main: number; cross: number };
55
+
43
56
  /**
44
57
  * The last answer `measureBox` gave for this box, and what it was asked.
45
58
  *
@@ -145,11 +158,15 @@ function mainOverflow(box: LayoutBox): Overflow {
145
158
  * that sets both reads the same either way round.
146
159
  */
147
160
  function gapsOf(box: LayoutBox): { main: number; cross: number } {
161
+ const seen = box.gaps;
162
+ if (seen !== undefined) return seen;
148
163
  const vertical = box.style.rowGap ?? box.style.gap ?? 0;
149
164
  const horizontal = box.style.columnGap ?? box.style.gap ?? 0;
150
- return isColumn(box)
165
+ const gaps = isColumn(box)
151
166
  ? { main: vertical, cross: horizontal }
152
167
  : { main: horizontal, cross: vertical };
168
+ box.gaps = gaps;
169
+ return gaps;
153
170
  }
154
171
 
155
172
  /**
@@ -181,10 +198,12 @@ function splitLines(mains: number[], gap: number, limit: number): [number, numbe
181
198
 
182
199
  /** Space this box consumes outside its content: margin, border, padding. */
183
200
  function frameOf(box: LayoutBox): { margin: Edges; inset: Edges } {
201
+ const seen = box.frame;
202
+ if (seen !== undefined) return seen;
184
203
  const margin = resolveEdges(box.style.margin);
185
204
  const padding = resolveEdges(box.style.padding);
186
205
  const b = box.borderEdges;
187
- return {
206
+ const frame = {
188
207
  margin,
189
208
  inset: {
190
209
  top: b.top + padding.top,
@@ -193,6 +212,8 @@ function frameOf(box: LayoutBox): { margin: Edges; inset: Edges } {
193
212
  left: b.left + padding.left,
194
213
  },
195
214
  };
215
+ box.frame = frame;
216
+ return frame;
196
217
  }
197
218
 
198
219
  /**
@@ -247,7 +268,7 @@ export function measureBox(box: LayoutBox, availW: number, availH: number): Size
247
268
  if (isAbsolute(child) || isHidden(child)) continue;
248
269
  count++;
249
270
  const m = measureBox(child, innerAvailW, innerAvailH);
250
- const cm = resolveEdges(child.style.margin);
271
+ const cm = frameOf(child).margin;
251
272
  mains.push(column ? m.height + edgeV(cm) : m.width + edgeH(cm));
252
273
  crosses.push(column ? m.width + edgeH(cm) : m.height + edgeV(cm));
253
274
  }
@@ -289,7 +310,7 @@ export function measureBox(box: LayoutBox, availW: number, availH: number): Size
289
310
  if (isAbsolute(child) || isHidden(child)) continue;
290
311
  const flex = Math.max(0, child.style.flex ?? 0);
291
312
  if (flex > 0) {
292
- const cm = resolveEdges(child.style.margin);
313
+ const cm = frameOf(child).margin;
293
314
  const share = Math.max(0, Math.floor((room * flex) / totalFlex) - edgeH(cm));
294
315
  const m = measureBox(child, share, innerAvailH);
295
316
  crosses[index] = m.height + edgeV(cm);
@@ -519,7 +540,7 @@ function layoutWrapped(box: LayoutBox, flow: LayoutBox[]): void {
519
540
  const mains: number[] = [];
520
541
  const crosses: number[] = [];
521
542
  for (const child of flow) {
522
- const margin = resolveEdges(child.style.margin);
543
+ const margin = frameOf(child).margin;
523
544
  const basis = child.style.basis ?? (column ? child.style.height : child.style.width);
524
545
  const fixed = resolveDimension(basis, mainAvail);
525
546
  const m = measureBox(
@@ -580,7 +601,7 @@ function layoutLine(
580
601
  const gapTotal = flow.length > 1 ? gap * (flow.length - 1) : 0;
581
602
 
582
603
  // 1. base sizes
583
- const margins = flow.map((c) => resolveEdges(c.style.margin));
604
+ const margins = flow.map((c) => frameOf(c).margin);
584
605
  const bases: number[] = [];
585
606
  for (let i = 0; i < flow.length; i++) {
586
607
  const child = flow[i] as LayoutBox;
@@ -94,13 +94,39 @@ export function resolveValue(ctx: ResolveContext, value: unknown): unknown {
94
94
  if (isComponentNode(value)) return value;
95
95
  if (isAction(value)) return value;
96
96
 
97
- if (Array.isArray(value)) return value.map((v) => resolveValue(ctx, v));
97
+ /*
98
+ * The same array back when there was nothing in it to resolve.
99
+ *
100
+ * Identity is what the reconciler compares - a component whose props are
101
+ * all unchanged is not re-run, and neither is anything under it - so
102
+ * copying unconditionally made that test impossible to pass for exactly
103
+ * the props worth passing it for. A list of four hundred rows arrived as a
104
+ * new array on every pass, its holder re-rendered every frame whatever it
105
+ * had been told, and the memoisation callers wrote to prevent that could
106
+ * not reach this far.
107
+ *
108
+ * Copying at all is for the bindings: a `{ path }` inside an array has to
109
+ * become the value it names, and that is a different array. So the copy is
110
+ * kept and returned only when something in it actually changed.
111
+ */
112
+ if (Array.isArray(value)) {
113
+ let changed = false;
114
+ const items = value.map((v) => {
115
+ const resolved = resolveValue(ctx, v);
116
+ if (resolved !== v) changed = true;
117
+ return resolved;
118
+ });
119
+ return changed ? items : value;
120
+ }
98
121
 
122
+ let changed = false;
99
123
  const out: Record<string, unknown> = {};
100
124
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
101
- out[k] = resolveValue(ctx, v);
125
+ const resolved = resolveValue(ctx, v);
126
+ if (resolved !== v) changed = true;
127
+ out[k] = resolved;
102
128
  }
103
- return out;
129
+ return changed ? out : value;
104
130
  }
105
131
 
106
132
  /**
@@ -1001,14 +1001,25 @@ export function useTicker(
1001
1001
  }, [enabled, options.fps]);
1002
1002
  }
1003
1003
 
1004
- /** A frame counter, for spinners and marquees. Frozen when animation is off. */
1005
- export function useFrame(fps = 10): number {
1004
+ /**
1005
+ * A frame counter, for spinners and marquees. Frozen when animation is off.
1006
+ *
1007
+ * `enabled` is not a convenience. A ticker is a standing invalidation - it
1008
+ * marks its component dirty `fps` times a second for as long as it is
1009
+ * mounted - so a component that animates only sometimes and calls this
1010
+ * unconditionally keeps the whole application rendering while it sits still.
1011
+ * That is invisible in a small tree and is the entire frame budget in a large
1012
+ * one, which is the case nobody tests. Frozen at 0 while off, so a caret or a
1013
+ * spinner drawn from it is steady rather than absent.
1014
+ */
1015
+ export function useFrame(fps = 10, options: { enabled?: boolean } = {}): number {
1006
1016
  const instance = currentInstance();
1007
1017
  const [frame, setFrame] = useState(0);
1008
1018
  const disabled = instance.runtime.animation.disabled;
1019
+ const running = (options.enabled ?? true) && !disabled;
1009
1020
 
1010
- useTicker(() => setFrame((f) => f + 1), { fps, enabled: !disabled });
1011
- return disabled ? 0 : frame;
1021
+ useTicker(() => setFrame((f) => f + 1), { fps, enabled: running });
1022
+ return running ? frame : 0;
1012
1023
  }
1013
1024
 
1014
1025
  /** A value that eases towards its target. Snaps when animation is off. */
@@ -1,5 +1,5 @@
1
1
  import type { Rect } from '../types/geometry.js';
2
- import type { Style } from '../types/style.js';
2
+ import type { Style, TextWrap } from '../types/style.js';
3
3
  import type { ResolvedTheme } from '../types/theme.js';
4
4
  import type { TerminalCapabilities } from '../types/capabilities.js';
5
5
  import type { Cell, Color } from '../types/cells.js';
@@ -12,7 +12,7 @@ import type { Buffer } from '../render/buffer.js';
12
12
  import { COLOR_DEFAULT, mix, packColor, type PackedColor } from '../render/color.js';
13
13
  import { rectIntersect } from '../types/geometry.js';
14
14
  import {
15
- graphemes, graphemeWidth, sanitize, stringWidth, truncate,
15
+ graphemes, graphemeWidth, isAscii, sanitize, stringWidth, truncate,
16
16
  truncateSideOf, wrapModeOf, wrapText,
17
17
  } from '../util/text.js';
18
18
  import {
@@ -87,8 +87,23 @@ class Surface implements PaintSurface {
87
87
  const link = style?.link;
88
88
  const oy = this.rect.y + y;
89
89
 
90
+ const clean = sanitize(text);
91
+
92
+ // Plain ASCII is one cell per character and needs none of the clustering
93
+ // machinery - and it is nearly every string a terminal ever draws. The
94
+ // general path allocates an array of one-character strings for the whole
95
+ // run before writing any of it.
90
96
  let cx = x;
91
- for (const g of graphemes(sanitize(text))) {
97
+ if (isAscii(clean)) {
98
+ for (let i = 0; i < clean.length; i++) {
99
+ const ax = this.rect.x + cx;
100
+ if (this.visible(ax, oy)) this.buffer.put(ax, oy, clean[i] as string, fg, bg, attrs, link);
101
+ cx += 1;
102
+ }
103
+ return cx - x;
104
+ }
105
+
106
+ for (const g of graphemes(clean)) {
92
107
  const w = graphemeWidth(g);
93
108
  if (w === 0) continue;
94
109
  const ax = this.rect.x + cx;
@@ -337,6 +352,17 @@ function oneLine(text: string): string {
337
352
  return text.includes('\n') ? text.split('\n').join(' ') : text;
338
353
  }
339
354
 
355
+ /**
356
+ * Text measurements already taken.
357
+ *
358
+ * A measurement is a pure function of the string, how it wraps and the width
359
+ * it is offered - and a layout asks for the same three several times in a
360
+ * frame, then the next frame asks for all of them again. The text in a
361
+ * transcript does not change; only the one line somebody is typing does.
362
+ */
363
+ const measurements = new Map<string, { width: number; height: number }>();
364
+ const MEASURE_CACHE = 4096;
365
+
340
366
  function textMeasure(instance: Instance, style: Style): (w: number, h: number) => { width: number; height: number } {
341
367
  return (maxWidth: number) => {
342
368
  const text = sanitize(textContent(instance));
@@ -344,26 +370,41 @@ function textMeasure(instance: Instance, style: Style): (w: number, h: number) =
344
370
 
345
371
  const wrap = style.wrap ?? 'none';
346
372
 
373
+ const key = `${wrap}\u0000${String(maxWidth)}\u0000${text}`;
374
+ const seen = measurements.get(key);
375
+ if (seen !== undefined) return seen;
376
+
347
377
  // A truncating text is one row, always. It still asks for the width it
348
378
  // would like - a box sizing itself around it gets the whole string when
349
379
  // there is room, and only cuts when there is not.
350
- if (truncateSideOf(wrap) !== undefined) {
351
- return { width: stringWidth(oneLine(text)), height: 1 };
352
- }
380
+ const size = measure(text, wrap, maxWidth);
381
+ if (measurements.size >= MEASURE_CACHE) measurements.clear();
382
+ measurements.set(key, size);
383
+ return size;
384
+ };
385
+ }
353
386
 
354
- if (wrap === 'none') {
355
- const lines = text.split('\n');
356
- return {
357
- width: Math.max(...lines.map(stringWidth)),
358
- height: lines.length,
359
- };
360
- }
361
- const limit = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : stringWidth(text);
362
- const lines = wrapText(text, limit, wrapModeOf(wrap));
387
+ /** The measurement itself, with nothing remembered. */
388
+ function measure(text: string, wrap: TextWrap, maxWidth: number): { width: number; height: number } {
389
+ // A truncating text is one row, always. It still asks for the width it
390
+ // would like - a box sizing itself around it gets the whole string when
391
+ // there is room, and only cuts when there is not.
392
+ if (truncateSideOf(wrap) !== undefined) {
393
+ return { width: stringWidth(oneLine(text)), height: 1 };
394
+ }
395
+
396
+ if (wrap === 'none') {
397
+ const lines = text.split('\n');
363
398
  return {
364
- width: Math.min(limit, Math.max(0, ...lines.map(stringWidth))),
365
- height: Math.max(1, lines.length),
399
+ width: Math.max(...lines.map(stringWidth)),
400
+ height: lines.length,
366
401
  };
402
+ }
403
+ const limit = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : stringWidth(text);
404
+ const lines = wrapText(text, limit, wrapModeOf(wrap));
405
+ return {
406
+ width: Math.min(limit, Math.max(0, ...lines.map(stringWidth))),
407
+ height: Math.max(1, lines.length),
367
408
  };
368
409
  }
369
410