@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
@@ -1,128 +1,15 @@
1
1
  /**
2
- * EventRouter — parses raw stdin into KeyEvent / ScreenMouseEvent values
3
- * and dispatches them to widgets via a FocusManager and the frame on screen.
2
+ * EventRouter — dispatches the keys and pointer events an `App` hears to
3
+ * widgets, via a FocusManager and the frame on screen.
4
4
  *
5
- * It reads the terminal and never sets it up: raw mode and mouse tracking are
6
- * the runtime's (`App`), which undoes them on every path out.
7
- *
8
- * [LAW:single-enforcer] ANSI escape parsing lives only here — widgets never
9
- * see raw bytes. One parser, one dispatch surface.
10
- *
11
- * [LAW:dataflow-not-control-flow] The same parse pipeline runs for every
12
- * input chunk: append → consume one event at a time until the buffer can't
13
- * yield more. The byte's value, not a side-mode, decides which event is
14
- * emitted. There is no "are we mid-sequence" mode flag — the buffer head
15
- * carries all the state we need.
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
- * router defers emission via setTimeout(0). The next chunk cancels the timer
20
- * if it extends the sequence. Tests can call `flush()` to drain synchronously.
5
+ * It parses nothing and reads no terminal: `App` owns the one parser over its
6
+ * host's input (`host/input`), and `WidgetApp` hands this what the app hears.
21
7
  */
22
8
  import { KeyEvent } from "./types.js";
23
9
  import { drew, originOf, scrollTargetAt, widgetAt } from "./hit.js";
24
- // Single-byte → key name table for the trivial cases.
25
- const SINGLE_BYTE_KEYS = {
26
- 0x09: "tab",
27
- 0x0a: "enter",
28
- 0x0d: "enter",
29
- 0x1b: "escape",
30
- 0x08: "backspace",
31
- 0x7f: "backspace",
32
- 0x20: "space",
33
- };
34
- // Final-byte → key name for the simple `ESC[<X>` arrow / nav forms.
35
- const CSI_LETTER_KEYS = {
36
- A: "up",
37
- B: "down",
38
- C: "right",
39
- D: "left",
40
- H: "home",
41
- F: "end",
42
- Z: "tab", // shift+tab encodes as ESC[Z
43
- };
44
- // `ESC[<n>~` numeric forms for navigation / function keys.
45
- const CSI_TILDE_KEYS = {
46
- "1": "home",
47
- "2": "insert",
48
- "3": "delete",
49
- "4": "end",
50
- "5": "pageup",
51
- "6": "pagedown",
52
- "7": "home",
53
- "8": "end",
54
- "11": "f1",
55
- "12": "f2",
56
- "13": "f3",
57
- "14": "f4",
58
- "15": "f5",
59
- "17": "f6",
60
- "18": "f7",
61
- "19": "f8",
62
- "20": "f9",
63
- "21": "f10",
64
- "23": "f11",
65
- "24": "f12",
66
- };
67
- // SS3 form `ESC O X` for F1–F4 (and arrow keys on some terminals).
68
- const SS3_KEYS = {
69
- P: "f1",
70
- Q: "f2",
71
- R: "f3",
72
- S: "f4",
73
- A: "up",
74
- B: "down",
75
- C: "right",
76
- D: "left",
77
- H: "home",
78
- F: "end",
79
- };
80
- const ESC = 0x1b;
81
- const LBRACKET = 0x5b; // [
82
- const LANGLE = 0x3c; // <
83
- const O_BYTE = 0x4f; // O
84
- const M_UPPER = 0x4d; // M
85
- const M_LOWER = 0x6d; // m
86
- // [LAW:single-enforcer] Shared encode/decode singletons. The parser
87
- // receives `Uint8Array | string` from the host and walks bytes through
88
- // its state machine; conversion happens at the two boundaries (string
89
- // input → bytes via TextEncoder; printable-byte run → JS string via
90
- // TextDecoder) and only there. Module-level instances keep the seam
91
- // allocation-free and runtime-agnostic — both APIs are universal across
92
- // node and browser.
93
- const UTF8_ENCODER = new TextEncoder();
94
- const UTF8_DECODER = new TextDecoder("utf-8");
95
- const ASCII_DECODER = new TextDecoder("ascii");
96
- const EMPTY_BYTES = new Uint8Array(0);
97
- // Concatenate two byte arrays into a fresh Uint8Array. Replaces
98
- // `Buffer.concat([...])` so the parser holds no node-specific globals.
99
- function concatBytes(a, b) {
100
- if (a.length === 0)
101
- return b;
102
- if (b.length === 0)
103
- return a;
104
- const merged = new Uint8Array(a.length + b.length);
105
- merged.set(a, 0);
106
- merged.set(b, a.length);
107
- return merged;
108
- }
109
10
  export class EventRouter {
110
11
  source;
111
- host;
112
- // [LAW:types-are-the-program] The parse buffer is `Uint8Array`, not
113
- // `Buffer` — node's Buffer extends Uint8Array, so Buffer chunks from
114
- // process.stdin satisfy this type and the parser still works on node,
115
- // while a browser host can emit native Uint8Array without the runtime
116
- // depending on a Buffer global polyfill.
117
- buffer = EMPTY_BYTES;
118
- running = false;
119
- dataUnsubscribe;
120
- // [LAW:types-are-the-program] `setTimeout`/`clearTimeout` are universal
121
- // across node and browser; `setImmediate` is node-only. Same one-macrotask
122
- // semantics for our purpose (defer until current sync work + queued
123
- // microtasks drain), without forcing a `setImmediate` polyfill on the
124
- // browser host.
125
- escTimer;
12
+ focusOnClick;
126
13
  // [LAW:single-enforcer] Drag capture lives only here. A mouse_down's
127
14
  // target is recorded; mouse_move/mouse_up between then and the next
128
15
  // mouse_up route to it unconditionally and relative to where it is painted
@@ -141,7 +28,7 @@ export class EventRouter {
141
28
  mouseHandlers = new Set();
142
29
  constructor(options) {
143
30
  this.source = options.source;
144
- this.host = options.host;
31
+ this.focusOnClick = options.focusOnClick;
145
32
  // [LAW:single-enforcer] FocusManager owns Tab/Shift+Tab traversal —
146
33
  // register it as a normal-priority handler so it participates in the
147
34
  // chain. This MUST be the first registration so it lands ahead of any
@@ -150,37 +37,13 @@ export class EventRouter {
150
37
  // never runs for that event.
151
38
  this.onKey((event) => this.source.focusManager.handleKey(event));
152
39
  }
153
- // --- Lifecycle ---
154
- start() {
155
- if (this.running)
156
- return;
157
- this.running = true;
158
- // Subscribing starts the underlying stream flowing on node, and is all
159
- // it takes on a transport with no paused mode.
160
- this.dataUnsubscribe = this.host.onData((chunk) => this.feed(chunk));
161
- }
162
- stop() {
163
- if (this.running) {
164
- this.running = false;
165
- if (this.dataUnsubscribe) {
166
- this.dataUnsubscribe();
167
- this.dataUnsubscribe = undefined;
168
- }
169
- }
170
- // [LAW:one-source-of-truth] Per-session state belongs to one session.
171
- // stop() leaves the router in the same shape a fresh construction
172
- // would: empty parse buffer, no captured widget, nothing hovered, no
173
- // pending ESC timer.
174
- // All cleanup is outside the `running` guard because feed() can arm
175
- // escTimer (and grow buffer) even when running is false — without
176
- // those, a pre-start feed could leave a deferred escape firing into
177
- // the next session. Clearing already-empty state is a no-op so this
178
- // stays safe on repeated stop().
179
- if (this.escTimer) {
180
- clearTimeout(this.escTimer);
181
- this.escTimer = undefined;
182
- }
183
- this.buffer = EMPTY_BYTES;
40
+ /**
41
+ * Drop what a run left behind — a captured drag, the pointer, hover — so the
42
+ * router is in the shape a fresh construction would be.
43
+ *
44
+ * [LAW:one-source-of-truth] Per-session state belongs to one session.
45
+ */
46
+ reset() {
184
47
  this.captured = null;
185
48
  this.pointer = null;
186
49
  this.hover(null);
@@ -213,258 +76,38 @@ export class EventRouter {
213
76
  this.mouseHandlers.add(handler);
214
77
  return () => this.mouseHandlers.delete(handler);
215
78
  }
216
- // --- Test / advanced API ---
217
- /** Feed a chunk of bytes (or a string of bytes) into the parser. */
218
- feed(chunk) {
219
- const next = typeof chunk === "string" ? UTF8_ENCODER.encode(chunk) : chunk;
220
- this.buffer = concatBytes(this.buffer, next);
221
- if (this.escTimer) {
222
- clearTimeout(this.escTimer);
223
- this.escTimer = undefined;
224
- }
225
- this.drain();
226
- // [LAW:dataflow-not-control-flow] If a lone ESC remains, defer emission
227
- // until the next macrotask so a follow-up chunk can extend it. The
228
- // explicit `0` is structural deferral, not a throttle — it just lets
229
- // the current sync work + queued microtasks drain before flushing.
230
- if (this.buffer.length === 1 && this.buffer[0] === ESC) {
231
- this.escTimer = setTimeout(() => {
232
- this.escTimer = undefined;
233
- this.flush();
234
- }, 0);
235
- }
236
- }
237
- /** Force any pending lone ESC out of the buffer. */
238
- flush() {
239
- if (this.escTimer) {
240
- clearTimeout(this.escTimer);
241
- this.escTimer = undefined;
242
- }
243
- if (this.buffer.length > 0 && this.buffer[0] === ESC && this.buffer.length === 1) {
244
- this.buffer = this.buffer.subarray(1);
245
- this.dispatchKey(new KeyEvent({ key: "escape", character: "", shift: false, ctrl: false, meta: false }));
246
- }
79
+ /** Route a key the app heard. */
80
+ key(press) {
81
+ this.dispatchKey(new KeyEvent(press));
247
82
  }
248
- // --- Internals ---
249
- drain() {
250
- while (this.buffer.length > 0) {
251
- const result = this.consumeOne(this.buffer);
252
- if (result.kind === "incomplete")
253
- return;
254
- this.buffer = this.buffer.subarray(result.bytes);
255
- if (result.kind === "key")
256
- this.dispatchKey(result.event);
257
- else if (result.kind === "mouse")
258
- this.dispatchMouse(result.event);
259
- // skip → drop bytes silently
260
- }
83
+ /** Route a pointer event the app heard. */
84
+ mouse(event) {
85
+ this.dispatchMouse(event);
261
86
  }
262
- consumeOne(buf) {
263
- const b0 = buf[0];
264
- if (b0 === ESC)
265
- return this.consumeEscape(buf);
266
- // Ctrl+C
267
- if (b0 === 0x03) {
268
- return {
269
- kind: "key",
270
- bytes: 1,
271
- event: new KeyEvent({ key: "c", character: "", shift: false, ctrl: true, meta: false }),
272
- };
273
- }
274
- // Named single-byte keys (tab/enter/space/backspace).
275
- const named = SINGLE_BYTE_KEYS[b0];
276
- if (named && named !== "escape") {
277
- return {
278
- kind: "key",
279
- bytes: 1,
280
- event: new KeyEvent({
281
- key: named,
282
- character: named === "space" ? " " : "",
283
- shift: false,
284
- ctrl: false,
285
- meta: false,
286
- }),
287
- };
288
- }
289
- // Ctrl+letter (Ctrl+A=0x01 .. Ctrl+Z=0x1a), excluding the bytes already
290
- // claimed by SINGLE_BYTE_KEYS above (tab=0x09, enter=0x0a/0x0d, backspace=0x08).
291
- if (b0 < 0x20) {
292
- const letter = String.fromCharCode(b0 + 0x60); // 0x01 → 'a'
293
- return {
294
- kind: "key",
295
- bytes: 1,
296
- event: new KeyEvent({ key: letter, character: "", shift: false, ctrl: true, meta: false }),
297
- };
298
- }
299
- // Printable ASCII / start of a UTF-8 multibyte character.
300
- return this.consumePrintable(buf);
301
- }
302
- consumePrintable(buf) {
303
- const b0 = buf[0];
304
- let len = 1;
305
- if (b0 >= 0xc0 && b0 < 0xe0)
306
- len = 2;
307
- else if (b0 >= 0xe0 && b0 < 0xf0)
308
- len = 3;
309
- else if (b0 >= 0xf0)
310
- len = 4;
311
- if (buf.length < len)
312
- return { kind: "incomplete" };
313
- const character = UTF8_DECODER.decode(buf.subarray(0, len));
314
- return {
315
- kind: "key",
316
- bytes: len,
317
- event: new KeyEvent({
318
- key: character.toLowerCase(),
319
- character,
320
- shift: character.length === 1 && character !== character.toLowerCase(),
321
- ctrl: false,
322
- meta: false,
323
- }),
324
- };
325
- }
326
- consumeEscape(buf) {
327
- if (buf.length === 1)
328
- return { kind: "incomplete" };
329
- const b1 = buf[1];
330
- // ESC [ ... → CSI
331
- if (b1 === LBRACKET)
332
- return this.consumeCSI(buf);
333
- // ESC O X → SS3 (F1–F4 / arrows on some terms)
334
- if (b1 === O_BYTE)
335
- return this.consumeSS3(buf);
336
- // ESC ESC → escape key (some terminals double-send). Consume BOTH bytes
337
- // so the user sees one escape event, not two. Without this, the second
338
- // ESC remained in the buffer and produced a second escape via the
339
- // lone-ESC flush path.
340
- if (b1 === ESC) {
341
- return {
342
- kind: "key",
343
- bytes: 2,
344
- event: new KeyEvent({ key: "escape", character: "", shift: false, ctrl: false, meta: false }),
345
- };
346
- }
347
- // ESC <named> / ESC <printable> → Alt-modified key.
348
- // Terminals encode Alt+<key> as the literal byte preceded by ESC, so
349
- // `ESC b` is Alt+B, `ESC <0x7f>` is Alt+Backspace, `ESC <0x20>` is
350
- // Alt+Space, etc. The router surfaces this as `{ key, meta: true }` —
351
- // matching the shape used for Ctrl-modified keys, so handlers can
352
- // branch on `event.meta` without parsing escape sequences themselves.
353
- //
354
- // [LAW:one-source-of-truth] Named single-byte keys (space, tab,
355
- // enter, backspace) use the SAME canonical key names on the Alt path
356
- // as on the non-Alt path. Without this, `Alt+Space` decoded as
357
- // `key: " "` while a bare space decoded as `key: "space"`, so a
358
- // handler couldn't match Alt+Space via `event.key === "space" &&
359
- // event.meta`. Routing through SINGLE_BYTE_KEYS first preserves the
360
- // canonical naming.
361
- //
362
- // [LAW:one-source-of-truth] Modifier decoding for *all* navigation keys
363
- // lives in this file: CSI param-style modifiers (e.g. `ESC[1;3D` for
364
- // Alt+Left) flow through `decodeModifier`, and ESC-prefixed Alt forms
365
- // flow through this branch. Widgets read `event.meta` and never re-parse.
366
- const namedAlt = SINGLE_BYTE_KEYS[b1];
367
- if (namedAlt && namedAlt !== "escape") {
368
- return {
369
- kind: "key",
370
- bytes: 2,
371
- event: new KeyEvent({
372
- key: namedAlt,
373
- character: "",
374
- shift: false,
375
- ctrl: false,
376
- meta: true,
377
- }),
378
- };
379
- }
380
- if (b1 >= 0x20 && b1 <= 0x7e) {
381
- const ch = String.fromCharCode(b1);
382
- return {
383
- kind: "key",
384
- bytes: 2,
385
- event: new KeyEvent({
386
- key: ch.toLowerCase(),
387
- character: "",
388
- shift: ch !== ch.toLowerCase(),
389
- ctrl: false,
390
- meta: true,
391
- }),
392
- };
393
- }
394
- // Truly unrecognised follow-up byte → treat the ESC as a lone escape
395
- // and re-process from b1 on the next pass.
396
- return {
397
- kind: "key",
398
- bytes: 1,
399
- event: new KeyEvent({ key: "escape", character: "", shift: false, ctrl: false, meta: false }),
400
- };
401
- }
402
- consumeSS3(buf) {
403
- if (buf.length < 3)
404
- return { kind: "incomplete" };
405
- const final = String.fromCharCode(buf[2]);
406
- const name = SS3_KEYS[final];
407
- if (!name)
408
- return { kind: "skip", bytes: 3 };
409
- return {
410
- kind: "key",
411
- bytes: 3,
412
- event: new KeyEvent({ key: name, character: "", shift: false, ctrl: false, meta: false }),
413
- };
414
- }
415
- consumeCSI(buf) {
416
- // buf starts with ESC [
417
- const next = buf[2];
418
- if (next === undefined)
419
- return { kind: "incomplete" };
420
- // X10 mouse: ESC [ M b x y (3 raw bytes after M)
421
- if (next === M_UPPER) {
422
- if (buf.length < 6)
423
- return { kind: "incomplete" };
424
- const cb = buf[3] - 32;
425
- const col = buf[4] - 32 - 1;
426
- const row = buf[5] - 32 - 1;
427
- return { kind: "mouse", bytes: 6, event: decodeMouseFromCb(cb, col, row, true) };
428
- }
429
- // SGR mouse: ESC [ < cb ; x ; y M|m
430
- if (next === LANGLE) {
431
- // Find terminator M or m.
432
- for (let i = 3; i < buf.length; i++) {
433
- const b = buf[i];
434
- if (b === M_UPPER || b === M_LOWER) {
435
- const params = ASCII_DECODER.decode(buf.subarray(3, i));
436
- const parts = params.split(";");
437
- if (parts.length !== 3)
438
- return { kind: "skip", bytes: i + 1 };
439
- const cb = parseInt(parts[0], 10);
440
- const col = parseInt(parts[1], 10) - 1;
441
- const row = parseInt(parts[2], 10) - 1;
442
- if (Number.isNaN(cb) || Number.isNaN(col) || Number.isNaN(row)) {
443
- return { kind: "skip", bytes: i + 1 };
444
- }
445
- return {
446
- kind: "mouse",
447
- bytes: i + 1,
448
- event: decodeMouseFromCb(cb, col, row, b === M_UPPER),
449
- };
450
- }
451
- }
452
- return { kind: "incomplete" };
453
- }
454
- // Generic CSI: collect parameter / intermediate bytes until a final byte
455
- // in the range 0x40–0x7e is found.
456
- let params = "";
457
- for (let i = 2; i < buf.length; i++) {
458
- const b = buf[i];
459
- if (b >= 0x40 && b <= 0x7e) {
460
- const final = String.fromCharCode(b);
461
- return decodeCSI(params, final, i + 1);
462
- }
463
- params += String.fromCharCode(b);
464
- }
465
- return { kind: "incomplete" };
87
+ /**
88
+ * Scroll the innermost viewport under the pointer that can move, a line a
89
+ * notch, past any widget it shows there and whatever a drag captured.
90
+ * Whether one moved is the answer: with none, the app passes the wheel
91
+ * outward.
92
+ */
93
+ wheel({ x, y, lines }) {
94
+ this.point(x, y);
95
+ const target = scrollTargetAt(this.source.frame, x, y, lines);
96
+ if (!target)
97
+ return false;
98
+ target.scrollBy(lines);
99
+ this.source.refresh();
100
+ return true;
466
101
  }
467
102
  // --- Dispatch ---
103
+ // [LAW:single-enforcer] Every pointer report, the wheel's included, says
104
+ // where the pointer is, so hover is read again from wherever it last was.
105
+ point(x, y) {
106
+ const hit = widgetAt(this.source.frame, x, y);
107
+ this.pointer = { x, y };
108
+ this.hover(hit?.widget ?? null);
109
+ return hit;
110
+ }
468
111
  // [LAW:dataflow-not-control-flow] Three-stage walk; same shape every
469
112
  // dispatch, only the event's `stopped` flag short-circuits later stages.
470
113
  // The router holds NO key-specific policy — Tab semantics live entirely
@@ -503,21 +146,18 @@ export class EventRouter {
503
146
  for (const handler of this.mouseHandlers)
504
147
  handler(event);
505
148
  const frame = this.source.frame;
506
- const hit = widgetAt(frame, event.x, event.y);
507
- this.pointer = { x: event.x, y: event.y };
508
- this.hover(hit?.widget ?? null);
509
- // The wheel scrolls the innermost viewport under the pointer that can
510
- // move, a line a notch, past any widget it shows there and whatever a
511
- // drag captured. With none, nothing moved and there is nothing to paint.
512
- if (event.type === "scroll_up" || event.type === "scroll_down") {
513
- const lines = event.type === "scroll_down" ? 1 : -1;
514
- const target = scrollTargetAt(frame, event.x, event.y, lines);
515
- if (!target)
516
- return;
517
- target.scrollBy(lines);
518
- this.source.refresh();
149
+ const hit = this.point(event.x, event.y);
150
+ // After the program's handlers, so one that reads focus sees it as it was;
151
+ // before delivery, so the pressed widget has focus when it hears the
152
+ // press. Only the left button: another may open the terminal's own menu.
153
+ // `focus` passes over a widget that cannot take it.
154
+ if (this.focusOnClick && event.type === "mouse_down" && event.button === 0 && hit) {
155
+ this.source.focusManager.focus(hit.widget);
156
+ }
157
+ // The wheel is `wheel`'s, which `App` hands the vertical turns; nothing
158
+ // scrolls by columns.
159
+ if (event.type === "scroll_left" || event.type === "scroll_right")
519
160
  return;
520
- }
521
161
  // [LAW:dataflow-not-control-flow] Same pipeline every event; the
522
162
  // capture value and event type pick the target. mouse_down opens a
523
163
  // capture, mouse_up closes it, mouse_move in between routes to the
@@ -556,74 +196,3 @@ function relocate(frame, target) {
556
196
  const origin = originOf(frame, target.widget);
557
197
  return origin ? { widget: target.widget, originX: origin.x, originY: origin.y } : target;
558
198
  }
559
- function decodeMouseFromCb(cb, x, y, isPress) {
560
- const isMotion = (cb & 32) !== 0;
561
- const isScroll = (cb & 64) !== 0;
562
- const button = cb & 3;
563
- const shift = !!(cb & 4);
564
- const ctrl = !!(cb & 16); // bit 16 = ctrl in xterm encoding
565
- if (isScroll) {
566
- const type = button === 0 ? "scroll_up" : "scroll_down";
567
- return { type, x, y, button: 0, shift, ctrl };
568
- }
569
- if (isMotion) {
570
- return { type: "mouse_move", x, y, button, shift, ctrl };
571
- }
572
- return {
573
- type: isPress ? "mouse_down" : "mouse_up",
574
- x,
575
- y,
576
- button,
577
- shift,
578
- ctrl,
579
- };
580
- }
581
- function decodeCSI(params, final, bytes) {
582
- // Handle modifier suffixes like `1;2A` (shift+up). We split params on `;`.
583
- const parts = params.split(";");
584
- const first = parts[0] ?? "";
585
- const mod = parseInt(parts[1] ?? "", 10);
586
- const { shift, ctrl, meta } = decodeModifier(mod);
587
- if (final === "~") {
588
- const name = CSI_TILDE_KEYS[first];
589
- if (!name)
590
- return { kind: "skip", bytes };
591
- return {
592
- kind: "key",
593
- bytes,
594
- event: new KeyEvent({ key: name, character: "", shift, ctrl, meta }),
595
- };
596
- }
597
- const name = CSI_LETTER_KEYS[final];
598
- if (!name)
599
- return { kind: "skip", bytes };
600
- // Special case: ESC[Z is shift+tab regardless of explicit modifier.
601
- // [LAW:one-source-of-truth] `character` is the printable character the
602
- // key would produce, or "" for non-printable / modifier-only events.
603
- // The non-shifted tab path emits character: "" (Tab is named, not
604
- // printed) — keep shift+tab consistent. Distinguish via key === "tab"
605
- // + shift, not character.
606
- if (final === "Z") {
607
- return {
608
- kind: "key",
609
- bytes,
610
- event: new KeyEvent({ key: "tab", character: "", shift: true, ctrl: false, meta: false }),
611
- };
612
- }
613
- return {
614
- kind: "key",
615
- bytes,
616
- event: new KeyEvent({ key: name, character: "", shift, ctrl, meta }),
617
- };
618
- }
619
- function decodeModifier(mod) {
620
- if (!Number.isFinite(mod) || mod <= 1) {
621
- return { shift: false, ctrl: false, meta: false };
622
- }
623
- const bits = mod - 1;
624
- return {
625
- shift: (bits & 1) !== 0,
626
- meta: (bits & 2) !== 0,
627
- ctrl: (bits & 4) !== 0,
628
- };
629
- }
@@ -1,9 +1,10 @@
1
- export type { KeyEventInit, KeyHandlerPriority, KeyHandlerOptions, ScreenMouseEvent, WidgetMouseEvent, WidgetFocusEvent, InteractiveWidget, OverlayRenderable, FocusManager, } from "./types.js";
1
+ export type { KeyHandlerPriority, KeyHandlerOptions, WidgetMouseEvent, WidgetFocusEvent, InteractiveWidget, OverlayRenderable, FocusManager, } from "./types.js";
2
2
  export { hasOverlay, KeyEvent } from "./types.js";
3
3
  export { StaticItem } from "./static-item.js";
4
4
  export type { StaticItemOptions } from "./static-item.js";
5
5
  export { WidgetBase } from "./widget-base.js";
6
6
  export { WidgetApp } from "./widget-app.js";
7
+ export type { WidgetAppOptions } from "./widget-app.js";
7
8
  export { Button } from "./button.js";
8
9
  export type { ButtonVariant, ButtonOptions } from "./button.js";
9
10
  export { Checkbox } from "./checkbox.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ink — the colour pair a widget cell is drawn in: a foreground and the ground
3
+ * it stands on, both from one theme's palette.
4
+ *
5
+ * A palette's colours are chosen against that palette's own background. A
6
+ * foreground drawn over whatever the terminal shows is readable only when the
7
+ * terminal happens to match the theme — the default theme's white text
8
+ * vanished into a light terminal. With both halves taken from the palette, a
9
+ * widget looks the same on every terminal, so it is readable on all of them
10
+ * exactly when its theme is readable against itself.
11
+ *
12
+ * [LAW:types-are-the-program] There is no call that takes a foreground alone,
13
+ * so a widget cannot draw half a pair.
14
+ * [LAW:single-enforcer] Every widget resolves palette keys here, and this is
15
+ * where a key the palette lacks, or one that draws no colour (a theme's
16
+ * fully transparent "no background"), is reported.
17
+ */
18
+ import { ColorSpec, type TerminalTheme } from "../core/color.js";
19
+ export declare function ink(theme: TerminalTheme, fg: string, bg: string): {
20
+ color: ColorSpec;
21
+ bgcolor: ColorSpec;
22
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ink — the colour pair a widget cell is drawn in: a foreground and the ground
3
+ * it stands on, both from one theme's palette.
4
+ *
5
+ * A palette's colours are chosen against that palette's own background. A
6
+ * foreground drawn over whatever the terminal shows is readable only when the
7
+ * terminal happens to match the theme — the default theme's white text
8
+ * vanished into a light terminal. With both halves taken from the palette, a
9
+ * widget looks the same on every terminal, so it is readable on all of them
10
+ * exactly when its theme is readable against itself.
11
+ *
12
+ * [LAW:types-are-the-program] There is no call that takes a foreground alone,
13
+ * so a widget cannot draw half a pair.
14
+ * [LAW:single-enforcer] Every widget resolves palette keys here, and this is
15
+ * where a key the palette lacks, or one that draws no colour (a theme's
16
+ * fully transparent "no background"), is reported.
17
+ */
18
+ import { ColorSpec } from "../core/color.js";
19
+ export function ink(theme, fg, bg) {
20
+ return { color: paletteColor(theme, fg), bgcolor: paletteColor(theme, bg) };
21
+ }
22
+ function paletteColor(theme, key) {
23
+ const rgba = theme.palette.get(key);
24
+ if (rgba === undefined) {
25
+ throw new RangeError(`palette ${JSON.stringify(theme.palette.name)} has no ${JSON.stringify(key)}`);
26
+ }
27
+ if (rgba.alpha === 0) {
28
+ throw new RangeError(`palette ${JSON.stringify(theme.palette.name)}: ${JSON.stringify(key)} is fully transparent, so it cannot be half of a widget's colour pair`);
29
+ }
30
+ return ColorSpec.fromRgba(rgba);
31
+ }