@promptctl/rich-js 0.26.0 → 0.28.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/README.md +1 -0
- package/THIRD-PARTY-NOTICES +42 -0
- package/dist/core/animation.d.ts +52 -15
- package/dist/core/animation.js +82 -37
- package/dist/core/ansi.d.ts +15 -8
- package/dist/core/ansi.js +106 -28
- package/dist/core/bidi-data.d.ts +14 -0
- package/dist/core/bidi-data.js +385 -0
- package/dist/core/bidi.d.ts +89 -0
- package/dist/core/bidi.js +615 -0
- package/dist/core/box.d.ts +9 -1
- package/dist/core/box.js +11 -0
- package/dist/core/cell-widths.d.ts +3 -2
- package/dist/core/cell-widths.js +93 -93
- package/dist/core/cells.d.ts +20 -0
- package/dist/core/cells.js +93 -5
- package/dist/core/color.d.ts +26 -18
- package/dist/core/color.js +37 -34
- package/dist/core/console.d.ts +26 -1
- package/dist/core/console.js +26 -6
- package/dist/core/control-codes.d.ts +61 -0
- package/dist/core/control-codes.js +157 -0
- package/dist/core/easing.js +6 -1
- package/dist/core/env.d.ts +6 -0
- package/dist/core/env.js +9 -1
- package/dist/core/export-html.js +4 -3
- package/dist/core/export-lines.d.ts +2 -0
- package/dist/core/export-lines.js +7 -3
- package/dist/core/export-svg.js +6 -5
- package/dist/core/json.js +9 -2
- package/dist/core/markup.js +4 -3
- package/dist/core/oklch.d.ts +2 -0
- package/dist/core/oklch.js +3 -3
- package/dist/core/orient.d.ts +68 -0
- package/dist/core/orient.js +224 -0
- package/dist/core/osc8.d.ts +12 -5
- package/dist/core/osc8.js +14 -7
- package/dist/core/pretty.js +22 -18
- package/dist/core/protocol.d.ts +53 -22
- package/dist/core/protocol.js +14 -6
- package/dist/core/render.d.ts +8 -3
- package/dist/core/render.js +19 -5
- package/dist/core/seal.d.ts +26 -0
- package/dist/core/seal.js +75 -0
- package/dist/core/segment.d.ts +9 -1
- package/dist/core/segment.js +11 -1
- package/dist/core/style.d.ts +29 -1
- package/dist/core/style.js +41 -5
- package/dist/core/text-order.d.ts +51 -0
- package/dist/core/text-order.js +50 -0
- package/dist/core/text.d.ts +10 -0
- package/dist/core/text.js +133 -87
- package/dist/core/timeline.js +6 -6
- package/dist/core/view.d.ts +23 -9
- package/dist/core/view.js +37 -12
- package/dist/core/wrap.d.ts +24 -6
- package/dist/core/wrap.js +82 -16
- package/dist/host/app.d.ts +48 -7
- package/dist/host/app.js +116 -11
- package/dist/host/index.d.ts +3 -2
- package/dist/host/index.js +1 -0
- package/dist/host/input.d.ts +35 -2
- package/dist/host/input.js +88 -4
- package/dist/host/terminal-colors.d.ts +18 -0
- package/dist/host/terminal-colors.js +46 -0
- package/dist/host/terminal-host.d.ts +2 -1
- package/dist/index.d.ts +10 -8
- package/dist/index.js +2 -2
- package/dist/renderables/change.d.ts +1 -3
- package/dist/renderables/change.js +4 -6
- package/dist/renderables/effect.d.ts +61 -60
- package/dist/renderables/effect.js +91 -81
- package/dist/renderables/effects.d.ts +37 -8
- package/dist/renderables/effects.js +193 -55
- package/dist/renderables/frames.d.ts +1 -10
- package/dist/renderables/frames.js +1 -16
- package/dist/renderables/glyph-effect.d.ts +12 -13
- package/dist/renderables/glyph-effect.js +34 -32
- package/dist/renderables/glyph-effects.d.ts +27 -8
- package/dist/renderables/glyph-effects.js +75 -7
- package/dist/renderables/lifecycle.d.ts +40 -32
- package/dist/renderables/lifecycle.js +87 -48
- package/dist/renderables/live.d.ts +3 -3
- package/dist/renderables/live.js +4 -4
- package/dist/renderables/markdown.js +4 -2
- package/dist/renderables/panel.js +6 -4
- package/dist/renderables/progress.d.ts +11 -4
- package/dist/renderables/progress.js +29 -17
- package/dist/renderables/progressBar.d.ts +3 -3
- package/dist/renderables/progressBar.js +16 -13
- package/dist/renderables/syntax.js +7 -2
- package/dist/renderables/table.js +2 -1
- package/dist/template-bindings/animation-funcs.d.ts +49 -18
- package/dist/template-bindings/animation-funcs.js +115 -32
- package/dist/template-bindings/index.d.ts +7 -5
- package/dist/template-bindings/index.js +8 -5
- package/dist/template-bindings/palette-funcs.d.ts +1 -1
- package/dist/template-bindings/palette-funcs.js +1 -1
- package/dist/themes/buildPalette.d.ts +15 -39
- package/dist/themes/buildPalette.js +196 -67
- package/dist/themes/colorMath.d.ts +10 -4
- package/dist/themes/colorMath.js +33 -1
- package/dist/themes/data/index.js +1 -4
- package/dist/themes/data/textual-ansi.js +0 -143
- package/dist/themes/data/types.d.ts +3 -1
- package/dist/themes/palette.d.ts +22 -6
- package/dist/themes/palette.js +13 -21
- package/dist/themes/ramp.d.ts +1 -1
- package/dist/themes/ramp.js +1 -1
- package/dist/themes/registry.d.ts +7 -10
- package/dist/themes/registry.js +9 -30
- package/dist/themes/terminalThemes.d.ts +4 -10
- package/dist/themes/terminalThemes.js +31 -46
- package/dist/themes/transpose.d.ts +5 -10
- package/dist/themes/transpose.js +13 -24
- package/dist/widgets/button.d.ts +1 -1
- package/dist/widgets/button.js +6 -6
- package/dist/widgets/checkbox.js +4 -4
- package/dist/widgets/dropdown.d.ts +1 -1
- package/dist/widgets/dropdown.js +7 -5
- package/dist/widgets/event-router.js +2 -2
- package/dist/widgets/ink.d.ts +5 -4
- package/dist/widgets/ink.js +6 -9
- package/dist/widgets/text-input.d.ts +1 -1
- package/dist/widgets/text-input.js +41 -32
- package/dist/widgets/themed-widget.js +3 -3
- package/dist/widgets/toggle.d.ts +1 -1
- package/dist/widgets/toggle.js +5 -5
- package/dist/widgets/types.d.ts +2 -2
- package/dist/widgets/widget-app.d.ts +3 -3
- package/dist/widgets/widget-app.js +5 -5
- package/package.json +5 -4
package/dist/core/wrap.d.ts
CHANGED
|
@@ -18,16 +18,30 @@
|
|
|
18
18
|
* line still too wide once wrapping is done. That order is the whole reason a
|
|
19
19
|
* long sentence in a table cell grows the row while an unbreakable word in the
|
|
20
20
|
* same cell still ellipsizes.
|
|
21
|
+
*
|
|
22
|
+
* Rich breaks only at whitespace, and that is what happens to any word holding
|
|
23
|
+
* nothing below: `test/core/text-wrap.golden.txt` pins it. Past that, a word
|
|
24
|
+
* breaks where its text says it may (UAX #14): after ZWSP, at a soft hyphen, and
|
|
25
|
+
* between the words of a script written without spaces. U+2060 and U+FEFF forbid
|
|
26
|
+
* a break, which here needs nothing but not being one.
|
|
21
27
|
*/
|
|
22
|
-
import { type CellCol } from "./cells.js";
|
|
28
|
+
import { type CellCol, type CodePoint } from "./cells.js";
|
|
29
|
+
/** One place a line of text was cut. */
|
|
30
|
+
export interface LineBreak {
|
|
31
|
+
/** Where the line is cut, in cells. */
|
|
32
|
+
readonly cell: CellCol;
|
|
33
|
+
/** The soft hyphen this break draws as `-`. Absent at any other break, and where no hyphen fits. */
|
|
34
|
+
readonly hyphen: CodePoint | undefined;
|
|
35
|
+
}
|
|
23
36
|
/**
|
|
24
|
-
* The cells each
|
|
25
|
-
* width `divideLine` can wrap it to without cutting a word.
|
|
37
|
+
* The cells each piece of `text` needs, in order — the widest is the narrowest
|
|
38
|
+
* width `divideLine` can wrap it to without cutting a word. A hyphen is not in
|
|
39
|
+
* them: where it does not fit, the break goes without it.
|
|
26
40
|
*/
|
|
27
41
|
export declare function wordWidths(text: string): number[];
|
|
28
42
|
/**
|
|
29
|
-
* The
|
|
30
|
-
* `
|
|
43
|
+
* The breaks that wrap `text` into lines of at most `width` cells, for
|
|
44
|
+
* `Segment.divide` to apply to the styled line.
|
|
31
45
|
*
|
|
32
46
|
* `fold` decides what happens to a word that fits on no line at all: folded,
|
|
33
47
|
* it is chopped across as many lines as it needs; unfolded, it is left whole
|
|
@@ -36,6 +50,10 @@ export declare function wordWidths(text: string): number[];
|
|
|
36
50
|
* can truncate it, which is what makes an unbreakable word ellipsize while the
|
|
37
51
|
* sentence around it merely wraps.
|
|
38
52
|
*
|
|
53
|
+
* A piece that ends in a soft hyphen stays on a line only with room for the
|
|
54
|
+
* hyphen, so a line it closes holds it, and a word that fits whole needs none. A
|
|
55
|
+
* piece as wide as the line breaks with no hyphen rather than lose a letter to one.
|
|
56
|
+
*
|
|
39
57
|
* Requires `width >= 1`. A zero-cell canvas gets no cuts, because "no cut
|
|
40
58
|
* fits" and "no cell fits" are different facts and only one of them is a list
|
|
41
59
|
* of offsets — the caller answers the second one by cropping the single
|
|
@@ -43,4 +61,4 @@ export declare function wordWidths(text: string): number[];
|
|
|
43
61
|
*/
|
|
44
62
|
export declare function divideLine(text: string, width: CellCol, options: {
|
|
45
63
|
fold: boolean;
|
|
46
|
-
}):
|
|
64
|
+
}): LineBreak[];
|
package/dist/core/wrap.js
CHANGED
|
@@ -18,8 +18,28 @@
|
|
|
18
18
|
* line still too wide once wrapping is done. That order is the whole reason a
|
|
19
19
|
* long sentence in a table cell grows the row while an unbreakable word in the
|
|
20
20
|
* same cell still ellipsizes.
|
|
21
|
+
*
|
|
22
|
+
* Rich breaks only at whitespace, and that is what happens to any word holding
|
|
23
|
+
* nothing below: `test/core/text-wrap.golden.txt` pins it. Past that, a word
|
|
24
|
+
* breaks where its text says it may (UAX #14): after ZWSP, at a soft hyphen, and
|
|
25
|
+
* between the words of a script written without spaces. U+2060 and U+FEFF forbid
|
|
26
|
+
* a break, which here needs nothing but not being one.
|
|
21
27
|
*/
|
|
22
|
-
import { cellLen, chopCells
|
|
28
|
+
import { asCellCol, asCodePoint, cellLen, chopCells } from "./cells.js";
|
|
29
|
+
import { pattern } from "./pattern.js";
|
|
30
|
+
const SOFT_HYPHEN = "\u00ad";
|
|
31
|
+
const ZWSP = "\u200b";
|
|
32
|
+
// The scripts UAX #14 sends to a dictionary (line break class SA) and ICU has one
|
|
33
|
+
// for. Which words it finds varies by ICU, and a dictionary cannot be vendored as
|
|
34
|
+
// the width table is; a word it misses is folded like any other.
|
|
35
|
+
const UNSPACED = "[\\p{Script_Extensions=Thai}\\p{Script_Extensions=Lao}\\p{Script_Extensions=Khmer}\\p{Script_Extensions=Myanmar}]+";
|
|
36
|
+
const BREAKABLE = pattern(new RegExp(`[${SOFT_HYPHEN}${ZWSP}]|${UNSPACED}`, "u"));
|
|
37
|
+
const WORD_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
38
|
+
// A break needs a glyph on both sides of it: with none before, nothing breaks away
|
|
39
|
+
// or takes a hyphen, and with none after, the word was already over.
|
|
40
|
+
const UNDRAWN = "\\s\\p{Cf}\\p{Mn}";
|
|
41
|
+
const OPENING = pattern(new RegExp(`^[${UNDRAWN}]*`, "u"));
|
|
42
|
+
const THROUGH_LAST_GLYPH = pattern(new RegExp(`^[^]*[^${UNDRAWN}]`, "u"));
|
|
23
43
|
/**
|
|
24
44
|
* Each word of `text` in order, where a "word" carries its own trailing
|
|
25
45
|
* whitespace — the run of spaces that follows it belongs to it, because that
|
|
@@ -34,7 +54,9 @@ function* words(text) {
|
|
|
34
54
|
// Constructed per call rather than hoisted: a sticky regex carries its
|
|
35
55
|
// `lastIndex` as mutable state, and a shared one would corrupt any two
|
|
36
56
|
// interleaved walks. [LAW:no-shared-mutable-globals]
|
|
37
|
-
|
|
57
|
+
// U+FEFF is outside it, as it is outside Python's `\s` and Rich's words: it is a
|
|
58
|
+
// no-break space that JavaScript's `\s` counts as a space.
|
|
59
|
+
const re = /[^\S\ufeff]*[\S\ufeff]+[^\S\ufeff]*/y;
|
|
38
60
|
for (let match = re.exec(text); match !== null; match = re.exec(text)) {
|
|
39
61
|
yield match[0];
|
|
40
62
|
}
|
|
@@ -47,12 +69,46 @@ function* words(text) {
|
|
|
47
69
|
function widthOf(word) {
|
|
48
70
|
return cellLen(word.trimEnd());
|
|
49
71
|
}
|
|
72
|
+
/** The offsets inside `word`, ascending, that it may break at. */
|
|
73
|
+
function breaksIn(word) {
|
|
74
|
+
if (word.match(BREAKABLE) === null)
|
|
75
|
+
return [];
|
|
76
|
+
const found = new Set();
|
|
77
|
+
// One break to a run of marks: a second would open a piece with nothing in it.
|
|
78
|
+
for (const { 0: mark, index } of word.matchAll(new RegExp(`(?<![${SOFT_HYPHEN}${ZWSP}])[${SOFT_HYPHEN}${ZWSP}]`, "gu")))
|
|
79
|
+
found.add(index + mark.length);
|
|
80
|
+
for (const { 0: run, index } of word.matchAll(new RegExp(UNSPACED, "gu"))) {
|
|
81
|
+
// A run's start is where the segmenter's input began, not a boundary it found:
|
|
82
|
+
// UAX #14 has these scripts break as letters do against what comes before them.
|
|
83
|
+
for (const { index: at } of WORD_SEGMENTER.segment(run))
|
|
84
|
+
if (at > 0)
|
|
85
|
+
found.add(index + at);
|
|
86
|
+
}
|
|
87
|
+
const start = OPENING.exec(word)[0].length;
|
|
88
|
+
const end = THROUGH_LAST_GLYPH.exec(word)?.[0].length ?? 0;
|
|
89
|
+
return [...found].filter((at) => at > start && at < end).sort((a, b) => a - b);
|
|
90
|
+
}
|
|
91
|
+
/** `text` as what wraps: its words, tiled further wherever one may break. */
|
|
92
|
+
function* piecesOf(text) {
|
|
93
|
+
for (const word of words(text)) {
|
|
94
|
+
let start = 0;
|
|
95
|
+
for (const at of breaksIn(word)) {
|
|
96
|
+
const piece = word.slice(start, at);
|
|
97
|
+
const cells = widthOf(piece);
|
|
98
|
+
yield { text: piece, cells, hyphenates: cells > 0 && piece.endsWith(SOFT_HYPHEN) };
|
|
99
|
+
start = at;
|
|
100
|
+
}
|
|
101
|
+
const last = word.slice(start);
|
|
102
|
+
yield { text: last, cells: widthOf(last), hyphenates: false };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
50
105
|
/**
|
|
51
|
-
* The cells each
|
|
52
|
-
* width `divideLine` can wrap it to without cutting a word.
|
|
106
|
+
* The cells each piece of `text` needs, in order — the widest is the narrowest
|
|
107
|
+
* width `divideLine` can wrap it to without cutting a word. A hyphen is not in
|
|
108
|
+
* them: where it does not fit, the break goes without it.
|
|
53
109
|
*/
|
|
54
110
|
export function wordWidths(text) {
|
|
55
|
-
return Array.from(
|
|
111
|
+
return Array.from(piecesOf(text), (piece) => piece.cells);
|
|
56
112
|
}
|
|
57
113
|
/**
|
|
58
114
|
* A word chopped into pieces of at most `width` cells, as the reference chops
|
|
@@ -78,8 +134,8 @@ function foldWord(word, width) {
|
|
|
78
134
|
return cellLen(pieces[0]) > width ? ["", ...pieces] : pieces;
|
|
79
135
|
}
|
|
80
136
|
/**
|
|
81
|
-
* The
|
|
82
|
-
* `
|
|
137
|
+
* The breaks that wrap `text` into lines of at most `width` cells, for
|
|
138
|
+
* `Segment.divide` to apply to the styled line.
|
|
83
139
|
*
|
|
84
140
|
* `fold` decides what happens to a word that fits on no line at all: folded,
|
|
85
141
|
* it is chopped across as many lines as it needs; unfolded, it is left whole
|
|
@@ -88,6 +144,10 @@ function foldWord(word, width) {
|
|
|
88
144
|
* can truncate it, which is what makes an unbreakable word ellipsize while the
|
|
89
145
|
* sentence around it merely wraps.
|
|
90
146
|
*
|
|
147
|
+
* A piece that ends in a soft hyphen stays on a line only with room for the
|
|
148
|
+
* hyphen, so a line it closes holds it, and a word that fits whole needs none. A
|
|
149
|
+
* piece as wide as the line breaks with no hyphen rather than lose a letter to one.
|
|
150
|
+
*
|
|
91
151
|
* Requires `width >= 1`. A zero-cell canvas gets no cuts, because "no cut
|
|
92
152
|
* fits" and "no cell fits" are different facts and only one of them is a list
|
|
93
153
|
* of offsets — the caller answers the second one by cropping the single
|
|
@@ -96,19 +156,22 @@ function foldWord(word, width) {
|
|
|
96
156
|
export function divideLine(text, width, options) {
|
|
97
157
|
if (width <= 0)
|
|
98
158
|
return [];
|
|
99
|
-
const
|
|
159
|
+
const breaks = [];
|
|
100
160
|
// A cut at the start of the text is not a cut: it would open the line with
|
|
101
161
|
// an empty piece. [LAW:single-enforcer] for that rule, so neither arm below
|
|
102
162
|
// has to restate it.
|
|
103
163
|
const cutAt = (cell) => {
|
|
104
164
|
if (cell > 0)
|
|
105
|
-
|
|
165
|
+
breaks.push({ cell: asCellCol(cell), hyphen: width - used > 0 ? softHyphen : undefined });
|
|
106
166
|
};
|
|
107
167
|
let used = 0; // cells already committed to the line being filled
|
|
108
168
|
let wordStart = 0; // absolute cell offset where the current word begins
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
169
|
+
let offset = 0; // code units before the current word
|
|
170
|
+
let softHyphen; // the one the line being filled ends in
|
|
171
|
+
for (const piece of piecesOf(text)) {
|
|
172
|
+
const word = piece.text;
|
|
173
|
+
const wordWidth = piece.cells;
|
|
174
|
+
if (width - used >= wordWidth + (piece.hyphenates ? 1 : 0)) {
|
|
112
175
|
used += cellLen(word);
|
|
113
176
|
}
|
|
114
177
|
else if (options.fold && wordWidth > width) {
|
|
@@ -119,13 +182,14 @@ export function divideLine(text, width, options) {
|
|
|
119
182
|
let pieceStart = wordStart;
|
|
120
183
|
for (let index = 0; index < pieces.length; index += 1) {
|
|
121
184
|
cutAt(pieceStart);
|
|
122
|
-
|
|
185
|
+
softHyphen = undefined;
|
|
186
|
+
const fold = pieces[index];
|
|
123
187
|
// The last piece is what the *next* word finds already on the line;
|
|
124
188
|
// the rest are whole lines of their own.
|
|
125
189
|
if (index === pieces.length - 1)
|
|
126
|
-
used = cellLen(
|
|
190
|
+
used = cellLen(fold);
|
|
127
191
|
else
|
|
128
|
-
pieceStart += cellLen(
|
|
192
|
+
pieceStart += cellLen(fold);
|
|
129
193
|
}
|
|
130
194
|
}
|
|
131
195
|
else {
|
|
@@ -135,6 +199,8 @@ export function divideLine(text, width, options) {
|
|
|
135
199
|
used = cellLen(word);
|
|
136
200
|
}
|
|
137
201
|
wordStart += cellLen(word);
|
|
202
|
+
offset += word.length;
|
|
203
|
+
softHyphen = piece.hyphenates ? asCodePoint(offset - 1) : undefined;
|
|
138
204
|
}
|
|
139
|
-
return
|
|
205
|
+
return breaks;
|
|
140
206
|
}
|
package/dist/host/app.d.ts
CHANGED
|
@@ -31,14 +31,15 @@
|
|
|
31
31
|
* — a focused field in a `WidgetApp` included — can swallow them.
|
|
32
32
|
*/
|
|
33
33
|
import { type Clock, type FrameRate } from "../core/clock.js";
|
|
34
|
+
import { type TerminalColors } from "../core/color.js";
|
|
34
35
|
import { type ConsoleOptions } from "../core/console.js";
|
|
35
36
|
import { Segment } from "../core/segment.js";
|
|
36
37
|
import { type Surface } from "../core/paint.js";
|
|
37
|
-
import { type FrameHost } from "../core/protocol.js";
|
|
38
|
+
import { type HostFrame, type FrameHost } from "../core/protocol.js";
|
|
38
39
|
import type { Unsubscribe } from "../core/subscription.js";
|
|
39
40
|
import { Timeline } from "../core/timeline.js";
|
|
40
41
|
import { type View } from "../core/view.js";
|
|
41
|
-
import { type KeyPress, type ScreenMouseEvent } from "./input.js";
|
|
42
|
+
import { type KeyPress, type ScreenMouseEvent, type TerminalReply } from "./input.js";
|
|
42
43
|
import type { TerminalHost } from "./terminal-host.js";
|
|
43
44
|
/**
|
|
44
45
|
* Where an app is in its life. `idle` has not run; `running` holds the
|
|
@@ -46,7 +47,7 @@ import type { TerminalHost } from "./terminal-host.js";
|
|
|
46
47
|
* the program; `stopped` has handed it back for good.
|
|
47
48
|
*/
|
|
48
49
|
export type AppPhase = "idle" | "running" | "suspended" | "stopped";
|
|
49
|
-
declare const DRAW_OPTIONS: readonly ["asciiOnly", "reducedMotion", "theme", "onStyleError", "markup", "highlight", "highlighter"];
|
|
50
|
+
declare const DRAW_OPTIONS: readonly ["asciiOnly", "reducedMotion", "textOrder", "terminal", "theme", "onStyleError", "markup", "highlight", "highlighter"];
|
|
50
51
|
type DrawOption = (typeof DRAW_OPTIONS)[number];
|
|
51
52
|
type DrawOptions = Readonly<Pick<ConsoleOptions, DrawOption>>;
|
|
52
53
|
export interface AppOptions extends DrawOptions {
|
|
@@ -102,6 +103,21 @@ type WheelTurn = Extract<ScreenMouseEvent, {
|
|
|
102
103
|
}>;
|
|
103
104
|
/** A pointer event `onMouse` hears: every one but the vertical wheel, which is `onWheel`'s. */
|
|
104
105
|
export type AppMouseEvent = Exclude<ScreenMouseEvent, WheelTurn>;
|
|
106
|
+
/**
|
|
107
|
+
* How an ask ended, with the replies heard before it did. `answered`: the
|
|
108
|
+
* terminal answered DA1, so a query with no reply among `replies` is one it
|
|
109
|
+
* does not support. `timeout`: it answered nothing in time, DA1 included.
|
|
110
|
+
* `left`: the app handed the terminal back first. `unasked`: the host writes
|
|
111
|
+
* to no terminal, so nothing was asked.
|
|
112
|
+
*/
|
|
113
|
+
export interface Asked {
|
|
114
|
+
readonly ended: "answered" | "timeout" | "left" | "unasked";
|
|
115
|
+
readonly replies: readonly TerminalReply[];
|
|
116
|
+
}
|
|
117
|
+
export interface AskOptions {
|
|
118
|
+
/** Seconds to wait for a terminal that answers nothing. Half a second, unless given. */
|
|
119
|
+
readonly timeout?: number;
|
|
120
|
+
}
|
|
105
121
|
export declare class App implements FrameHost {
|
|
106
122
|
private readonly host;
|
|
107
123
|
private readonly surface;
|
|
@@ -113,7 +129,11 @@ export declare class App implements FrameHost {
|
|
|
113
129
|
private ticking;
|
|
114
130
|
private said;
|
|
115
131
|
private waking;
|
|
116
|
-
private
|
|
132
|
+
private console;
|
|
133
|
+
private drawing;
|
|
134
|
+
private asking;
|
|
135
|
+
private asked;
|
|
136
|
+
private owedDa1;
|
|
117
137
|
private _phase;
|
|
118
138
|
private _frame;
|
|
119
139
|
private readonly painter;
|
|
@@ -127,6 +147,26 @@ export declare class App implements FrameHost {
|
|
|
127
147
|
private readonly input;
|
|
128
148
|
private settle;
|
|
129
149
|
constructor(options: AppOptions);
|
|
150
|
+
/**
|
|
151
|
+
* The terminal the app draws on: the colours a cell that sets none is drawn
|
|
152
|
+
* in, and the sixteen an ANSI colour names — as given, or as the colour
|
|
153
|
+
* depth assumes (`resolveTerminal`).
|
|
154
|
+
*/
|
|
155
|
+
get terminal(): TerminalColors;
|
|
156
|
+
/** Draw on `terminal` from the next frame on — what `terminalColors` learned, say. */
|
|
157
|
+
set terminal(terminal: TerminalColors);
|
|
158
|
+
/**
|
|
159
|
+
* Ask the terminal: write `queries`, then DA1, and resolve with the replies
|
|
160
|
+
* heard before DA1's, or before the timeout or the app handing the terminal
|
|
161
|
+
* back. It rejects only when writing to the terminal throws. Asks run one
|
|
162
|
+
* after another, and only while the app runs: it holds raw mode and reads
|
|
163
|
+
* the input, so a reply reaches the ask and never a key handler. A reply no
|
|
164
|
+
* ask is waiting for is dropped. `queries` must not ask for DA1 itself: its
|
|
165
|
+
* reply would end the ask early.
|
|
166
|
+
*/
|
|
167
|
+
ask(queries: readonly string[], options?: AskOptions): Promise<Asked>;
|
|
168
|
+
private drawingConsole;
|
|
169
|
+
private askNow;
|
|
130
170
|
get phase(): AppPhase;
|
|
131
171
|
/** The clock the app reads the time from: where the host time a `Timeline` operation takes is read. */
|
|
132
172
|
get clock(): Clock;
|
|
@@ -149,12 +189,12 @@ export declare class App implements FrameHost {
|
|
|
149
189
|
/** The rows on screen now, as painted: what a pointer event lands on. */
|
|
150
190
|
get frame(): readonly (readonly Segment[])[];
|
|
151
191
|
/**
|
|
152
|
-
* The
|
|
153
|
-
*
|
|
192
|
+
* The frame this app draws now (`FrameHost`): the clock's now, through the
|
|
193
|
+
* timeline, on a frame it is the host of. Its time is what a moment heard
|
|
154
194
|
* between frames — a key — is stamped with, so it is on the clock the
|
|
155
195
|
* frame is.
|
|
156
196
|
*/
|
|
157
|
-
|
|
197
|
+
frameNow(): HostFrame;
|
|
158
198
|
private _time;
|
|
159
199
|
/**
|
|
160
200
|
* Take the terminal, paint the first frame, and resolve when the app stops
|
|
@@ -217,6 +257,7 @@ export declare class App implements FrameHost {
|
|
|
217
257
|
private enter;
|
|
218
258
|
private leave;
|
|
219
259
|
private end;
|
|
260
|
+
private reply;
|
|
220
261
|
private key;
|
|
221
262
|
private mouse;
|
|
222
263
|
private hear;
|
package/dist/host/app.js
CHANGED
|
@@ -31,21 +31,22 @@
|
|
|
31
31
|
* — a focused field in a `WidgetApp` included — can swallow them.
|
|
32
32
|
*/
|
|
33
33
|
import { frameRate, systemClock } from "../core/clock.js";
|
|
34
|
+
import { ColorDepth, resolveTerminal } from "../core/color.js";
|
|
34
35
|
import { Console } from "../core/console.js";
|
|
35
36
|
import { Segment } from "../core/segment.js";
|
|
36
37
|
import { SurfacePainter } from "../core/paint.js";
|
|
37
38
|
import { fitHeight, render } from "../core/protocol.js";
|
|
38
39
|
import { Timeline } from "../core/timeline.js";
|
|
39
|
-
import { drawView,
|
|
40
|
+
import { drawView, frameNow, hostView, undrawn } from "../core/view.js";
|
|
40
41
|
import { hostEnvironment } from "./host-environment.js";
|
|
41
42
|
import { InputParser, keyName, parseKeyName } from "./input.js";
|
|
42
|
-
// [LAW:one-source-of-truth] How a frame draws — its glyphs, the
|
|
43
|
-
// it knows, what happens to a style it
|
|
44
|
-
// highlighting — is the console's to say, so App takes those options as the
|
|
43
|
+
// [LAW:one-source-of-truth] How a frame draws — its glyphs, the terminal its
|
|
44
|
+
// colours resolve on, the style names it knows, what happens to a style it
|
|
45
|
+
// cannot resolve, markup and highlighting — is the console's to say, so App takes those options as the
|
|
45
46
|
// console declares them. This list is both the type and what is copied: the
|
|
46
47
|
// host alone decides the console's size, colours and sink, so no other
|
|
47
48
|
// property of the options object reaches it, whatever the object carries.
|
|
48
|
-
const DRAW_OPTIONS = ["asciiOnly", "reducedMotion", "theme", "onStyleError", "markup", "highlight", "highlighter"];
|
|
49
|
+
const DRAW_OPTIONS = ["asciiOnly", "reducedMotion", "textOrder", "terminal", "theme", "onStyleError", "markup", "highlight", "highlighter"];
|
|
49
50
|
function drawOptions(options) {
|
|
50
51
|
const picked = {};
|
|
51
52
|
const copy = (key) => {
|
|
@@ -68,6 +69,12 @@ const POINTER = {
|
|
|
68
69
|
// The sideways wheel is not here: nothing scrolls by columns, inside an app or
|
|
69
70
|
// around it.
|
|
70
71
|
const WHEEL_LINES = { scroll_up: -1, scroll_down: 1 };
|
|
72
|
+
// Primary Device Attributes. Every VT100 descendant answers it, and a terminal
|
|
73
|
+
// answers in the order it was asked, so its reply, sent after an ask's queries,
|
|
74
|
+
// says every reply to them that is coming has come.
|
|
75
|
+
const DA1 = "\x1b[c";
|
|
76
|
+
const isDa1Reply = (reply) => reply.kind === "csi" && reply.body.startsWith("?") && reply.body.endsWith("c");
|
|
77
|
+
const DEFAULT_ASK_TIMEOUT = 0.5;
|
|
71
78
|
const isWheelTurn = (event) => event.type in WHEEL_LINES;
|
|
72
79
|
export class App {
|
|
73
80
|
host;
|
|
@@ -90,6 +97,15 @@ export class App {
|
|
|
90
97
|
// its size, its colours, where bytes go. Every frame renders with this
|
|
91
98
|
// console's options and is encoded for its destination.
|
|
92
99
|
console;
|
|
100
|
+
drawing;
|
|
101
|
+
// [LAW:single-enforcer] One ask at a time, through the one input reader:
|
|
102
|
+
// the next waits on the last, and `leave` ends the one in flight.
|
|
103
|
+
asking = null;
|
|
104
|
+
asked = Promise.resolve();
|
|
105
|
+
// DA1 replies still owed to asks that timed out. A terminal answers in the
|
|
106
|
+
// order it was asked, so every reply before the last of them belongs to an
|
|
107
|
+
// ask that has ended, and none reaches the ask after it.
|
|
108
|
+
owedDa1 = 0;
|
|
93
109
|
_phase = "idle";
|
|
94
110
|
_frame = [];
|
|
95
111
|
painter;
|
|
@@ -104,7 +120,7 @@ export class App {
|
|
|
104
120
|
settle = () => { };
|
|
105
121
|
constructor(options) {
|
|
106
122
|
this.host = options.host;
|
|
107
|
-
this.input = new InputParser({ key: (press) => this.key(press), mouse: (event) => this.mouse(event) }, options.host.inputChunks);
|
|
123
|
+
this.input = new InputParser({ key: (press) => this.key(press), mouse: (event) => this.mouse(event), reply: (reply) => this.reply(reply) }, options.host.inputChunks);
|
|
108
124
|
this.surface = options.surface;
|
|
109
125
|
this.painter = new SurfacePainter(options.surface, (bytes) => this.host.write(bytes));
|
|
110
126
|
this.view = hostView(options.view);
|
|
@@ -113,7 +129,79 @@ export class App {
|
|
|
113
129
|
this._timeline = options.timeline ?? Timeline.play(this._epoch);
|
|
114
130
|
this._rate = options.rate ?? DEFAULT_RATE;
|
|
115
131
|
this.quitKeys = new Set((options.quitKeys ?? DEFAULT_QUIT_KEYS).map(parseKeyName));
|
|
116
|
-
this.
|
|
132
|
+
this.drawing = drawOptions(options);
|
|
133
|
+
this.console = this.drawingConsole();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The terminal the app draws on: the colours a cell that sets none is drawn
|
|
137
|
+
* in, and the sixteen an ANSI colour names — as given, or as the colour
|
|
138
|
+
* depth assumes (`resolveTerminal`).
|
|
139
|
+
*/
|
|
140
|
+
get terminal() {
|
|
141
|
+
return resolveTerminal(this.console.options.terminal, this.console.colorSystem ?? ColorDepth.TRUECOLOR);
|
|
142
|
+
}
|
|
143
|
+
/** Draw on `terminal` from the next frame on — what `terminalColors` learned, say. */
|
|
144
|
+
set terminal(terminal) {
|
|
145
|
+
// [LAW:one-source-of-truth] The console is the one holder of how a frame
|
|
146
|
+
// draws, so the terminal changes by a new console, not beside the old one.
|
|
147
|
+
this.drawing = { ...this.drawing, terminal };
|
|
148
|
+
this.console = this.drawingConsole();
|
|
149
|
+
this.refresh();
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Ask the terminal: write `queries`, then DA1, and resolve with the replies
|
|
153
|
+
* heard before DA1's, or before the timeout or the app handing the terminal
|
|
154
|
+
* back. It rejects only when writing to the terminal throws. Asks run one
|
|
155
|
+
* after another, and only while the app runs: it holds raw mode and reads
|
|
156
|
+
* the input, so a reply reaches the ask and never a key handler. A reply no
|
|
157
|
+
* ask is waiting for is dropped. `queries` must not ask for DA1 itself: its
|
|
158
|
+
* reply would end the ask early.
|
|
159
|
+
*/
|
|
160
|
+
ask(queries, options = {}) {
|
|
161
|
+
// An ask before the app runs is a mistake in the program; one after it
|
|
162
|
+
// stopped ends `left`, so the error that stopped it is the one heard.
|
|
163
|
+
if (this._phase === "idle")
|
|
164
|
+
throw new Error("App.ask: an app asks its terminal once it runs, and this one has not started");
|
|
165
|
+
const timeout = options.timeout ?? DEFAULT_ASK_TIMEOUT;
|
|
166
|
+
if (!(timeout > 0 && Number.isFinite(timeout)))
|
|
167
|
+
throw new RangeError(`App.ask: a timeout is a positive, finite number of seconds, not ${timeout}`);
|
|
168
|
+
const turn = this.asked.then(() => this.askNow(queries, timeout));
|
|
169
|
+
// One ask's failure is its caller's; the asks queued after it still run.
|
|
170
|
+
this.asked = turn.catch(() => undefined);
|
|
171
|
+
return turn;
|
|
172
|
+
}
|
|
173
|
+
// The one way the app builds the console it draws with.
|
|
174
|
+
drawingConsole() {
|
|
175
|
+
return new Console({ ...this.drawing, environment: hostEnvironment(this.host) });
|
|
176
|
+
}
|
|
177
|
+
askNow(queries, timeout) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
if (this._phase !== "running")
|
|
180
|
+
return resolve({ ended: "left", replies: [] });
|
|
181
|
+
if (!this.host.writesToTerminal)
|
|
182
|
+
return resolve({ ended: "unasked", replies: [] });
|
|
183
|
+
// Written before the ask takes the input, so a write that throws leaves
|
|
184
|
+
// no ask in flight and no timer armed.
|
|
185
|
+
this.host.write(queries.join("") + DA1);
|
|
186
|
+
const replies = [];
|
|
187
|
+
const timer = this._clock.at(this._clock.now() + timeout, () => {
|
|
188
|
+
this.owedDa1++;
|
|
189
|
+
asking.finish("timeout");
|
|
190
|
+
});
|
|
191
|
+
const asking = {
|
|
192
|
+
replies,
|
|
193
|
+
finish: (ended) => {
|
|
194
|
+
timer();
|
|
195
|
+
this.asking = null;
|
|
196
|
+
// [LAW:single-enforcer] Every way an ask ends goes through here, so
|
|
197
|
+
// what the parser held for its replies is settled on every one.
|
|
198
|
+
this.input.awaitReplies(false);
|
|
199
|
+
resolve({ ended, replies });
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
this.asking = asking;
|
|
203
|
+
this.input.awaitReplies(true);
|
|
204
|
+
});
|
|
117
205
|
}
|
|
118
206
|
get phase() {
|
|
119
207
|
return this._phase;
|
|
@@ -153,13 +241,13 @@ export class App {
|
|
|
153
241
|
return this._frame;
|
|
154
242
|
}
|
|
155
243
|
/**
|
|
156
|
-
* The
|
|
157
|
-
*
|
|
244
|
+
* The frame this app draws now (`FrameHost`): the clock's now, through the
|
|
245
|
+
* timeline, on a frame it is the host of. Its time is what a moment heard
|
|
158
246
|
* between frames — a key — is stamped with, so it is on the clock the
|
|
159
247
|
* frame is.
|
|
160
248
|
*/
|
|
161
|
-
|
|
162
|
-
return
|
|
249
|
+
frameNow() {
|
|
250
|
+
return frameNow(this, this._time());
|
|
163
251
|
}
|
|
164
252
|
_time() {
|
|
165
253
|
return { now: this._clock.now(), epoch: this._epoch, timeline: this._timeline };
|
|
@@ -238,6 +326,8 @@ export class App {
|
|
|
238
326
|
return;
|
|
239
327
|
this._phase = "suspended";
|
|
240
328
|
this.tick();
|
|
329
|
+
// What is half-read belongs to no one once the terminal is handed back.
|
|
330
|
+
this.input.reset();
|
|
241
331
|
this.leave();
|
|
242
332
|
await this.host.suspend();
|
|
243
333
|
// Stopped while suspended — by a signal, say: the terminal is already
|
|
@@ -306,6 +396,9 @@ export class App {
|
|
|
306
396
|
this.host.write(POINTER[this.surface].on);
|
|
307
397
|
}
|
|
308
398
|
leave() {
|
|
399
|
+
this.asking?.finish("left");
|
|
400
|
+
// Whatever answers the terminal sends from here reach whoever holds it next.
|
|
401
|
+
this.owedDa1 = 0;
|
|
309
402
|
this.host.write(POINTER[this.surface].off);
|
|
310
403
|
this.painter.handBack();
|
|
311
404
|
this.host.setRawMode(false);
|
|
@@ -332,6 +425,18 @@ export class App {
|
|
|
332
425
|
this.settle(outcome);
|
|
333
426
|
}
|
|
334
427
|
// --- input ---
|
|
428
|
+
reply(reply) {
|
|
429
|
+
if (this.owedDa1 > 0) {
|
|
430
|
+
if (isDa1Reply(reply))
|
|
431
|
+
this.owedDa1--;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (this.asking === null)
|
|
435
|
+
return;
|
|
436
|
+
if (isDa1Reply(reply))
|
|
437
|
+
return this.asking.finish("answered");
|
|
438
|
+
this.asking.replies.push(reply);
|
|
439
|
+
}
|
|
335
440
|
key(press) {
|
|
336
441
|
if (this.quitKeys.has(keyName(press)))
|
|
337
442
|
return this.stop();
|
package/dist/host/index.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ export { BrowserTerminalHost } from "./terminal-host.js";
|
|
|
2
2
|
export type { TerminalHost, TerminalSize, DataHandler, InputChunks, ResizeHandler, ExitHandler, BrowserTerminalHostOptions, XtermTerminal, XtermDisposable, XtermResizeEvent, } from "./terminal-host.js";
|
|
3
3
|
export { hostEnvironment } from "./host-environment.js";
|
|
4
4
|
export { App } from "./app.js";
|
|
5
|
-
export type { AppMouseEvent, AppOptions, AppPhase, Wheel } from "./app.js";
|
|
5
|
+
export type { AppMouseEvent, AppOptions, AppPhase, Asked, AskOptions, Wheel } from "./app.js";
|
|
6
|
+
export { terminalColors } from "./terminal-colors.js";
|
|
6
7
|
export type { Surface } from "../core/paint.js";
|
|
7
8
|
export { keyName } from "./input.js";
|
|
8
|
-
export type { KeyPress, ScreenMouseEvent } from "./input.js";
|
|
9
|
+
export type { KeyPress, ScreenMouseEvent, TerminalReply } from "./input.js";
|
package/dist/host/index.js
CHANGED
package/dist/host/input.d.ts
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* "are we mid-sequence" mode flag — the buffer head carries that state, so on
|
|
14
14
|
* a host whose chunks can split a key, a UTF-8 character or an escape
|
|
15
15
|
* sequence split across two chunks is completed by the second. What a chunk
|
|
16
|
-
* end means is the host's `InputChunks`,
|
|
17
|
-
*
|
|
16
|
+
* end means is the host's `InputChunks`, and whether an ask awaits a reply is
|
|
17
|
+
* what its owner last told `awaitReplies`: the two inputs to the parser that
|
|
18
|
+
* are not bytes, and both decide only what becomes of an unfinished ESC.
|
|
18
19
|
*
|
|
19
20
|
* An unfinished escape sequence is ambiguous: `ESC` alone is the Escape key
|
|
20
21
|
* or the start of a sequence, and `ESC [` is Alt+[ or the start of a CSI.
|
|
@@ -29,6 +30,15 @@
|
|
|
29
30
|
* lone ESC is also settled early by a next chunk that opens with anything but
|
|
30
31
|
* `[` or `O`, because a busy worker can queue the next key behind it.
|
|
31
32
|
* `flush()` settles the hold synchronously.
|
|
33
|
+
*
|
|
34
|
+
* `ESC ]`, `ESC P` and `ESC _` open the control strings a terminal replies
|
|
35
|
+
* in, and are also Alt+], Alt+P and Alt+_. A reply can arrive split only on
|
|
36
|
+
* a split host while an ask awaits one (`replyMayArrive`), and only then is an
|
|
37
|
+
* unterminated one, or a lone ESC that may open one, held — past the escape
|
|
38
|
+
* delay, until `awaitReplies(false)` arms the delay afresh. Otherwise it is Alt
|
|
39
|
+
* on its key at once. A terminated one is a reply either way, and one cut off
|
|
40
|
+
* by an ESC that is not ST was never a reply, and is Alt on its key, as with
|
|
41
|
+
* none awaited.
|
|
32
42
|
*/
|
|
33
43
|
import type { InputChunks } from "./terminal-host.js";
|
|
34
44
|
/** A key as the terminal reported it: plain data, with no say over who hears it. */
|
|
@@ -61,10 +71,22 @@ interface MouseEventOf<Type extends string> {
|
|
|
61
71
|
* which already carries it: Shift+G is `G`.
|
|
62
72
|
*/
|
|
63
73
|
export declare function keyName(press: KeyPress): string;
|
|
74
|
+
/**
|
|
75
|
+
* What a terminal sends back when it is asked something: an OSC, DCS or APC
|
|
76
|
+
* string, its introducer and terminator taken off (`11;rgb:1e1e/1e1e/1e1e`
|
|
77
|
+
* for an OSC 11 reply), or a CSI report with a private prefix, its final byte
|
|
78
|
+
* kept (`?62;22c` for DA1, `?2026;2$y` for DECRQM). No key is sent in either
|
|
79
|
+
* form, so neither is ever read as one.
|
|
80
|
+
*/
|
|
81
|
+
export interface TerminalReply {
|
|
82
|
+
readonly kind: "osc" | "dcs" | "apc" | "csi";
|
|
83
|
+
readonly body: string;
|
|
84
|
+
}
|
|
64
85
|
/** Where a parser delivers what it parses. */
|
|
65
86
|
export interface InputSink {
|
|
66
87
|
key(press: KeyPress): void;
|
|
67
88
|
mouse(event: ScreenMouseEvent): void;
|
|
89
|
+
reply(reply: TerminalReply): void;
|
|
68
90
|
}
|
|
69
91
|
/**
|
|
70
92
|
* `name`, if `keyName` can produce it: modifiers in its order, then a named
|
|
@@ -87,11 +109,22 @@ export declare class InputParser {
|
|
|
87
109
|
private readonly chunks;
|
|
88
110
|
private buffer;
|
|
89
111
|
private escTimer;
|
|
112
|
+
private awaiting;
|
|
90
113
|
constructor(sink: InputSink, chunks: InputChunks);
|
|
91
114
|
/** Feed a chunk of bytes (or a string of them) into the parser. */
|
|
92
115
|
feed(chunk: Uint8Array | string): void;
|
|
93
116
|
private drainAndHold;
|
|
94
117
|
private hold;
|
|
118
|
+
/**
|
|
119
|
+
* Whether an ask is awaiting the terminal's replies, told by whoever asks
|
|
120
|
+
* at the ask's start and end. Only while one is does an unfinished
|
|
121
|
+
* `ESC ]`, `ESC P` or `ESC _` open a reply still arriving; otherwise it is
|
|
122
|
+
* Alt on that key. The wait's end restarts the escape delay for what is
|
|
123
|
+
* held, on a split host, the only one that holds.
|
|
124
|
+
*/
|
|
125
|
+
awaitReplies(awaiting: boolean): void;
|
|
126
|
+
private replyMayArrive;
|
|
127
|
+
private continuesEscape;
|
|
95
128
|
private settleChunk;
|
|
96
129
|
private drainFully;
|
|
97
130
|
/** Settle a held escape sequence now, as the end of a whole-key chunk. */
|