@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.
Files changed (132) hide show
  1. package/README.md +1 -0
  2. package/THIRD-PARTY-NOTICES +42 -0
  3. package/dist/core/animation.d.ts +52 -15
  4. package/dist/core/animation.js +82 -37
  5. package/dist/core/ansi.d.ts +15 -8
  6. package/dist/core/ansi.js +106 -28
  7. package/dist/core/bidi-data.d.ts +14 -0
  8. package/dist/core/bidi-data.js +385 -0
  9. package/dist/core/bidi.d.ts +89 -0
  10. package/dist/core/bidi.js +615 -0
  11. package/dist/core/box.d.ts +9 -1
  12. package/dist/core/box.js +11 -0
  13. package/dist/core/cell-widths.d.ts +3 -2
  14. package/dist/core/cell-widths.js +93 -93
  15. package/dist/core/cells.d.ts +20 -0
  16. package/dist/core/cells.js +93 -5
  17. package/dist/core/color.d.ts +26 -18
  18. package/dist/core/color.js +37 -34
  19. package/dist/core/console.d.ts +26 -1
  20. package/dist/core/console.js +26 -6
  21. package/dist/core/control-codes.d.ts +61 -0
  22. package/dist/core/control-codes.js +157 -0
  23. package/dist/core/easing.js +6 -1
  24. package/dist/core/env.d.ts +6 -0
  25. package/dist/core/env.js +9 -1
  26. package/dist/core/export-html.js +4 -3
  27. package/dist/core/export-lines.d.ts +2 -0
  28. package/dist/core/export-lines.js +7 -3
  29. package/dist/core/export-svg.js +6 -5
  30. package/dist/core/json.js +9 -2
  31. package/dist/core/markup.js +4 -3
  32. package/dist/core/oklch.d.ts +2 -0
  33. package/dist/core/oklch.js +3 -3
  34. package/dist/core/orient.d.ts +68 -0
  35. package/dist/core/orient.js +224 -0
  36. package/dist/core/osc8.d.ts +12 -5
  37. package/dist/core/osc8.js +14 -7
  38. package/dist/core/pretty.js +22 -18
  39. package/dist/core/protocol.d.ts +53 -22
  40. package/dist/core/protocol.js +14 -6
  41. package/dist/core/render.d.ts +8 -3
  42. package/dist/core/render.js +19 -5
  43. package/dist/core/seal.d.ts +26 -0
  44. package/dist/core/seal.js +75 -0
  45. package/dist/core/segment.d.ts +9 -1
  46. package/dist/core/segment.js +11 -1
  47. package/dist/core/style.d.ts +29 -1
  48. package/dist/core/style.js +41 -5
  49. package/dist/core/text-order.d.ts +51 -0
  50. package/dist/core/text-order.js +50 -0
  51. package/dist/core/text.d.ts +10 -0
  52. package/dist/core/text.js +133 -87
  53. package/dist/core/timeline.js +6 -6
  54. package/dist/core/view.d.ts +23 -9
  55. package/dist/core/view.js +37 -12
  56. package/dist/core/wrap.d.ts +24 -6
  57. package/dist/core/wrap.js +82 -16
  58. package/dist/host/app.d.ts +48 -7
  59. package/dist/host/app.js +116 -11
  60. package/dist/host/index.d.ts +3 -2
  61. package/dist/host/index.js +1 -0
  62. package/dist/host/input.d.ts +35 -2
  63. package/dist/host/input.js +88 -4
  64. package/dist/host/terminal-colors.d.ts +18 -0
  65. package/dist/host/terminal-colors.js +46 -0
  66. package/dist/host/terminal-host.d.ts +2 -1
  67. package/dist/index.d.ts +10 -8
  68. package/dist/index.js +2 -2
  69. package/dist/renderables/change.d.ts +1 -3
  70. package/dist/renderables/change.js +4 -6
  71. package/dist/renderables/effect.d.ts +61 -60
  72. package/dist/renderables/effect.js +91 -81
  73. package/dist/renderables/effects.d.ts +37 -8
  74. package/dist/renderables/effects.js +193 -55
  75. package/dist/renderables/frames.d.ts +1 -10
  76. package/dist/renderables/frames.js +1 -16
  77. package/dist/renderables/glyph-effect.d.ts +12 -13
  78. package/dist/renderables/glyph-effect.js +34 -32
  79. package/dist/renderables/glyph-effects.d.ts +27 -8
  80. package/dist/renderables/glyph-effects.js +75 -7
  81. package/dist/renderables/lifecycle.d.ts +40 -32
  82. package/dist/renderables/lifecycle.js +87 -48
  83. package/dist/renderables/live.d.ts +3 -3
  84. package/dist/renderables/live.js +4 -4
  85. package/dist/renderables/markdown.js +4 -2
  86. package/dist/renderables/panel.js +6 -4
  87. package/dist/renderables/progress.d.ts +11 -4
  88. package/dist/renderables/progress.js +29 -17
  89. package/dist/renderables/progressBar.d.ts +3 -3
  90. package/dist/renderables/progressBar.js +16 -13
  91. package/dist/renderables/syntax.js +7 -2
  92. package/dist/renderables/table.js +2 -1
  93. package/dist/template-bindings/animation-funcs.d.ts +49 -18
  94. package/dist/template-bindings/animation-funcs.js +115 -32
  95. package/dist/template-bindings/index.d.ts +7 -5
  96. package/dist/template-bindings/index.js +8 -5
  97. package/dist/template-bindings/palette-funcs.d.ts +1 -1
  98. package/dist/template-bindings/palette-funcs.js +1 -1
  99. package/dist/themes/buildPalette.d.ts +15 -39
  100. package/dist/themes/buildPalette.js +196 -67
  101. package/dist/themes/colorMath.d.ts +10 -4
  102. package/dist/themes/colorMath.js +33 -1
  103. package/dist/themes/data/index.js +1 -4
  104. package/dist/themes/data/textual-ansi.js +0 -143
  105. package/dist/themes/data/types.d.ts +3 -1
  106. package/dist/themes/palette.d.ts +22 -6
  107. package/dist/themes/palette.js +13 -21
  108. package/dist/themes/ramp.d.ts +1 -1
  109. package/dist/themes/ramp.js +1 -1
  110. package/dist/themes/registry.d.ts +7 -10
  111. package/dist/themes/registry.js +9 -30
  112. package/dist/themes/terminalThemes.d.ts +4 -10
  113. package/dist/themes/terminalThemes.js +31 -46
  114. package/dist/themes/transpose.d.ts +5 -10
  115. package/dist/themes/transpose.js +13 -24
  116. package/dist/widgets/button.d.ts +1 -1
  117. package/dist/widgets/button.js +6 -6
  118. package/dist/widgets/checkbox.js +4 -4
  119. package/dist/widgets/dropdown.d.ts +1 -1
  120. package/dist/widgets/dropdown.js +7 -5
  121. package/dist/widgets/event-router.js +2 -2
  122. package/dist/widgets/ink.d.ts +5 -4
  123. package/dist/widgets/ink.js +6 -9
  124. package/dist/widgets/text-input.d.ts +1 -1
  125. package/dist/widgets/text-input.js +41 -32
  126. package/dist/widgets/themed-widget.js +3 -3
  127. package/dist/widgets/toggle.d.ts +1 -1
  128. package/dist/widgets/toggle.js +5 -5
  129. package/dist/widgets/types.d.ts +2 -2
  130. package/dist/widgets/widget-app.d.ts +3 -3
  131. package/dist/widgets/widget-app.js +5 -5
  132. package/package.json +5 -4
@@ -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 word of `text` needs, in order — the widest is the narrowest
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 cell offsets `text` should be cut at to wrap it into lines of at most
30
- * `width` cells, for `Segment.divide` to apply to the styled line.
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
- }): CellCol[];
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, asCellCol } from "./cells.js";
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
- const re = /\s*\S+\s*/y;
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 word of `text` needs, in order — the widest is the narrowest
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(words(text), widthOf);
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 cell offsets `text` should be cut at to wrap it into lines of at most
82
- * `width` cells, for `Segment.divide` to apply to the styled line.
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 cuts = [];
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
- cuts.push(asCellCol(cell));
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
- for (const word of words(text)) {
110
- const wordWidth = widthOf(word);
111
- if (width - used >= wordWidth) {
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
- const piece = pieces[index];
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(piece);
190
+ used = cellLen(fold);
127
191
  else
128
- pieceStart += cellLen(piece);
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 cuts;
205
+ return breaks;
140
206
  }
@@ -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 readonly console;
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 time a frame drawn now is drawn for (`RenderOptions.frame`): the
153
- * clock's now, through the timeline. What a moment heard
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
- get frameTime(): number;
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, frameTime, hostView, undrawn } from "../core/view.js";
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 style names
43
- // it knows, what happens to a style it cannot resolve, markup and
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.console = new Console({ ...drawOptions(options), environment: hostEnvironment(options.host) });
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 time a frame drawn now is drawn for (`RenderOptions.frame`): the
157
- * clock's now, through the timeline. What a moment heard
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
- get frameTime() {
162
- return frameTime(this._time());
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();
@@ -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";
@@ -1,4 +1,5 @@
1
1
  export { BrowserTerminalHost } from "./terminal-host.js";
2
2
  export { hostEnvironment } from "./host-environment.js";
3
3
  export { App } from "./app.js";
4
+ export { terminalColors } from "./terminal-colors.js";
4
5
  export { keyName } from "./input.js";
@@ -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`, the one input to the parser that is
17
- * not a byte, and it decides only what becomes of an unfinished ESC.
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. */