@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.
@@ -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.3.0",
3
+ "version": "0.5.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
@@ -7,6 +7,7 @@ import type { ResourceAdapter } from '../types/adapter.js';
7
7
  import type { Size, Rect } from '../types/geometry.js';
8
8
  import type { ResolvedTheme } from '../types/theme.js';
9
9
  import type { TerminalCapabilities, CapabilityOverrides } from '../types/capabilities.js';
10
+ import type { TerminalSessionOptions } from '../types/terminal.js';
10
11
  import type { InputEvent, KeyEvent, MouseEvent } from '../types/input.js';
11
12
  import type { LayerEntry } from '../types/layer.js';
12
13
  import type { Instance } from '../runtime/instance.js';
@@ -93,6 +94,8 @@ export class App implements TextUIApp {
93
94
  private frameScheduled = false;
94
95
  private frameTimer: ReturnType<typeof setTimeout> | null = null;
95
96
  private running_ = false;
97
+ /** What `start` acquired, so `suspend` can put the same thing back. */
98
+ private session: TerminalSessionOptions | null = null;
96
99
  private disposed = false;
97
100
  private themeId: string;
98
101
  private shellId: string;
@@ -363,7 +366,10 @@ export class App implements TextUIApp {
363
366
  }
364
367
 
365
368
  const caps = this.terminal.capabilities();
366
- await this.terminal.acquire({
369
+ // Kept, because `suspend` has to put back exactly what was taken: a
370
+ // session re-acquired from defaults would come back without the mouse, or
371
+ // with an alt screen the caller had asked not to have.
372
+ this.session = {
367
373
  managed: true,
368
374
  altScreen: true,
369
375
  hideCursor: true,
@@ -373,7 +379,8 @@ export class App implements TextUIApp {
373
379
  focusEvents: caps.focusEvents,
374
380
  enhancedKeys: caps.kittyKeyboard,
375
381
  ...this.options.session,
376
- });
382
+ };
383
+ await this.terminal.acquire(this.session);
377
384
 
378
385
  // After `acquire`, never before: with no session there is nothing to put
379
386
  // the shape back, so an early call is dropped rather than leaked.
@@ -387,6 +394,36 @@ export class App implements TextUIApp {
387
394
  this.renderFrame();
388
395
  }
389
396
 
397
+ /**
398
+ * Hand the terminal to something else, and take it back.
399
+ *
400
+ * For a program that draws for itself - an editor, a pager, a shell. The
401
+ * session is released, so the alt screen is left, raw mode is off and the
402
+ * child inherits a terminal in the state it expects; then it is acquired
403
+ * again exactly as it was.
404
+ *
405
+ * The frame buffer is invalidated on the way back rather than merely
406
+ * redrawn. The renderer writes the difference between what it painted last
407
+ * and what it paints now, and after another program has been on the screen
408
+ * that difference is against something no longer there - so a plain redraw
409
+ * puts back only the cells this application happened to change.
410
+ *
411
+ * `run` rejecting still gives the terminal back.
412
+ */
413
+ async suspend<T>(run: () => Promise<T>): Promise<T> {
414
+ if (!this.running_ || !this.session) return run();
415
+ await this.terminal.release();
416
+ try {
417
+ return await run();
418
+ }
419
+ finally {
420
+ await this.terminal.acquire(this.session);
421
+ this.applyCursorShape();
422
+ this.buffer_.invalidate();
423
+ this.renderFrame();
424
+ }
425
+ }
426
+
390
427
  async stop(): Promise<void> {
391
428
  if (!this.running_) return;
392
429
  this.running_ = false;
@@ -816,7 +853,21 @@ export class App implements TextUIApp {
816
853
  walkInstances(this.root, (instance) => {
817
854
  if (position) return;
818
855
  const cursor = instance.props.cursor;
819
- if (!cursor || !instance.box) return;
856
+ /*
857
+ * Absent, not falsy.
858
+ *
859
+ * `cursor` is a column, and column 0 is a real one - it is where the
860
+ * caret sits in an empty field, and where it sits at the start of any
861
+ * field. `!cursor` treated that as "no caret here", so a text input
862
+ * published no cursor position at all until its first character was
863
+ * typed: the terminal cursor stayed hidden, and the only thing marking
864
+ * the focused control was its border colour.
865
+ *
866
+ * `true` still means "here, at offset 0", which is what the `typeof`
867
+ * below is for - so this rejects the three ways of saying nothing and
868
+ * nothing else.
869
+ */
870
+ if (cursor === undefined || cursor === null || cursor === false || !instance.box) return;
820
871
  const id = typeof instance.props.id === 'string' ? instance.props.id : instance.id;
821
872
  if (id !== focused && `${instance.id}:focus` !== focused) return;
822
873
 
@@ -941,6 +992,33 @@ export class App implements TextUIApp {
941
992
  } catch (err) {
942
993
  this.handleError(err, `input:${event.type}`);
943
994
  }
995
+
996
+ /*
997
+ * Pointer motion and the wheel are streams, and only where they got to
998
+ * means anything.
999
+ *
1000
+ * A terminal reports every cell the pointer crosses and every notch of the
1001
+ * wheel, and one read can carry twenty of them - so rendering per event
1002
+ * lays out and paints the whole tree twenty times to draw one gesture. The
1003
+ * cost is the size of the tree, which is why it is invisible on a short
1004
+ * screen and, on a long transcript, is the thing being dragged trailing
1005
+ * seconds behind the pointer and a wheel that goes on scrolling after the
1006
+ * hand has stopped. That second one is the tell: it is not momentum, it is
1007
+ * a backlog of events still being drawn one frame each.
1008
+ *
1009
+ * Only the drawing is coalesced. Every event is still dispatched, so the
1010
+ * notches still add up and the pointer still ends where it ended; what
1011
+ * they share is the one frame `handleMouse` asked for, at the animation
1012
+ * ceiling rather than at whatever rate the terminal felt like reporting.
1013
+ *
1014
+ * Keys are the opposite and stay synchronous: every one of them is a
1015
+ * separate thing a person did, and a handler closes over the props from
1016
+ * its last render, so typing "ab" without a frame in between makes the
1017
+ * handler for "b" see the state from before "a".
1018
+ */
1019
+ if (event.type === 'mouse'
1020
+ && (event.action === 'move' || event.action === 'drag' || event.action === 'wheel')) return;
1021
+
944
1022
  if (this.running_ && this.isDirty()) this.renderFrame();
945
1023
  }
946
1024
 
@@ -1,4 +1,4 @@
1
- import type { Style, StyleInput } from '../types/style.js';
1
+ import type { Style, StyleColor, StyleInput } from '../types/style.js';
2
2
  import type { SemanticRole } from '../types/component-registry.js';
3
3
  import type { KeyEvent, MouseEvent } from '../types/input.js';
4
4
  import type { PaintSurface, RenderContext } from '../types/render.js';
@@ -99,6 +99,19 @@ export interface TextProps extends BaseProps {
99
99
  /** Where to cut when the text does not fit. */
100
100
  truncate?: 'end' | 'start' | 'middle' | false;
101
101
  ellipsis?: string;
102
+ /**
103
+ * Text to pick out wherever it appears, case-insensitively.
104
+ *
105
+ * For search: the caller passes what it is looking for and the rows are
106
+ * coloured where they hold it. It is applied after wrapping and truncation,
107
+ * to the text as drawn - so nothing about how a paragraph is broken into
108
+ * lines has to change to mark a hit in it, and a match split across a wrap
109
+ * is simply not on either row to colour.
110
+ */
111
+ match?: string;
112
+ /** The match's colours. Accent on its own foreground by default. */
113
+ matchFg?: StyleColor;
114
+ matchBg?: StyleColor;
102
115
  }
103
116
 
104
117
  export interface CanvasProps extends BaseProps {
@@ -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;
@@ -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, StyleColor, 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
 
@@ -672,6 +713,23 @@ function paintText(
672
713
  link: typeof instance.props.link === 'string' ? instance.props.link : undefined,
673
714
  };
674
715
 
716
+ /*
717
+ * Text to pick out of this one, and how to draw it where it appears.
718
+ *
719
+ * Search highlighting is a paint-time concern and not a content one: the
720
+ * caller passes the same string it is searching for and the wrapped rows
721
+ * are coloured where they hold it, so nothing about how the text is broken
722
+ * into lines has to change to mark a hit in it.
723
+ */
724
+ const match = typeof instance.props.match === 'string'
725
+ ? instance.props.match.toLowerCase()
726
+ : '';
727
+ const matchStyle: CellStyle = {
728
+ ...style,
729
+ fg: colorOf(packStyleColor((instance.props.matchFg as StyleColor | undefined) ?? 'onAccent', env.theme)),
730
+ bg: colorOf(packStyleColor((instance.props.matchBg as StyleColor | undefined) ?? 'accent', env.theme)),
731
+ };
732
+
675
733
  const wrap = visual.style.wrap ?? 'none';
676
734
  const align = visual.style.textAlign ?? 'left';
677
735
  // The ellipsis is a glyph like any other: on an ascii terminal it is '...'.
@@ -703,8 +761,42 @@ function paintText(
703
761
  align === 'center' ? Math.max(0, Math.floor((area.width - w) / 2))
704
762
  : align === 'right' ? Math.max(0, area.width - w)
705
763
  : 0;
706
- surface.text(area.x + offset, area.y + i, line, style);
764
+ if (match === '') {
765
+ surface.text(area.x + offset, area.y + i, line, style);
766
+ continue;
767
+ }
768
+ for (const run of split(line, match)) {
769
+ surface.text(
770
+ area.x + offset + stringWidth(line.slice(0, run.at)),
771
+ area.y + i,
772
+ run.text,
773
+ run.hit ? matchStyle : style,
774
+ );
775
+ }
776
+ }
777
+ }
778
+
779
+ /**
780
+ * A line cut into the parts that match a needle and the parts that do not.
781
+ *
782
+ * Case-insensitive, and over the line as it will be drawn - so a match broken
783
+ * across a wrap is two lines with no match in either, which is the honest
784
+ * answer: there is nothing on one row to put a colour on.
785
+ */
786
+ function split(line: string, needle: string): { at: number; text: string; hit: boolean }[] {
787
+ const runs: { at: number; text: string; hit: boolean }[] = [];
788
+ const haystack = line.toLowerCase();
789
+ let from = 0;
790
+ for (;;) {
791
+ const at = haystack.indexOf(needle, from);
792
+ if (at === -1) break;
793
+ if (at > from) runs.push({ at: from, text: line.slice(from, at), hit: false });
794
+ runs.push({ at, text: line.slice(at, at + needle.length), hit: true });
795
+ from = at + needle.length;
707
796
  }
797
+ if (runs.length === 0) return [{ at: 0, text: line, hit: false }];
798
+ if (from < line.length) runs.push({ at: from, text: line.slice(from), hit: false });
799
+ return runs;
708
800
  }
709
801
 
710
802
  /**
package/src/types/app.ts CHANGED
@@ -67,6 +67,17 @@ export interface TextUIApp extends Disposable {
67
67
  start(): Promise<void>;
68
68
  /** Release exactly what was acquired, then stop. */
69
69
  stop(): Promise<void>;
70
+ /**
71
+ * Hand the terminal to something that draws for itself, and take it back.
72
+ *
73
+ * For an editor, a pager, a shell. The session is released so the child
74
+ * inherits a terminal in the state it expects - no alt screen, no raw mode -
75
+ * and is acquired again afterwards exactly as it was, with the frame
76
+ * repainted whole rather than diffed against what another program left.
77
+ *
78
+ * `run` rejecting still gives the terminal back.
79
+ */
80
+ suspend<T>(run: () => Promise<T>): Promise<T>;
70
81
  /** Force a frame now, outside the scheduler. Tests and screenshots use it. */
71
82
  flush(): void;
72
83
  /**