@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/dist/app/app.d.ts +19 -0
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +79 -3
- package/dist/jsx/intrinsics.d.ts +14 -1
- package/dist/jsx/intrinsics.d.ts.map +1 -1
- package/dist/render/buffer.d.ts +16 -1
- package/dist/render/buffer.d.ts.map +1 -1
- package/dist/render/buffer.js +27 -2
- package/dist/render/layout.d.ts +18 -0
- package/dist/render/layout.d.ts.map +1 -1
- package/dist/render/layout.js +16 -6
- package/dist/runtime/paint.d.ts.map +1 -1
- package/dist/runtime/paint.js +104 -17
- package/dist/types/app.d.ts +11 -0
- package/dist/types/app.d.ts.map +1 -1
- package/dist/util/text.d.ts +18 -1
- package/dist/util/text.d.ts.map +1 -1
- package/dist/util/text.js +55 -4
- package/package.json +1 -1
- package/src/app/app.ts +81 -3
- package/src/jsx/intrinsics.ts +14 -1
- package/src/render/buffer.ts +28 -2
- package/src/render/layout.ts +27 -6
- package/src/runtime/paint.ts +110 -18
- package/src/types/app.ts +11 -0
- package/src/util/text.ts +55 -4
package/dist/util/text.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/util/text.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/jsx/intrinsics.ts
CHANGED
|
@@ -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 {
|
package/src/render/buffer.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 {
|
package/src/render/layout.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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) =>
|
|
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;
|
package/src/runtime/paint.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
|
|
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.
|
|
365
|
-
height:
|
|
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
|
-
|
|
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
|
/**
|