@promptctl/rich-js 0.18.1 → 0.20.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 (169) hide show
  1. package/README.md +11 -8
  2. package/dist/core/box.d.ts +3 -1
  3. package/dist/core/box.js +6 -13
  4. package/dist/core/cells.d.ts +61 -28
  5. package/dist/core/cells.js +171 -124
  6. package/dist/core/clock.d.ts +48 -0
  7. package/dist/core/clock.js +42 -0
  8. package/dist/core/color.d.ts +99 -18
  9. package/dist/core/color.js +334 -146
  10. package/dist/core/console.d.ts +38 -7
  11. package/dist/core/console.js +159 -90
  12. package/dist/core/easing.d.ts +86 -0
  13. package/dist/core/easing.js +171 -0
  14. package/dist/core/env.d.ts +16 -0
  15. package/dist/core/env.js +16 -0
  16. package/dist/core/export-html.js +1 -4
  17. package/dist/core/export-lines.d.ts +22 -13
  18. package/dist/core/export-lines.js +37 -19
  19. package/dist/core/export-svg.d.ts +49 -0
  20. package/dist/core/export-svg.js +213 -0
  21. package/dist/core/fnv1a.d.ts +10 -0
  22. package/dist/core/fnv1a.js +17 -0
  23. package/dist/core/highlighter.d.ts +2 -2
  24. package/dist/core/highlighter.js +113 -47
  25. package/dist/core/json.js +1 -1
  26. package/dist/core/markup.d.ts +28 -7
  27. package/dist/core/markup.js +235 -312
  28. package/dist/core/measure.d.ts +3 -3
  29. package/dist/core/measure.js +12 -7
  30. package/dist/core/memo.d.ts +19 -0
  31. package/dist/core/memo.js +34 -0
  32. package/dist/core/noise.d.ts +31 -0
  33. package/dist/core/noise.js +94 -0
  34. package/dist/core/osc8.d.ts +6 -6
  35. package/dist/core/osc8.js +9 -21
  36. package/dist/core/paint.d.ts +107 -0
  37. package/dist/core/paint.js +161 -0
  38. package/dist/core/place.d.ts +1 -1
  39. package/dist/core/place.js +4 -6
  40. package/dist/core/pretty.d.ts +126 -2
  41. package/dist/core/pretty.js +571 -105
  42. package/dist/core/protocol.d.ts +43 -3
  43. package/dist/core/protocol.js +23 -2
  44. package/dist/core/render.d.ts +33 -30
  45. package/dist/core/render.js +43 -83
  46. package/dist/core/segment.d.ts +6 -5
  47. package/dist/core/segment.js +11 -15
  48. package/dist/core/selection.d.ts +12 -0
  49. package/dist/core/selection.js +14 -0
  50. package/dist/core/strip.d.ts +116 -74
  51. package/dist/core/strip.js +266 -217
  52. package/dist/core/style.d.ts +27 -7
  53. package/dist/core/style.js +106 -41
  54. package/dist/core/text.d.ts +43 -16
  55. package/dist/core/text.js +180 -59
  56. package/dist/core/wrap.d.ts +5 -0
  57. package/dist/core/wrap.js +16 -3
  58. package/dist/host/app.d.ts +95 -17
  59. package/dist/host/app.js +201 -61
  60. package/dist/host/index.d.ts +4 -1
  61. package/dist/host/index.js +1 -0
  62. package/dist/host/input.d.ts +84 -0
  63. package/dist/host/input.js +498 -0
  64. package/dist/host/terminal-host.d.ts +15 -2
  65. package/dist/host/terminal-host.js +2 -0
  66. package/dist/index.d.ts +26 -14
  67. package/dist/index.js +17 -7
  68. package/dist/node/prompt.d.ts +13 -0
  69. package/dist/node/prompt.js +30 -9
  70. package/dist/node/save.d.ts +4 -3
  71. package/dist/node/save.js +6 -3
  72. package/dist/node/terminal-host.d.ts +10 -2
  73. package/dist/node/terminal-host.js +7 -0
  74. package/dist/node/traceback.d.ts +2 -1
  75. package/dist/node/traceback.js +3 -39
  76. package/dist/renderables/align.js +2 -9
  77. package/dist/renderables/columns.d.ts +45 -29
  78. package/dist/renderables/columns.js +142 -103
  79. package/dist/renderables/constrain.js +6 -12
  80. package/dist/renderables/effect.d.ts +113 -0
  81. package/dist/renderables/effect.js +182 -0
  82. package/dist/renderables/effects.d.ts +252 -0
  83. package/dist/renderables/effects.js +565 -0
  84. package/dist/renderables/embed.d.ts +94 -31
  85. package/dist/renderables/embed.js +141 -45
  86. package/dist/renderables/group.d.ts +6 -3
  87. package/dist/renderables/group.js +5 -0
  88. package/dist/renderables/layout.d.ts +6 -23
  89. package/dist/renderables/layout.js +24 -113
  90. package/dist/renderables/list-view.d.ts +42 -0
  91. package/dist/renderables/list-view.js +78 -0
  92. package/dist/renderables/live.d.ts +40 -16
  93. package/dist/renderables/live.js +96 -83
  94. package/dist/renderables/markdown.d.ts +16 -2
  95. package/dist/renderables/markdown.js +521 -118
  96. package/dist/renderables/padding.d.ts +22 -1
  97. package/dist/renderables/padding.js +30 -24
  98. package/dist/renderables/panel.d.ts +31 -33
  99. package/dist/renderables/panel.js +137 -141
  100. package/dist/renderables/progress.d.ts +110 -16
  101. package/dist/renderables/progress.js +212 -81
  102. package/dist/renderables/progressBar.d.ts +24 -4
  103. package/dist/renderables/progressBar.js +78 -23
  104. package/dist/renderables/prompt.d.ts +37 -15
  105. package/dist/renderables/prompt.js +125 -85
  106. package/dist/renderables/ratio.d.ts +123 -0
  107. package/dist/renderables/ratio.js +213 -0
  108. package/dist/renderables/rule.d.ts +3 -2
  109. package/dist/renderables/rule.js +50 -52
  110. package/dist/renderables/spinner.d.ts +28 -2
  111. package/dist/renderables/spinner.js +52 -19
  112. package/dist/renderables/status.d.ts +13 -7
  113. package/dist/renderables/status.js +15 -29
  114. package/dist/renderables/syntax.d.ts +37 -7
  115. package/dist/renderables/syntax.js +134 -83
  116. package/dist/renderables/table.d.ts +58 -20
  117. package/dist/renderables/table.js +373 -197
  118. package/dist/renderables/traceback.d.ts +5 -4
  119. package/dist/renderables/traceback.js +239 -68
  120. package/dist/renderables/tree.d.ts +10 -2
  121. package/dist/renderables/tree.js +61 -34
  122. package/dist/renderables/viewport.d.ts +30 -0
  123. package/dist/renderables/viewport.js +61 -7
  124. package/dist/template-bindings/color-funcs.d.ts +31 -9
  125. package/dist/template-bindings/color-funcs.js +40 -14
  126. package/dist/template-bindings/helpers.d.ts +4 -2
  127. package/dist/template-bindings/helpers.js +5 -20
  128. package/dist/template-bindings/index.d.ts +29 -17
  129. package/dist/template-bindings/index.js +36 -20
  130. package/dist/template-bindings/palette-funcs.d.ts +3 -1
  131. package/dist/template-bindings/palette-funcs.js +11 -7
  132. package/dist/template-bindings/style-funcs.js +4 -2
  133. package/dist/themes/buildPalette.d.ts +24 -3
  134. package/dist/themes/buildPalette.js +52 -8
  135. package/dist/themes/colorMath.d.ts +29 -17
  136. package/dist/themes/colorMath.js +74 -48
  137. package/dist/themes/colorRef.d.ts +2 -17
  138. package/dist/themes/colorRef.js +13 -31
  139. package/dist/themes/palette.d.ts +24 -0
  140. package/dist/themes/palette.js +46 -3
  141. package/dist/themes/ramp.d.ts +21 -34
  142. package/dist/themes/ramp.js +23 -42
  143. package/dist/themes/registry.js +39 -28
  144. package/dist/widgets/button.d.ts +2 -6
  145. package/dist/widgets/button.js +9 -40
  146. package/dist/widgets/checkbox.d.ts +2 -5
  147. package/dist/widgets/checkbox.js +9 -31
  148. package/dist/widgets/dropdown.d.ts +2 -5
  149. package/dist/widgets/dropdown.js +17 -51
  150. package/dist/widgets/event-router.d.ts +31 -40
  151. package/dist/widgets/event-router.js +52 -483
  152. package/dist/widgets/index.d.ts +2 -1
  153. package/dist/widgets/ink.d.ts +22 -0
  154. package/dist/widgets/ink.js +31 -0
  155. package/dist/widgets/slider.d.ts +2 -5
  156. package/dist/widgets/slider.js +11 -34
  157. package/dist/widgets/text-input.d.ts +19 -10
  158. package/dist/widgets/text-input.js +156 -147
  159. package/dist/widgets/themed-widget.d.ts +20 -0
  160. package/dist/widgets/themed-widget.js +89 -0
  161. package/dist/widgets/toggle.d.ts +2 -5
  162. package/dist/widgets/toggle.js +8 -31
  163. package/dist/widgets/types.d.ts +8 -25
  164. package/dist/widgets/types.js +8 -0
  165. package/dist/widgets/widget-app.d.ts +24 -7
  166. package/dist/widgets/widget-app.js +35 -18
  167. package/dist/widgets/widget-base.d.ts +2 -0
  168. package/dist/widgets/widget-base.js +9 -2
  169. package/package.json +31 -17
package/dist/host/app.js CHANGED
@@ -23,68 +23,104 @@
23
23
  * frame's encoding. What differs per host — whether the program can end
24
24
  * under us, how it is suspended — is the `TerminalHost`'s, so the same app
25
25
  * runs in node and in a browser with no path of its own for either.
26
+ *
27
+ * [LAW:single-enforcer] Input has one reader: the app subscribes to its
28
+ * host's bytes once and parses them (`host/input`), so a program hears keys
29
+ * and the pointer as events and never decodes a byte. The quit keys are
30
+ * checked here, before any handler hears the key, so nothing a program binds
31
+ * — a focused field in a `WidgetApp` included — can swallow them.
26
32
  */
33
+ import { frameRate, systemClock } from "../core/clock.js";
27
34
  import { Console } from "../core/console.js";
28
35
  import { Segment } from "../core/segment.js";
29
- import { segmentsToString } from "../core/render.js";
36
+ import { SurfacePainter } from "../core/paint.js";
30
37
  import { fitHeight } from "../core/protocol.js";
31
38
  import { hostEnvironment } from "./host-environment.js";
39
+ import { InputParser, keyName, parseKeyName } from "./input.js";
40
+ // [LAW:one-source-of-truth] How a frame draws — its glyphs, the style names
41
+ // it knows, what happens to a style it cannot resolve, markup and
42
+ // highlighting — is the console's to say, so App takes those options as the
43
+ // console declares them. This list is both the type and what is copied: the
44
+ // host alone decides the console's size, colours and sink, so no other
45
+ // property of the options object reaches it, whatever the object carries.
46
+ const DRAW_OPTIONS = ["asciiOnly", "theme", "onStyleError", "markup", "highlight", "highlighter"];
47
+ function drawOptions(options) {
48
+ const picked = {};
49
+ const copy = (key) => {
50
+ picked[key] = options[key];
51
+ };
52
+ DRAW_OPTIONS.forEach(copy);
53
+ return picked;
54
+ }
55
+ const DEFAULT_RATE = frameRate(30);
56
+ const DEFAULT_QUIT_KEYS = ["ctrl+c"];
32
57
  // Button presses, motion and the wheel, in the SGR encoding: coordinates as
33
58
  // decimal numbers, so a terminal wider than 223 columns still reports them.
34
- const POINTER_ON = "\x1b[?1006h\x1b[?1000h\x1b[?1003h";
35
- const POINTER_OFF = "\x1b[?1003l\x1b[?1000l\x1b[?1006l";
36
- const SURFACES = {
37
- alternate: {
38
- exact: true,
39
- enter: "\x1b[?1049h" + POINTER_ON,
40
- leave: () => POINTER_OFF + "\x1b[?1049l",
41
- home: () => "\x1b[H",
42
- // The frame is every row of the screen, so there is nothing left under it
43
- // — and after the terminal shrinks, painting the old count would scroll.
44
- painted: (frameRows) => frameRows,
45
- },
46
- inline: {
47
- exact: false,
48
- enter: "",
49
- // Below the frame, where the program's next line belongs.
50
- leave: (rows) => (rows > 0 ? "\n" : ""),
51
- // After N rows written with N-1 newlines between them, the cursor is on
52
- // the last. `ESC[0A` still moves a row on some terminals, so a one-row
53
- // frame is returned to with the carriage return alone.
54
- home: (rows) => (rows > 1 ? `\x1b[${rows - 1}A\r` : "\r"),
55
- // A shorter frame blanks the rows the last one left below it.
56
- painted: (frameRows, lastRows) => Math.max(frameRows, lastRows),
57
- },
59
+ // The terminal reports a pointer by its row on the screen, and an inline
60
+ // frame does not know which row of the screen it starts on, so only the
61
+ // alternate surface gets them.
62
+ const POINTER = {
63
+ alternate: { on: "\x1b[?1006h\x1b[?1000h\x1b[?1003h", off: "\x1b[?1003l\x1b[?1000l\x1b[?1006l" },
64
+ inline: { on: "", off: "" },
58
65
  };
59
- const HIDE_CURSOR = "\x1b[?25l";
60
- const SHOW_CURSOR = "\x1b[?25h";
61
- const RESET_STYLE = "\x1b[0m";
62
- const ERASE_LINE = "\x1b[2K";
66
+ // The sideways wheel is not here: nothing scrolls by columns, inside an app or
67
+ // around it.
68
+ const WHEEL_LINES = { scroll_up: -1, scroll_down: 1 };
69
+ const isWheelTurn = (event) => event.type in WHEEL_LINES;
63
70
  export class App {
64
71
  host;
65
72
  surface;
66
73
  view;
74
+ clock;
75
+ _rate;
76
+ // What is animating now: one token per `animate()` not yet released.
77
+ animations = new Set();
78
+ // The timer painting frames, and the rate it was started at.
79
+ ticking = null;
67
80
  // [LAW:one-source-of-truth] The host is the console's whole environment:
68
81
  // its size, its colours, where bytes go. Every frame renders with this
69
82
  // console's options and is encoded for its destination.
70
83
  console;
71
84
  _phase = "idle";
72
85
  _frame = [];
73
- // The last frame's own rows on the terminal — what `home` rewinds.
74
- rows = 0;
86
+ painter;
75
87
  refreshQueued = false;
76
88
  subscriptions = [];
77
89
  paintHandlers = new Set();
90
+ keyHandlers = new Set();
91
+ mouseHandlers = new Set();
92
+ wheelHandlers = new Set();
93
+ quitKeys;
94
+ input = new InputParser({
95
+ key: (press) => this.key(press),
96
+ mouse: (event) => this.mouse(event),
97
+ });
78
98
  settle = () => { };
79
99
  constructor(options) {
80
100
  this.host = options.host;
81
- this.surface = SURFACES[options.surface];
101
+ this.surface = options.surface;
102
+ this.painter = new SurfacePainter(options.surface, (bytes) => this.host.write(bytes));
82
103
  this.view = options.view;
83
- this.console = new Console({ environment: hostEnvironment(options.host), asciiOnly: options.asciiOnly });
104
+ this.clock = options.clock ?? systemClock();
105
+ this._rate = options.rate ?? DEFAULT_RATE;
106
+ this.quitKeys = new Set((options.quitKeys ?? DEFAULT_QUIT_KEYS).map(parseKeyName));
107
+ this.console = new Console({ ...drawOptions(options), environment: hostEnvironment(options.host) });
84
108
  }
85
109
  get phase() {
86
110
  return this._phase;
87
111
  }
112
+ /** How often the app paints while something is animating. */
113
+ get rate() {
114
+ return this._rate;
115
+ }
116
+ /**
117
+ * Set how often the app paints: an animation under way goes on at the new
118
+ * rate from its next frame, without a frame lost or one painted twice.
119
+ */
120
+ set rate(rate) {
121
+ this._rate = rate;
122
+ this.tick();
123
+ }
88
124
  /** The rows on screen now, as painted: what a pointer event lands on. */
89
125
  get frame() {
90
126
  return this._frame;
@@ -116,6 +152,7 @@ export class App {
116
152
  this._phase = "running";
117
153
  this.host.start();
118
154
  this.subscriptions = [
155
+ this.host.onData((chunk) => this.input.feed(chunk)),
119
156
  this.host.onResize(() => this.refresh()),
120
157
  // The program is ending under the app — by a signal, a crash, or an
121
158
  // exit the app's own code never saw. Ending hands the terminal back,
@@ -124,6 +161,7 @@ export class App {
124
161
  ];
125
162
  this.enter();
126
163
  this.paint();
164
+ this.tick();
127
165
  });
128
166
  }
129
167
  /**
@@ -140,6 +178,10 @@ export class App {
140
178
  return;
141
179
  this.refreshQueued = true;
142
180
  setTimeout(() => {
181
+ // A frame painted since it was asked for — a tick — drew every change
182
+ // made before it, so there is nothing left to paint.
183
+ if (!this.refreshQueued)
184
+ return;
143
185
  this.refreshQueued = false;
144
186
  // [LAW:types-are-the-program] Only a running app holds the terminal; a
145
187
  // suspended one repaints when it resumes, and a stopped one never does.
@@ -147,6 +189,24 @@ export class App {
147
189
  this.paint();
148
190
  });
149
191
  }
192
+ /**
193
+ * Paint at the app's `rate` until the returned function is called: what an
194
+ * effect sampled at the frame's time asks for while it moves. The app ticks
195
+ * while anything is animating and it holds the terminal, and stops when
196
+ * nothing is, so a still app paints only when asked. The animation's first
197
+ * frame does not wait out an interval: at one frame every two seconds, that
198
+ * would hold its start off screen for two.
199
+ */
200
+ animate() {
201
+ const animation = {};
202
+ this.animations.add(animation);
203
+ this.tick();
204
+ this.refresh();
205
+ return () => {
206
+ this.animations.delete(animation);
207
+ this.tick();
208
+ };
209
+ }
150
210
  /**
151
211
  * Hand the terminal to whatever launched the program, and take it back and
152
212
  * repaint when that resumes it. What sends the app here is the app's own —
@@ -156,6 +216,7 @@ export class App {
156
216
  if (this._phase !== "running")
157
217
  return;
158
218
  this._phase = "suspended";
219
+ this.tick();
159
220
  this.leave();
160
221
  await this.host.suspend();
161
222
  // Stopped while suspended — by a signal, say: the terminal is already
@@ -163,11 +224,9 @@ export class App {
163
224
  if (this._phase !== "suspended")
164
225
  return;
165
226
  this._phase = "running";
166
- // What the last frame occupied is gone under whatever ran meanwhile, so
167
- // the next one starts where the cursor is.
168
- this.rows = 0;
169
227
  this.enter();
170
228
  this.paint();
229
+ this.tick();
171
230
  }
172
231
  /**
173
232
  * Hear each frame once it is on screen — what `frame` now returns. A
@@ -177,6 +236,35 @@ export class App {
177
236
  this.paintHandlers.add(handler);
178
237
  return () => this.paintHandlers.delete(handler);
179
238
  }
239
+ /**
240
+ * Hear every key the app does not quit on, by the `KeyPress` the terminal
241
+ * reported; `keyName` names it. A handler that throws fails the app.
242
+ */
243
+ onKey(handler) {
244
+ this.keyHandlers.add(handler);
245
+ return () => this.keyHandlers.delete(handler);
246
+ }
247
+ /**
248
+ * Hear every pointer event but the vertical wheel, which is `onWheel`'s,
249
+ * in terminal cells. Only the alternate surface reports the pointer. A
250
+ * handler that throws fails the app.
251
+ */
252
+ onMouse(handler) {
253
+ this.mouseHandlers.add(handler);
254
+ return () => this.mouseHandlers.delete(handler);
255
+ }
256
+ /**
257
+ * Offer every wheel turn to `handler`, which returns whether it moved
258
+ * something. A turn no handler moves anything with is passed outward to the
259
+ * host (`TerminalHost.passWheel`), so the page or terminal around the app
260
+ * scrolls instead. Handlers are offered a turn in the order they subscribed,
261
+ * and the first that moves something takes it. A handler that throws fails
262
+ * the app.
263
+ */
264
+ onWheel(handler) {
265
+ this.wheelHandlers.add(handler);
266
+ return () => this.wheelHandlers.delete(handler);
267
+ }
180
268
  /** Hand the terminal back for good; `run` resolves. */
181
269
  stop() {
182
270
  this.end({ kind: "stopped" });
@@ -193,26 +281,91 @@ export class App {
193
281
  // --- the terminal ---
194
282
  enter() {
195
283
  this.host.setRawMode(true);
196
- this.host.write(this.surface.enter + HIDE_CURSOR);
284
+ this.painter.take();
285
+ this.host.write(POINTER[this.surface].on);
197
286
  }
198
287
  leave() {
199
- this.host.write(RESET_STYLE + SHOW_CURSOR + this.surface.leave(this.rows));
288
+ this.host.write(POINTER[this.surface].off);
289
+ this.painter.handBack();
200
290
  this.host.setRawMode(false);
201
291
  }
202
292
  end(outcome) {
203
293
  const phase = this._phase;
204
- if (phase === "idle" || phase === "stopped")
294
+ // [LAW:no-silent-failure] An error after the app ended has no `run` left
295
+ // to reject, so it is thrown to whoever raised it instead of dropped.
296
+ if (phase === "idle" || phase === "stopped") {
297
+ if (outcome.kind === "failed")
298
+ throw outcome.error;
205
299
  return;
300
+ }
206
301
  this._phase = "stopped";
302
+ this.tick();
207
303
  for (const unsubscribe of this.subscriptions)
208
304
  unsubscribe();
209
305
  this.subscriptions = [];
306
+ this.input.reset();
210
307
  // A suspended app handed the terminal back when it suspended.
211
308
  if (phase === "running")
212
309
  this.leave();
213
310
  this.host.stop();
214
311
  this.settle(outcome);
215
312
  }
313
+ // --- input ---
314
+ key(press) {
315
+ if (this.quitKeys.has(keyName(press)))
316
+ return this.stop();
317
+ this.hear(this.keyHandlers, press);
318
+ }
319
+ // [LAW:dataflow-not-control-flow] One rule for every app: a wheel turn
320
+ // nothing in the app moved with goes outward. The vertical wheel reaches the
321
+ // program through `onWheel` alone, so moving something with it and claiming
322
+ // the turn are one act, and an app that reads no wheel passes every turn.
323
+ // [LAW:single-enforcer] Only a running app holds the terminal, so only a
324
+ // running app is offered a turn or passes one on.
325
+ mouse(event) {
326
+ if (!isWheelTurn(event))
327
+ return this.hear(this.mouseHandlers, event);
328
+ if (this._phase !== "running")
329
+ return;
330
+ const lines = WHEEL_LINES[event.type];
331
+ const wheel = { x: event.x, y: event.y, lines };
332
+ try {
333
+ const moved = [...this.wheelHandlers].some((handler) => this._phase === "running" && handler(wheel));
334
+ if (!moved && this._phase === "running")
335
+ this.host.passWheel(lines);
336
+ }
337
+ catch (error) {
338
+ this.fail(error);
339
+ }
340
+ }
341
+ // [LAW:no-silent-failure] The host calls these handlers, not the program,
342
+ // so one that throws ends the app with its error, as a frame that throws
343
+ // does. A handler that stops the app is the last to hear the event.
344
+ hear(handlers, event) {
345
+ try {
346
+ for (const handler of [...handlers])
347
+ if (this._phase !== "stopped")
348
+ handler(event);
349
+ }
350
+ catch (error) {
351
+ this.fail(error);
352
+ }
353
+ }
354
+ // [LAW:single-enforcer] Whether the clock is ticking, and at what rate,
355
+ // follows from the phase, what is animating and the rate, and is brought
356
+ // in line with them here alone, after every change to any — so a timer
357
+ // runs exactly while a running app has something animating, at the rate
358
+ // the app has now, and none outlives the app.
359
+ tick() {
360
+ const wanted = this._phase === "running" && this.animations.size > 0;
361
+ if (this.ticking !== null && (!wanted || this.ticking.rate.perSecond !== this._rate.perSecond)) {
362
+ this.ticking.stop();
363
+ this.ticking = null;
364
+ }
365
+ if (wanted && this.ticking === null) {
366
+ this.ticking = { rate: this._rate, stop: this.clock.every(this._rate, () => this.paint()) };
367
+ }
368
+ }
216
369
  // [LAW:no-silent-failure] A frame that throws ends the app with that
217
370
  // error: the terminal is handed back, and `run` rejects with it.
218
371
  paint() {
@@ -224,31 +377,18 @@ export class App {
224
377
  }
225
378
  }
226
379
  draw() {
380
+ // This frame draws every change made before it, a queued refresh's included.
381
+ this.refreshQueued = false;
227
382
  const options = this.console.options;
228
- const { maxWidth: cols } = options;
229
- const height = { rows: options.height.rows, exact: this.surface.exact };
230
- const lines = Segment.splitLines(this.view().render({ ...options, height }));
383
+ const height = this.painter.height(options.height.rows);
384
+ // [LAW:no-ambient-temporal-coupling] The frame owner reads the clock,
385
+ // once a frame; the view is handed the time as data.
386
+ const lines = Segment.splitLines(this.view(this.clock.now()).render({ ...options, height }));
231
387
  // The app set the budget, so the app shapes what comes back: no deeper
232
- // than the terminal — a taller frame would scroll the rows `home` counts
233
- // back over — and, as a region, exactly that deep. No row wider than the
234
- // terminal either: one that soft-wrapped would push every row below it
235
- // down a row the frame does not know about.
236
- const frame = fitHeight(lines.slice(0, height.rows), height).map((line) => Segment.adjustLineLength(line, cols, undefined, false));
237
- const painted = Math.min(this.surface.painted(frame.length, this.rows), height.rows);
238
- // Each row is erased as it is reached rather than the frame cleared
239
- // first, so no blank screen shows between two frames. The erase leads
240
- // its row: after a row that fills the width, the cursor sits on its last
241
- // cell, and an erase there would take it.
242
- const destination = this.console.destination;
243
- const body = Array.from({ length: painted }, (_, row) => ERASE_LINE + segmentsToString(frame[row] ?? [], destination)).join("\n");
244
- // Rows blanked below the frame are not the frame's: the cursor goes back
245
- // up to its last row, so the next frame and the program's next line start
246
- // from the frame's own height.
247
- const blanked = painted - Math.max(frame.length, 1);
248
- const back = blanked > 0 ? `\x1b[${blanked}A` : "";
249
- this.host.write(this.surface.home(this.rows) + body + back);
388
+ // than the terminal — a taller frame would scroll the rows the painter
389
+ // goes back over — and, as a region, exactly that deep.
390
+ const frame = this.painter.paint(() => fitHeight(lines.slice(0, height.rows), height), { rows: height.rows, cols: options.maxWidth }, this.console.destination);
250
391
  this._frame = frame;
251
- this.rows = frame.length;
252
392
  for (const handler of [...this.paintHandlers])
253
393
  handler(frame);
254
394
  }
@@ -2,4 +2,7 @@ export { BrowserTerminalHost } from "./terminal-host.js";
2
2
  export type { TerminalHost, TerminalSize, DataHandler, 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 { AppOptions, AppPhase, Surface } from "./app.js";
5
+ export type { AppMouseEvent, AppOptions, AppPhase, Wheel } from "./app.js";
6
+ export type { Surface } from "../core/paint.js";
7
+ export { keyName } from "./input.js";
8
+ export type { KeyPress, ScreenMouseEvent } from "./input.js";
@@ -1,3 +1,4 @@
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 { keyName } from "./input.js";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Terminal input: the bytes a terminal sends for keys and the pointer,
3
+ * parsed into `KeyPress` and `ScreenMouseEvent` values, and the one name
4
+ * each key goes by.
5
+ *
6
+ * [LAW:single-enforcer] Escape-sequence parsing lives only here. `App` owns
7
+ * the one parser over its host's input; a `WidgetApp` routes the events `App`
8
+ * hears, and no program reads its terminal's bytes itself.
9
+ *
10
+ * [LAW:dataflow-not-control-flow] The same parse pipeline runs for every
11
+ * input chunk: append → consume one event at a time until the buffer can't
12
+ * yield more. The byte's value, not a side-mode, decides which event is
13
+ * emitted. There is no "are we mid-sequence" mode flag — the buffer head
14
+ * carries all the state we need, so a UTF-8 character or an escape sequence
15
+ * split across two chunks is completed by the second.
16
+ *
17
+ * Lone-ESC handling: a bare `\x1b` is ambiguous (escape key vs. start of a
18
+ * CSI sequence). When the buffer is drained but ends with a lone ESC, the
19
+ * parser defers emission via setTimeout(0). The next chunk cancels the timer
20
+ * if it extends the sequence. `flush()` drains it synchronously.
21
+ */
22
+ /** A key as the terminal reported it: plain data, with no say over who hears it. */
23
+ export interface KeyPress {
24
+ readonly key: string;
25
+ /** The printable character the key types, or "" for a key that types none. */
26
+ readonly character: string;
27
+ readonly shift: boolean;
28
+ readonly ctrl: boolean;
29
+ readonly meta: boolean;
30
+ }
31
+ export type ScreenMouseEvent = {
32
+ [T in MouseEventType]: MouseEventOf<T>;
33
+ }[MouseEventType];
34
+ type MouseEventType = "mouse_down" | "mouse_up" | "mouse_move" | "scroll_up" | "scroll_down" | "scroll_left" | "scroll_right";
35
+ interface MouseEventOf<Type extends string> {
36
+ readonly type: Type;
37
+ readonly x: number;
38
+ readonly y: number;
39
+ readonly button: number;
40
+ readonly shift: boolean;
41
+ readonly ctrl: boolean;
42
+ }
43
+ /**
44
+ * The one name a key goes by — what a keymap is keyed by and what
45
+ * `quitKeys` lists. A key that types a character is named by it (`q`, `G`,
46
+ * `>`); any other is named by its key (`up`, `enter`, `space`, `f5`). Held
47
+ * modifiers come first, always in the order `ctrl+`, `meta+`, `shift+`:
48
+ * `ctrl+c`, `shift+tab`, `meta+left`. Shift is not named on a character,
49
+ * which already carries it: Shift+G is `G`.
50
+ */
51
+ export declare function keyName(press: KeyPress): string;
52
+ /** Where a parser delivers what it parses. */
53
+ export interface InputSink {
54
+ key(press: KeyPress): void;
55
+ mouse(event: ScreenMouseEvent): void;
56
+ }
57
+ /**
58
+ * `name`, if `keyName` can produce it: modifiers in its order, then a named
59
+ * key or one glyph, with no `shift+` on a glyph and, after `ctrl+`, only a
60
+ * glyph a control byte stands for.
61
+ * [LAW:parse-dont-validate] A name no key can produce throws here, at the
62
+ * one place a program hands names in, rather than binding a key that never fires.
63
+ */
64
+ export declare function parseKeyName(name: string): string;
65
+ export declare class InputParser {
66
+ private readonly sink;
67
+ private buffer;
68
+ private escTimer;
69
+ constructor(sink: InputSink);
70
+ /** Feed a chunk of bytes (or a string of them) into the parser. */
71
+ feed(chunk: Uint8Array | string): void;
72
+ /** Force any pending lone ESC out of the buffer. */
73
+ flush(): void;
74
+ /** Drop what is buffered and any pending lone ESC: the parser as constructed. */
75
+ reset(): void;
76
+ private cancelEscape;
77
+ private drain;
78
+ private consumeOne;
79
+ private consumePrintable;
80
+ private consumeEscape;
81
+ private consumeSS3;
82
+ private consumeCSI;
83
+ }
84
+ export {};