@promptctl/rich-js 0.17.0 → 0.18.1

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 (147) hide show
  1. package/README.md +13 -26
  2. package/dist/core/anchor.d.ts +45 -0
  3. package/dist/core/anchor.js +39 -0
  4. package/dist/core/ansi.d.ts +65 -0
  5. package/dist/core/ansi.js +231 -0
  6. package/dist/core/box.d.ts +0 -1
  7. package/dist/core/box.js +6 -18
  8. package/dist/core/cells.d.ts +16 -5
  9. package/dist/core/cells.js +31 -10
  10. package/dist/core/color.d.ts +4 -8
  11. package/dist/core/color.js +28 -19
  12. package/dist/core/console.d.ts +13 -4
  13. package/dist/core/console.js +91 -36
  14. package/dist/core/export-html.d.ts +40 -5
  15. package/dist/core/export-html.js +111 -17
  16. package/dist/core/markup.d.ts +27 -5
  17. package/dist/core/markup.js +282 -170
  18. package/dist/core/measure.d.ts +1 -1
  19. package/dist/core/measure.js +2 -1
  20. package/dist/core/place.d.ts +30 -0
  21. package/dist/core/place.js +51 -0
  22. package/dist/core/pretty.d.ts +3 -0
  23. package/dist/core/pretty.js +5 -1
  24. package/dist/core/protocol.d.ts +136 -6
  25. package/dist/core/protocol.js +64 -2
  26. package/dist/core/render.d.ts +2 -2
  27. package/dist/core/render.js +2 -2
  28. package/dist/core/segment.d.ts +23 -4
  29. package/dist/core/segment.js +75 -16
  30. package/dist/core/spinnerData.d.ts +2 -0
  31. package/dist/core/spinnerData.js +3 -1
  32. package/dist/core/strip.d.ts +3 -7
  33. package/dist/core/strip.js +32 -35
  34. package/dist/core/style.d.ts +25 -0
  35. package/dist/core/style.js +61 -89
  36. package/dist/core/text.d.ts +16 -15
  37. package/dist/core/text.js +62 -76
  38. package/dist/host/app.d.ts +121 -0
  39. package/dist/host/app.js +255 -0
  40. package/dist/host/host-environment.d.ts +26 -0
  41. package/dist/host/host-environment.js +44 -0
  42. package/dist/host/index.d.ts +4 -2
  43. package/dist/host/index.js +2 -1
  44. package/dist/host/terminal-host.d.ts +43 -7
  45. package/dist/host/terminal-host.js +57 -3
  46. package/dist/index.d.ts +7 -3
  47. package/dist/index.js +4 -1
  48. package/dist/node/terminal-host.d.ts +31 -2
  49. package/dist/node/terminal-host.js +69 -2
  50. package/dist/node/traceback.d.ts +16 -6
  51. package/dist/node/traceback.js +30 -8
  52. package/dist/renderables/align.d.ts +2 -2
  53. package/dist/renderables/align.js +6 -19
  54. package/dist/renderables/columns.js +4 -20
  55. package/dist/renderables/embed.d.ts +50 -0
  56. package/dist/renderables/embed.js +71 -0
  57. package/dist/renderables/group.js +3 -1
  58. package/dist/renderables/layout.js +31 -34
  59. package/dist/renderables/live.d.ts +6 -3
  60. package/dist/renderables/live.js +89 -45
  61. package/dist/renderables/markdown.d.ts +1 -1
  62. package/dist/renderables/markdown.js +6 -4
  63. package/dist/renderables/padding.js +3 -2
  64. package/dist/renderables/panel.js +26 -41
  65. package/dist/renderables/progress.js +5 -8
  66. package/dist/renderables/progressBar.js +6 -5
  67. package/dist/renderables/rule.d.ts +3 -2
  68. package/dist/renderables/rule.js +9 -9
  69. package/dist/renderables/spinner.d.ts +5 -3
  70. package/dist/renderables/spinner.js +14 -8
  71. package/dist/renderables/syntax.d.ts +1 -1
  72. package/dist/renderables/syntax.js +2 -1
  73. package/dist/renderables/table.d.ts +26 -14
  74. package/dist/renderables/table.js +239 -141
  75. package/dist/renderables/traceback.js +8 -9
  76. package/dist/renderables/tree.d.ts +17 -3
  77. package/dist/renderables/tree.js +62 -71
  78. package/dist/renderables/viewport.d.ts +114 -0
  79. package/dist/renderables/viewport.js +198 -0
  80. package/dist/template-bindings/color-funcs.js +11 -13
  81. package/dist/template-bindings/index.js +4 -2
  82. package/dist/template-bindings/palette-funcs.js +6 -16
  83. package/dist/template-bindings/style-funcs.js +4 -0
  84. package/dist/themes/data/ansi-tables.d.ts +14 -0
  85. package/dist/themes/data/ansi-tables.js +49 -0
  86. package/dist/themes/data/atom-one-dark.js +19 -0
  87. package/dist/themes/data/atom-one-light.js +19 -0
  88. package/dist/themes/data/catppuccin-frappe.js +19 -0
  89. package/dist/themes/data/catppuccin-latte.js +19 -0
  90. package/dist/themes/data/catppuccin-macchiato.js +19 -0
  91. package/dist/themes/data/catppuccin-mocha.js +19 -0
  92. package/dist/themes/data/cyberpunk.js +2 -0
  93. package/dist/themes/data/default.js +2 -0
  94. package/dist/themes/data/dracula.js +19 -0
  95. package/dist/themes/data/flexoki.js +19 -0
  96. package/dist/themes/data/gruvbox.js +19 -0
  97. package/dist/themes/data/monokai.js +19 -0
  98. package/dist/themes/data/nord.js +19 -0
  99. package/dist/themes/data/rose-pine-dawn.js +19 -0
  100. package/dist/themes/data/rose-pine-moon.js +19 -0
  101. package/dist/themes/data/rose-pine.js +19 -0
  102. package/dist/themes/data/solarized-dark.js +19 -0
  103. package/dist/themes/data/solarized-light.js +19 -0
  104. package/dist/themes/data/svg-export.js +19 -0
  105. package/dist/themes/data/textual-ansi.js +2 -0
  106. package/dist/themes/data/textual-dark.js +2 -0
  107. package/dist/themes/data/textual-light.js +2 -0
  108. package/dist/themes/data/tokyo-night.js +19 -0
  109. package/dist/themes/data/types.d.ts +16 -0
  110. package/dist/themes/data/types.js +10 -1
  111. package/dist/themes/registry.d.ts +3 -2
  112. package/dist/themes/registry.js +13 -1
  113. package/dist/themes/terminalThemes.d.ts +2 -7
  114. package/dist/themes/terminalThemes.js +4 -9
  115. package/dist/widgets/button.d.ts +1 -1
  116. package/dist/widgets/button.js +4 -2
  117. package/dist/widgets/checkbox.d.ts +2 -2
  118. package/dist/widgets/checkbox.js +5 -3
  119. package/dist/widgets/dropdown.d.ts +2 -2
  120. package/dist/widgets/dropdown.js +13 -16
  121. package/dist/widgets/event-router.d.ts +21 -28
  122. package/dist/widgets/event-router.js +80 -73
  123. package/dist/widgets/focus-manager.d.ts +25 -6
  124. package/dist/widgets/focus-manager.js +63 -65
  125. package/dist/widgets/hit.d.ts +64 -0
  126. package/dist/widgets/hit.js +113 -0
  127. package/dist/widgets/index.d.ts +5 -7
  128. package/dist/widgets/index.js +3 -4
  129. package/dist/widgets/slider.d.ts +2 -2
  130. package/dist/widgets/slider.js +7 -7
  131. package/dist/widgets/static-item.d.ts +9 -15
  132. package/dist/widgets/static-item.js +9 -15
  133. package/dist/widgets/text-input.d.ts +2 -2
  134. package/dist/widgets/text-input.js +17 -14
  135. package/dist/widgets/toggle.d.ts +1 -1
  136. package/dist/widgets/toggle.js +3 -2
  137. package/dist/widgets/types.d.ts +14 -35
  138. package/dist/widgets/types.js +0 -1
  139. package/dist/widgets/widget-app.d.ts +63 -0
  140. package/dist/widgets/widget-app.js +199 -0
  141. package/dist/widgets/widget-base.d.ts +4 -6
  142. package/dist/widgets/widget-base.js +22 -30
  143. package/package.json +12 -4
  144. package/dist/host/host-stream.d.ts +0 -30
  145. package/dist/host/host-stream.js +0 -35
  146. package/dist/widgets/screen.d.ts +0 -98
  147. package/dist/widgets/screen.js +0 -530
@@ -11,5 +11,21 @@
11
11
  export interface ThemePaletteData {
12
12
  readonly name: string;
13
13
  readonly dark: boolean;
14
+ /**
15
+ * The sixteen colours a terminal showing this theme draws `red`, `blue`,
16
+ * `bright_black` and the rest in. A theme without its own table would draw
17
+ * them in the VGA defaults (`blue` as #000080), which belong to no theme.
18
+ */
19
+ readonly ansi: AnsiColorsData;
14
20
  readonly vars: Readonly<Record<string, string>>;
15
21
  }
22
+ /**
23
+ * ANSI colour numbers 0–15 in order: the eight normal colours, then their
24
+ * bright forms. Named rather than positional so a data file cannot shift a
25
+ * colour into its neighbour's slot by miscounting.
26
+ */
27
+ export declare const ANSI_SLOTS: readonly ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"];
28
+ /** One `#RRGGBB` per ANSI colour number, keyed by `ANSI_SLOTS`. */
29
+ export type AnsiColorsData = {
30
+ readonly [K in (typeof ANSI_SLOTS)[number]]: string;
31
+ };
@@ -1 +1,10 @@
1
- export {};
1
+ /**
2
+ * ANSI colour numbers 0–15 in order: the eight normal colours, then their
3
+ * bright forms. Named rather than positional so a data file cannot shift a
4
+ * colour into its neighbour's slot by miscounting.
5
+ */
6
+ export const ANSI_SLOTS = [
7
+ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
8
+ "brightBlack", "brightRed", "brightGreen", "brightYellow",
9
+ "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
10
+ ];
@@ -1,4 +1,4 @@
1
- import { ColorRgba } from "../core/color.js";
1
+ import { ColorRgba, ColorTable } from "../core/color.js";
2
2
  import { Palette } from "./palette.js";
3
3
  import { type ThemeName } from "./data/index.js";
4
4
  export type { ThemeName };
@@ -17,7 +17,7 @@ export declare function listThemePalettes(): readonly ThemeName[];
17
17
  export declare function getThemePalette(name: ThemeName): Palette;
18
18
  export declare function getThemePalette(name: string): Palette | null;
19
19
  /**
20
- * The eight base colors every theme must declare. Returned by
20
+ * The eight base colors every theme must declare, and its ANSI table. Returned by
21
21
  * `getThemeBaseColors` for callers (notably `terminalThemes.ts`) that only
22
22
  * need the substrate and would otherwise pay the cost of hydrating the
23
23
  * full ~150-var palette and polluting the registry cache.
@@ -33,6 +33,7 @@ export interface ThemeBaseColors {
33
33
  readonly success: ColorRgba;
34
34
  readonly warning: ColorRgba;
35
35
  readonly error: ColorRgba;
36
+ readonly ansi: ColorTable;
36
37
  }
37
38
  /**
38
39
  * Read just the eight base colors from a theme's data file. Cheap — parses
@@ -1,6 +1,7 @@
1
- import { parseRgbHex, parseRgbaHex } from "../core/color.js";
1
+ import { ColorTable, parseRgbHex, parseRgbaHex } from "../core/color.js";
2
2
  import { Palette } from "./palette.js";
3
3
  import { THEMES } from "./data/index.js";
4
+ import { ANSI_SLOTS } from "./data/types.js";
4
5
  const THEME_NAMES = Object.freeze(Object.keys(THEMES).sort());
5
6
  const cache = new Map();
6
7
  /**
@@ -41,6 +42,7 @@ export function getThemeBaseColors(name) {
41
42
  success: requireBaseVar(data, "success"),
42
43
  warning: requireBaseVar(data, "warning"),
43
44
  error: requireBaseVar(data, "error"),
45
+ ansi: new ColorTable(ANSI_SLOTS.map((slot) => parseOpaqueHex(data.ansi[slot], data.name, `ansi.${slot}`))),
44
46
  };
45
47
  }
46
48
  function requireBaseVar(data, key) {
@@ -72,6 +74,16 @@ function hydrate(data) {
72
74
  // the untrusted side (authored data files) and the place that has to
73
75
  // reject `"0G"`, `""`, `"#GGGGGG"`, etc.
74
76
  const HEX_RE = /^[0-9a-fA-F]+$/;
77
+ /**
78
+ * An ANSI slot is a colour a terminal paints, never a blend, so it takes no
79
+ * alpha: `flattenAlpha` leaves a STANDARD spec alone and would pass one through.
80
+ */
81
+ function parseOpaqueHex(value, theme, key) {
82
+ if (!/^#[0-9A-Fa-f]{6}$/.test(value)) {
83
+ throw new Error(`Theme ${theme}: ${key} has invalid hex ${JSON.stringify(value)} (expected #RRGGBB)`);
84
+ }
85
+ return parseRgbHex(value.slice(1));
86
+ }
75
87
  function parseHex(value, theme, key) {
76
88
  const hex = value.startsWith("#") ? value.slice(1) : value;
77
89
  if ((hex.length !== 6 && hex.length !== 8) || !HEX_RE.test(hex)) {
@@ -11,8 +11,8 @@
11
11
  *
12
12
  * [LAW:one-source-of-truth] Hex values live in `data/<name>.ts`. This
13
13
  * module is a derived view that builds a `TerminalTheme` from each
14
- * theme's eight base colors via `buildPalette` for the substrate palette
15
- * and `STANDARD_TABLE` for the ANSI substrate.
14
+ * theme's eight base colors via `buildPalette` for the substrate palette,
15
+ * and from the theme's own ANSI table for the sixteen named colours.
16
16
  *
17
17
  * [LAW:one-type-per-behavior] Every theme has the same shape — name,
18
18
  * `dark` flag, full Palette in the registry, `TerminalTheme` constant
@@ -22,11 +22,6 @@
22
22
  * [LAW:one-way-deps] `core/color → themes/palette` remains the only edge
23
23
  * into themes/. This file imports only sibling theme modules and core
24
24
  * color primitives; nothing in `core/` imports back.
25
- *
26
- * Note on `ansiColors`: every preset theme uses `STANDARD_TABLE`. Theme
27
- * identity lives in the semantic `palette`, not in the standard ANSI
28
- * 16/256 LUT — `ColorSpec.parse("red")` is intentionally canonical across
29
- * themes, matching Textual's model.
30
25
  */
31
26
  import { TerminalTheme } from "../core/color.js";
32
27
  export declare const DEFAULT_TERMINAL_THEME: TerminalTheme;
@@ -11,8 +11,8 @@
11
11
  *
12
12
  * [LAW:one-source-of-truth] Hex values live in `data/<name>.ts`. This
13
13
  * module is a derived view that builds a `TerminalTheme` from each
14
- * theme's eight base colors via `buildPalette` for the substrate palette
15
- * and `STANDARD_TABLE` for the ANSI substrate.
14
+ * theme's eight base colors via `buildPalette` for the substrate palette,
15
+ * and from the theme's own ANSI table for the sixteen named colours.
16
16
  *
17
17
  * [LAW:one-type-per-behavior] Every theme has the same shape — name,
18
18
  * `dark` flag, full Palette in the registry, `TerminalTheme` constant
@@ -22,17 +22,12 @@
22
22
  * [LAW:one-way-deps] `core/color → themes/palette` remains the only edge
23
23
  * into themes/. This file imports only sibling theme modules and core
24
24
  * color primitives; nothing in `core/` imports back.
25
- *
26
- * Note on `ansiColors`: every preset theme uses `STANDARD_TABLE`. Theme
27
- * identity lives in the semantic `palette`, not in the standard ANSI
28
- * 16/256 LUT — `ColorSpec.parse("red")` is intentionally canonical across
29
- * themes, matching Textual's model.
30
25
  */
31
- import { STANDARD_TABLE, TerminalTheme, } from "../core/color.js";
26
+ import { TerminalTheme } from "../core/color.js";
32
27
  import { buildPalette } from "./buildPalette.js";
33
28
  import { getThemeBaseColors } from "./registry.js";
34
29
  function defineTheme(d) {
35
- return new TerminalTheme(d.bg, d.fg, STANDARD_TABLE, buildPalette(d.name, d.dark, {
30
+ return new TerminalTheme(d.bg, d.fg, d.ansi, buildPalette(d.name, d.dark, {
36
31
  primary: d.primary,
37
32
  secondary: d.secondary,
38
33
  accent: d.accent,
@@ -39,7 +39,7 @@ export declare class Button extends WidgetBase {
39
39
  handleKey(event: KeyEvent): void;
40
40
  handleMouse(event: WidgetMouseEvent): void;
41
41
  setActive(value: boolean): void;
42
- render(_options: RenderOptions): Iterable<Segment>;
42
+ protected draw(_options: RenderOptions): Iterable<Segment>;
43
43
  measure(_options: RenderOptions): {
44
44
  minimum: number;
45
45
  maximum: number;
@@ -151,17 +151,19 @@ let Button = (() => {
151
151
  if (event.type === "mouse_down") {
152
152
  this.active = true;
153
153
  }
154
+ // A release that dragged off the button cancels the press.
154
155
  if (event.type === "mouse_up") {
155
156
  if (this.active) {
156
157
  this.active = false;
157
- this.emitSubmit();
158
+ if (event.over)
159
+ this.emitSubmit();
158
160
  }
159
161
  }
160
162
  }
161
163
  // --- Active mutator (the keyboard-activation microtask uses this) ---
162
164
  setActive(value) { this.active = value; }
163
165
  // --- Rendering ---
164
- render(_options) {
166
+ draw(_options) {
165
167
  // [LAW:dataflow-not-control-flow] same segment count and width every state
166
168
  const focused = this.focused;
167
169
  const left = focused ? "[" : " ";
@@ -11,7 +11,7 @@
11
11
  * disabled — dimmed
12
12
  */
13
13
  import { Segment } from "../core/segment.js";
14
- import type { RenderOptions } from "../core/protocol.js";
14
+ import { type RenderOptions } from "../core/protocol.js";
15
15
  import type { TerminalTheme } from "../core/color.js";
16
16
  import { WidgetBase } from "./widget-base.js";
17
17
  import type { KeyEvent, WidgetMouseEvent } from "./types.js";
@@ -32,7 +32,7 @@ export declare class Checkbox extends WidgetBase {
32
32
  setTheme(theme: TerminalTheme): void;
33
33
  handleKey(event: KeyEvent): void;
34
34
  handleMouse(event: WidgetMouseEvent): void;
35
- render(options: RenderOptions): Iterable<Segment>;
35
+ protected draw(options: RenderOptions): Iterable<Segment>;
36
36
  measure(_options: RenderOptions): {
37
37
  minimum: number;
38
38
  maximum: number;
@@ -50,6 +50,7 @@ import { Style } from "../core/style.js";
50
50
  import { ColorSpec } from "../core/color.js";
51
51
  import { cellLen } from "../core/cells.js";
52
52
  import { DEFAULT_TERMINAL_THEME } from "../themes/terminalThemes.js";
53
+ import { drawable } from "../core/protocol.js";
53
54
  import { WidgetBase } from "./widget-base.js";
54
55
  let Checkbox = (() => {
55
56
  let _classSuper = WidgetBase;
@@ -124,14 +125,15 @@ let Checkbox = (() => {
124
125
  handleMouse(event) {
125
126
  if (this.disabled)
126
127
  return;
127
- if (event.type === "mouse_up") {
128
+ // A release that dragged off the checkbox cancels the press.
129
+ if (event.type === "mouse_up" && event.over) {
128
130
  this.checked = !this.checked;
129
131
  this.emitChange();
130
132
  }
131
133
  }
132
134
  // --- Rendering ---
133
- render(options) {
134
- const indicator = this.checked ? (options.asciiOnly ? "x" : "✓") : " ";
135
+ draw(options) {
136
+ const indicator = this.checked ? drawable(options, "✓", "x") : " ";
135
137
  const text = `[${indicator}] ${this.label}`;
136
138
  if (this.disabled) {
137
139
  return [new Segment(text, new Style({ color: "#666666", bgcolor: "#333333", dim: true }))];
@@ -23,7 +23,7 @@
23
23
  * than the header. See docs/widgets.md → Dropdown.
24
24
  */
25
25
  import { Segment } from "../core/segment.js";
26
- import type { RenderOptions } from "../core/protocol.js";
26
+ import { type RenderOptions } from "../core/protocol.js";
27
27
  import type { TerminalTheme } from "../core/color.js";
28
28
  import { WidgetBase } from "./widget-base.js";
29
29
  import type { KeyEvent, WidgetMouseEvent, WidgetFocusEvent, OverlayRenderable } from "./types.js";
@@ -53,7 +53,7 @@ export declare class Dropdown extends WidgetBase implements OverlayRenderable {
53
53
  handleKey(event: KeyEvent): void;
54
54
  handleMouse(event: WidgetMouseEvent): void;
55
55
  handleFocus(event: WidgetFocusEvent): void;
56
- render(options: RenderOptions): Iterable<Segment>;
56
+ protected draw(options: RenderOptions): Iterable<Segment>;
57
57
  private headerText;
58
58
  renderOverlay(_options: RenderOptions): Iterable<Segment> | null;
59
59
  private renderNoMatchRow;
@@ -62,6 +62,7 @@ import { Style } from "../core/style.js";
62
62
  import { ColorSpec } from "../core/color.js";
63
63
  import { cellLen, setCellSize, splitText, asCellCol } from "../core/cells.js";
64
64
  import { DEFAULT_TERMINAL_THEME } from "../themes/terminalThemes.js";
65
+ import { drawable } from "../core/protocol.js";
65
66
  import { WidgetBase } from "./widget-base.js";
66
67
  let Dropdown = (() => {
67
68
  let _classSuper = WidgetBase;
@@ -263,13 +264,11 @@ let Dropdown = (() => {
263
264
  return;
264
265
  if (event.type !== "mouse_up")
265
266
  return;
266
- const b = this.bounds;
267
- if (!b)
268
- return;
269
- const inside = event.x >= b.x &&
270
- event.x < b.x + b.width &&
271
- event.y >= b.y &&
272
- event.y < b.y + b.height;
267
+ // [LAW:one-source-of-truth] Whether the release is on this dropdown is
268
+ // read off the frame (`over`), not its own idea of its size; a press on
269
+ // it that dragged away releases elsewhere. Row 0 is the header, and the
270
+ // overlay rows continue below it.
271
+ const inside = event.over;
273
272
  if (!this.expanded) {
274
273
  if (inside) {
275
274
  this.expanded = true;
@@ -284,15 +283,14 @@ let Dropdown = (() => {
284
283
  this.expanded = false;
285
284
  return;
286
285
  }
287
- const rowOffset = event.y - b.y;
288
- if (rowOffset === 0) {
286
+ if (event.y === 0) {
289
287
  // Click the header → collapse + clear filter, no selection change.
290
288
  this.filter = "";
291
289
  this.expanded = false;
292
290
  return;
293
291
  }
294
292
  // [LAW:one-source-of-truth] Filtered position → canonical idx.
295
- const picked = this.filteredOptions[rowOffset - 1];
293
+ const picked = this.filteredOptions[event.y - 1];
296
294
  if (picked === undefined)
297
295
  return;
298
296
  this.selectedIndex = picked.idx;
@@ -314,15 +312,14 @@ let Dropdown = (() => {
314
312
  }
315
313
  }
316
314
  // --- Rendering ---
317
- render(options) {
315
+ draw(options) {
318
316
  // Header only — the inline footprint that flow layout sees. Always
319
317
  // 1 row regardless of `expanded`/`filter`. Option rows live in
320
318
  // renderOverlay. Width invariant: maxLabelLen + 4.
321
- const arrowChar = options.asciiOnly ? "v" : "▾";
322
- // [LAW:one-source-of-truth] render() owns the asciiOnly switch; the
323
- // caret character flows into headerText as data rather than headerText
324
- // re-reading the option flag. Same arrow / caret resolution path.
325
- const caret = this.focused ? (options.asciiOnly ? "|" : "│") : "";
319
+ const arrowChar = drawable(options, "▾", "v");
320
+ // [LAW:one-source-of-truth] Both glyphs are resolved here, once, and flow
321
+ // into headerText as data rather than headerText re-reading the options.
322
+ const caret = this.focused ? drawable(options, "│", "|") : "";
326
323
  const maxLabelLen = this.maxLabelLen();
327
324
  const baseStyle = this.disabled
328
325
  ? new Style({ color: "#666666", bgcolor: "#333333", dim: true })
@@ -1,6 +1,9 @@
1
1
  /**
2
- * EventRouter — parses raw stdin into KeyEvent / WidgetMouseEvent values
3
- * and dispatches them to widgets via the Screen's FocusManager and bounds.
2
+ * EventRouter — parses raw stdin into KeyEvent / ScreenMouseEvent values
3
+ * and dispatches them to widgets via a FocusManager and the frame on screen.
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.
4
7
  *
5
8
  * [LAW:single-enforcer] ANSI escape parsing lives only here — widgets never
6
9
  * see raw bytes. One parser, one dispatch surface.
@@ -17,51 +20,40 @@
17
20
  * if it extends the sequence. Tests can call `flush()` to drain synchronously.
18
21
  */
19
22
  import { KeyEvent } from "./types.js";
20
- import type { Screen, FocusManager, InteractiveWidget, WidgetMouseEvent, KeyHandlerOptions } from "./types.js";
23
+ import type { FrameSource, ScreenMouseEvent, KeyHandlerOptions } from "./types.js";
21
24
  import type { Unsubscribe } from "../core/subscription.js";
22
25
  import type { TerminalHost } from "../host/terminal-host.js";
23
26
  type KeyHandler = (event: KeyEvent) => void;
24
- type WidgetSource = {
25
- focusManager: FocusManager;
26
- getWidgets: () => readonly InteractiveWidget[];
27
- };
28
27
  export interface EventRouterOptions {
29
- screen: Screen | WidgetSource;
30
- /**
31
- * The I/O capability the router reads input from and writes
32
- * mouse-tracking sequences to. Construct a `NodeTerminalHost` for
33
- * production node demos; tests pass a host that wraps mock streams.
34
- */
28
+ source: FrameSource;
29
+ /** The I/O capability the router reads input from. */
35
30
  host: TerminalHost;
36
- /**
37
- * When true (default when the host reports `isTTY`), enable mouse
38
- * tracking on `start()` and disable on `stop()`. Disable in tests /
39
- * non-TTY environments.
40
- */
41
- manageMouse?: boolean;
42
- /**
43
- * When true (default when the host reports `isTTY`), switch the host
44
- * into raw mode on `start()` and restore on `stop()`.
45
- */
46
- manageRawMode?: boolean;
47
31
  }
48
32
  export declare class EventRouter {
49
33
  private readonly source;
50
34
  private readonly host;
51
- private readonly manageMouse;
52
- private readonly manageRawMode;
53
35
  private buffer;
54
36
  private running;
55
37
  private dataUnsubscribe;
56
38
  private escTimer;
57
- private capturedWidget;
39
+ private captured;
40
+ private pointer;
41
+ private hoveredWidget;
58
42
  private readonly keyChain;
59
43
  private readonly mouseHandlers;
60
44
  constructor(options: EventRouterOptions);
61
45
  start(): void;
62
46
  stop(): void;
47
+ /**
48
+ * Read hover again off the frame just painted: under a pointer that has not
49
+ * moved, a widget may have gone, moved, or arrived.
50
+ *
51
+ * [LAW:one-source-of-truth] Hover is the widget under the pointer on the
52
+ * frame on screen, so it is re-read whenever either one changes.
53
+ */
54
+ framePainted(): void;
63
55
  onKey(handler: KeyHandler, options?: KeyHandlerOptions): Unsubscribe;
64
- onMouse(handler: (event: WidgetMouseEvent) => void): Unsubscribe;
56
+ onMouse(handler: (event: ScreenMouseEvent) => void): Unsubscribe;
65
57
  /** Feed a chunk of bytes (or a string of bytes) into the parser. */
66
58
  feed(chunk: Uint8Array | string): void;
67
59
  /** Force any pending lone ESC out of the buffer. */
@@ -74,5 +66,6 @@ export declare class EventRouter {
74
66
  private consumeCSI;
75
67
  private dispatchKey;
76
68
  private dispatchMouse;
69
+ private hover;
77
70
  }
78
71
  export {};
@@ -1,6 +1,9 @@
1
1
  /**
2
- * EventRouter — parses raw stdin into KeyEvent / WidgetMouseEvent values
3
- * and dispatches them to widgets via the Screen's FocusManager and bounds.
2
+ * EventRouter — parses raw stdin into KeyEvent / ScreenMouseEvent values
3
+ * and dispatches them to widgets via a FocusManager and the frame on screen.
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.
4
7
  *
5
8
  * [LAW:single-enforcer] ANSI escape parsing lives only here — widgets never
6
9
  * see raw bytes. One parser, one dispatch surface.
@@ -17,6 +20,7 @@
17
20
  * if it extends the sequence. Tests can call `flush()` to drain synchronously.
18
21
  */
19
22
  import { KeyEvent } from "./types.js";
23
+ import { drew, originOf, scrollTargetAt, widgetAt } from "./hit.js";
20
24
  // Single-byte → key name table for the trivial cases.
21
25
  const SINGLE_BYTE_KEYS = {
22
26
  0x09: "tab",
@@ -79,8 +83,6 @@ const LANGLE = 0x3c; // <
79
83
  const O_BYTE = 0x4f; // O
80
84
  const M_UPPER = 0x4d; // M
81
85
  const M_LOWER = 0x6d; // m
82
- const MOUSE_TRACK_ON = "\x1b[?1006h\x1b[?1000h\x1b[?1003h";
83
- const MOUSE_TRACK_OFF = "\x1b[?1003l\x1b[?1000l\x1b[?1006l";
84
86
  // [LAW:single-enforcer] Shared encode/decode singletons. The parser
85
87
  // receives `Uint8Array | string` from the host and walks bytes through
86
88
  // its state machine; conversion happens at the two boundaries (string
@@ -107,8 +109,6 @@ function concatBytes(a, b) {
107
109
  export class EventRouter {
108
110
  source;
109
111
  host;
110
- manageMouse;
111
- manageRawMode;
112
112
  // [LAW:types-are-the-program] The parse buffer is `Uint8Array`, not
113
113
  // `Buffer` — node's Buffer extends Uint8Array, so Buffer chunks from
114
114
  // process.stdin satisfy this type and the parser still works on node,
@@ -123,29 +123,25 @@ export class EventRouter {
123
123
  // microtasks drain), without forcing a `setImmediate` polyfill on the
124
124
  // browser host.
125
125
  escTimer;
126
- // [LAW:single-enforcer] Drag capture lives only here. A mouse_down's hit
127
- // widget is recorded; mouse_move/mouse_up between then and the next
128
- // mouse_up route to this widget unconditionally so dragging outside its
129
- // bounds still drives state (e.g. Slider's _dragging). Cleared on the
130
- // next mouse_up.
131
- capturedWidget = null;
126
+ // [LAW:single-enforcer] Drag capture lives only here. A mouse_down's
127
+ // target is recorded; mouse_move/mouse_up between then and the next
128
+ // mouse_up route to it unconditionally and relative to where it is painted
129
+ // (`relocate`), so a drag past the widget's edge still drives its state
130
+ // (Slider's _dragging) and reports a column outside it. Cleared on the next
131
+ // mouse_up.
132
+ captured = null;
133
+ // Where the terminal last reported the pointer, and the widget under it on
134
+ // the frame on screen — the one widget `hovered` is true on.
135
+ pointer = null;
136
+ hoveredWidget = null;
132
137
  // [LAW:dataflow-not-control-flow] Ordered chain; the dispatcher walks it
133
138
  // in priority tiers and stops as soon as some participant calls
134
139
  // `event.stop()`. Insertion order is preserved within each tier.
135
140
  keyChain = [];
136
141
  mouseHandlers = new Set();
137
142
  constructor(options) {
138
- const { screen } = options;
139
- this.source = isWidgetSource(screen)
140
- ? screen
141
- : { focusManager: screen.focusManager, getWidgets: () => screen.widgets };
143
+ this.source = options.source;
142
144
  this.host = options.host;
143
- // [LAW:one-source-of-truth] The host owns the "is this a real
144
- // terminal?" question. Both raw-mode and mouse-tracking defaults
145
- // derive from the same isTTY — no separate input/output skew.
146
- const isTTY = this.host.isTTY;
147
- this.manageRawMode = options.manageRawMode ?? isTTY;
148
- this.manageMouse = options.manageMouse ?? isTTY;
149
145
  // [LAW:single-enforcer] FocusManager owns Tab/Shift+Tab traversal —
150
146
  // register it as a normal-priority handler so it participates in the
151
147
  // chain. This MUST be the first registration so it lands ahead of any
@@ -159,14 +155,8 @@ export class EventRouter {
159
155
  if (this.running)
160
156
  return;
161
157
  this.running = true;
162
- // [LAW:dataflow-not-control-flow] No `if (host.setRawMode)` capability
163
- // check — the host absorbs that. Same goes for resume(): subscribing
164
- // via host.onData triggers the underlying stream's flow on node, no-ops
165
- // on transports that don't have a paused mode.
166
- if (this.manageRawMode)
167
- this.host.setRawMode(true);
168
- if (this.manageMouse)
169
- this.host.write(MOUSE_TRACK_ON);
158
+ // Subscribing starts the underlying stream flowing on node, and is all
159
+ // it takes on a transport with no paused mode.
170
160
  this.dataUnsubscribe = this.host.onData((chunk) => this.feed(chunk));
171
161
  }
172
162
  stop() {
@@ -176,14 +166,11 @@ export class EventRouter {
176
166
  this.dataUnsubscribe();
177
167
  this.dataUnsubscribe = undefined;
178
168
  }
179
- if (this.manageMouse)
180
- this.host.write(MOUSE_TRACK_OFF);
181
- if (this.manageRawMode)
182
- this.host.setRawMode(false);
183
169
  }
184
170
  // [LAW:one-source-of-truth] Per-session state belongs to one session.
185
171
  // stop() leaves the router in the same shape a fresh construction
186
- // would: empty parse buffer, no captured widget, no pending ESC timer.
172
+ // would: empty parse buffer, no captured widget, nothing hovered, no
173
+ // pending ESC timer.
187
174
  // All cleanup is outside the `running` guard because feed() can arm
188
175
  // escTimer (and grow buffer) even when running is false — without
189
176
  // those, a pre-start feed could leave a deferred escape firing into
@@ -194,7 +181,20 @@ export class EventRouter {
194
181
  this.escTimer = undefined;
195
182
  }
196
183
  this.buffer = EMPTY_BYTES;
197
- this.capturedWidget = null;
184
+ this.captured = null;
185
+ this.pointer = null;
186
+ this.hover(null);
187
+ }
188
+ /**
189
+ * Read hover again off the frame just painted: under a pointer that has not
190
+ * moved, a widget may have gone, moved, or arrived.
191
+ *
192
+ * [LAW:one-source-of-truth] Hover is the widget under the pointer on the
193
+ * frame on screen, so it is re-read whenever either one changes.
194
+ */
195
+ framePainted() {
196
+ const hit = this.pointer && widgetAt(this.source.frame, this.pointer.x, this.pointer.y);
197
+ this.hover(hit ? hit.widget : null);
198
198
  }
199
199
  // --- External hooks ---
200
200
  onKey(handler, options) {
@@ -502,42 +502,59 @@ export class EventRouter {
502
502
  dispatchMouse(event) {
503
503
  for (const handler of this.mouseHandlers)
504
504
  handler(event);
505
- const widgets = this.source.getWidgets();
506
- // Hover tracking: any widget whose hover state changed gets an update
507
- // via the canonical setHovered (on WidgetBase). One setter, no fallback.
508
- if (event.type === "mouse_move") {
509
- for (const w of widgets) {
510
- if (!w.visible)
511
- continue;
512
- const inside = w.containsPoint(event.x, event.y);
513
- if (inside !== w.hovered)
514
- w.setHovered(inside);
515
- }
505
+ 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();
519
+ return;
516
520
  }
517
521
  // [LAW:dataflow-not-control-flow] Same pipeline every event; the
518
522
  // capture value and event type pick the target. mouse_down opens a
519
523
  // capture, mouse_up closes it, mouse_move in between routes to the
520
- // captured widget regardless of pointer position. Scroll events
521
- // bypass capture (they're not drag-scoped).
522
- const useCapture = this.capturedWidget !== null &&
523
- (event.type === "mouse_move" || event.type === "mouse_up");
524
- const target = useCapture
525
- ? this.capturedWidget
526
- : topmostHit(widgets, event.x, event.y);
527
- if (target)
528
- target.handleMouse(event);
524
+ // captured widget regardless of pointer position. A hit's origin puts the
525
+ // pressed cell at its own column and row, so every delivery is the same
526
+ // subtraction.
527
+ const useCapture = event.type === "mouse_move" || event.type === "mouse_up";
528
+ const target = (useCapture && this.captured ? relocate(frame, this.captured) : null) ??
529
+ (hit ? { widget: hit.widget, originX: event.x - hit.col, originY: event.y - hit.row } : null);
530
+ target?.widget.handleMouse({
531
+ ...event,
532
+ x: event.x - target.originX,
533
+ y: event.y - target.originY,
534
+ over: drew(frame, target.widget, event.x, event.y),
535
+ });
529
536
  if (event.type === "mouse_down")
530
- this.capturedWidget = target;
537
+ this.captured = target;
531
538
  else if (event.type === "mouse_up")
532
- this.capturedWidget = null;
539
+ this.captured = null;
540
+ }
541
+ // [LAW:single-enforcer] The canonical setter on the widget is the only
542
+ // writer of `hovered`; this is the only caller, and only on a change.
543
+ hover(widget) {
544
+ if (widget === this.hoveredWidget)
545
+ return;
546
+ this.hoveredWidget?.setHovered(false);
547
+ widget?.setHovered(true);
548
+ this.hoveredWidget = widget;
533
549
  }
534
550
  }
535
551
  // --- Helpers ---
536
- function isWidgetSource(value) {
537
- return (!!value &&
538
- typeof value === "object" &&
539
- "getWidgets" in value &&
540
- typeof value.getWidgets === "function");
552
+ // [LAW:one-source-of-truth] A captured widget's origin is read off the frame
553
+ // just painted, so a drag follows the widget if it moved since the press; one
554
+ // no longer painted is where the press found it.
555
+ function relocate(frame, target) {
556
+ const origin = originOf(frame, target.widget);
557
+ return origin ? { widget: target.widget, originX: origin.x, originY: origin.y } : target;
541
558
  }
542
559
  function decodeMouseFromCb(cb, x, y, isPress) {
543
560
  const isMotion = (cb & 32) !== 0;
@@ -610,13 +627,3 @@ function decodeModifier(mod) {
610
627
  ctrl: (bits & 4) !== 0,
611
628
  };
612
629
  }
613
- function topmostHit(widgets, x, y) {
614
- for (let i = widgets.length - 1; i >= 0; i--) {
615
- const w = widgets[i];
616
- if (!w.visible)
617
- continue;
618
- if (w.containsPoint(x, y))
619
- return w;
620
- }
621
- return null;
622
- }