@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
package/README.md CHANGED
@@ -24,7 +24,7 @@ Most of the library comes from the package name itself — `Console`, `Table`, `
24
24
  |---|---|---|---|
25
25
  | `@promptctl/rich-js/widgets` | Button, Checkbox, Toggle, TextInput, Dropdown, Slider, and the screen that mounts them | `mobx` | Carries a third-party runtime dependency of its own — MobX, for widget state |
26
26
  | `@promptctl/rich-js/template-bindings` | The styling vocabulary as Go-template functions, so styled text can be authored as a template | `@promptctl/go-template-js` | Carries a third-party runtime dependency of its own — the Go-template engine, for parsing and evaluating the templates |
27
- | `@promptctl/rich-js/host` | `TerminalHost`, `BrowserTerminalHost`, `hostStream` — the seam between rendering and a terminal | Nothing | A program that just wants to write bytes through a host shouldn't pay for the widget set to do it |
27
+ | `@promptctl/rich-js/host` | `TerminalHost`, `BrowserTerminalHost`, `hostEnvironment` — the seam between rendering and a terminal | Nothing | A program that just wants to write bytes through a host shouldn't pay for the widget set to do it |
28
28
  | `@promptctl/rich-js/node/save`, `/node/prompt`, `/node/traceback`, `/node/terminal-host` | File export, readline input, the crash handler, and the node TTY host | Nothing | Each one reads node built-ins, and keeping them off the main entry point is what keeps that entry point browser-safe |
29
29
 
30
30
  Those two peer dependencies are yours to install — `npm install @promptctl/rich-js` deliberately fetches neither, because a program that prints a table shouldn't acquire a state library or a template engine to do it. Add the ones you need:
@@ -293,45 +293,31 @@ installTraceback();
293
293
  Everything above draws once and returns. Widgets stay on screen and respond — a button that highlights under the cursor, a text field with a cursor you can move, a dropdown you filter by typing. They come from the `widgets` subpath and need MobX installed alongside the package (see [Entry points](#entry-points)).
294
294
 
295
295
  ```typescript
296
- import { Button, TextInput, DefaultScreen, EventRouter } from "@promptctl/rich-js/widgets";
296
+ import { Group } from "@promptctl/rich-js";
297
+ import { Button, TextInput, WidgetApp } from "@promptctl/rich-js/widgets";
297
298
  import { NodeTerminalHost } from "@promptctl/rich-js/node/terminal-host";
298
299
 
299
- const host = new NodeTerminalHost();
300
- const screen = new DefaultScreen({ host });
301
- const router = new EventRouter({ screen, host });
302
-
303
300
  const name = new TextInput({ placeholder: "your name" });
304
301
  const submit = new Button({ label: "Submit", variant: "primary" });
302
+ const form = new Group(name, submit);
305
303
 
306
- const quit = (): void => {
307
- router.stop();
308
- screen.stop();
309
- host.write("\n");
310
- };
304
+ const app = new WidgetApp({ host: new NodeTerminalHost(), surface: "alternate", view: () => form });
311
305
 
312
- submit.onSubmit(() => {
313
- quit();
314
- host.write(`hello, ${name.value}\n`);
315
- process.exit(0);
316
- });
306
+ submit.onSubmit(() => app.stop());
317
307
 
318
308
  // Raw mode swallows Ctrl+C, so the app has to handle it itself.
319
- router.onKey(
309
+ app.onKey(
320
310
  (event) => {
321
- if (event.ctrl && event.key === "c") {
322
- quit();
323
- process.exit(0);
324
- }
311
+ if (event.ctrl && event.key === "c") app.stop();
325
312
  },
326
313
  { priority: "high" },
327
314
  );
328
315
 
329
- screen.mount(name, submit);
330
- screen.start();
331
- router.start();
316
+ await app.run();
317
+ console.log(`hello, ${name.value}`);
332
318
  ```
333
319
 
334
- `DefaultScreen` builds a focus manager when you don't pass one, so the `TextInput` has focus before the user touches anything and Tab moves between the two widgets. The screen re-renders through a MobX reaction: change a widget's state and the frame redraws itself, with no explicit repaint call anywhere. See [docs/widgets.md](docs/widgets.md) for the full widget set, key dispatch, layout placements, and how to write your own.
320
+ Widgets go anywhere a renderable goes — a `Group`, a `Panel`, a `Layout` pane — and the app finds them on the frame it paints. The `TextInput` has focus before the user touches anything, Tab moves between the two widgets in the order they are drawn, and a change to a widget's state repaints with no explicit call. `app.stop()` — or a crash, or a signal — hands the terminal back. See [docs/widgets.md](docs/widgets.md) for the full widget set, key dispatch, layout, and how to write your own.
335
321
 
336
322
  </details>
337
323
 
@@ -401,6 +387,7 @@ npm run demo
401
387
  | `npm run themes-and-color-studio` | themes-and-color-studio | A printed tour of colours, palettes, bundled themes, and contrast. Prints once and exits; set `EXPORT_HTML=out.html` to save it as HTML too. |
402
388
  | `npm run strip` | rich-strip | Every built-in `Joiner`, printed side by side. Prints once and exits. |
403
389
  | `npm run markup-plugins` | rich-markup-plugins | Custom markup tags registered through `MarkupRegistry`. Prints once and exits. |
390
+ | `npm run viewport` | rich-viewport | A cursor walking a list taller than its window, kept in view by `Viewport.ensureVisible`. |
404
391
 
405
392
  `Ctrl-C` quits any demo that stays running.
406
393
 
@@ -410,5 +397,5 @@ npm run demo
410
397
  |---|---|
411
398
  | `NO_COLOR` | Disable all color (hyperlinks still print) |
412
399
  | `FORCE_COLOR` | Enable color regardless of `TERM` |
413
- | `TERM=dumb` | Disable color and style |
400
+ | `TERM=dumb` | Disable color, style, and hyperlinks |
414
401
  | `COLUMNS` / `LINES` | Override terminal dimensions |
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Anchor — where a cell sat in the output of the thing that drew it.
3
+ *
4
+ * A widget nested inside a `Layout` cell inside a `Panel` does not know where
5
+ * it lands on screen, and nothing can tell it before it renders: `Align`
6
+ * centres each line only after rendering it, so the lines of one widget can
7
+ * start at different columns, and a `Viewport` shows whichever of its
8
+ * content's rows its offset selects. An origin handed down through the render
9
+ * options would be a number each container has to compute before it knows
10
+ * the answer, and one more thing every container must forward correctly.
11
+ *
12
+ * So the position travels up instead, on the cells themselves. The owner
13
+ * stamps each segment of its output with its row and column there
14
+ * (`Segment.anchorLines`), the stamp rides on the segment's `Style`, which
15
+ * every container already carries through untouched, and whoever holds the
16
+ * composed frame reads any cell's owner and position straight off it
17
+ * (`Segment.anchorAt`). Containers do nothing, so no container can get it
18
+ * wrong.
19
+ *
20
+ * [LAW:single-enforcer] Two things can falsify a stamp. Cutting a segment in
21
+ * two starts the right half at a later column than the stamp names:
22
+ * `Segment.splitCells` is where a segment is cut, and it shifts the right
23
+ * half's anchor (`shiftAnchor`); a crop that keeps the left half keeps a stamp
24
+ * that is already true. Laying the text out again moves every cell: a
25
+ * `RichText` does that, so it admits no anchor (`admitStyle` in `./text.ts`),
26
+ * and the owner that renders it stamps what it draws.
27
+ */
28
+ /**
29
+ * The position of a segment's first cell in its owner's output, and the
30
+ * anchor that segment carried before this owner stamped it — the owner nested
31
+ * inside this one, if any. Innermost last: follow `inner` to reach the owner
32
+ * that drew the cell itself.
33
+ */
34
+ export interface Anchor {
35
+ /** The thing that drew this cell, compared by identity. */
36
+ readonly owner: object;
37
+ readonly row: number;
38
+ readonly col: number;
39
+ readonly inner: Anchor | undefined;
40
+ }
41
+ /**
42
+ * The anchor of the cell `cells` to the right of the one `anchor` names, at
43
+ * every level of the nesting — each level names the same cell.
44
+ */
45
+ export declare function shiftAnchor(anchor: Anchor, cells: number): Anchor;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Anchor — where a cell sat in the output of the thing that drew it.
3
+ *
4
+ * A widget nested inside a `Layout` cell inside a `Panel` does not know where
5
+ * it lands on screen, and nothing can tell it before it renders: `Align`
6
+ * centres each line only after rendering it, so the lines of one widget can
7
+ * start at different columns, and a `Viewport` shows whichever of its
8
+ * content's rows its offset selects. An origin handed down through the render
9
+ * options would be a number each container has to compute before it knows
10
+ * the answer, and one more thing every container must forward correctly.
11
+ *
12
+ * So the position travels up instead, on the cells themselves. The owner
13
+ * stamps each segment of its output with its row and column there
14
+ * (`Segment.anchorLines`), the stamp rides on the segment's `Style`, which
15
+ * every container already carries through untouched, and whoever holds the
16
+ * composed frame reads any cell's owner and position straight off it
17
+ * (`Segment.anchorAt`). Containers do nothing, so no container can get it
18
+ * wrong.
19
+ *
20
+ * [LAW:single-enforcer] Two things can falsify a stamp. Cutting a segment in
21
+ * two starts the right half at a later column than the stamp names:
22
+ * `Segment.splitCells` is where a segment is cut, and it shifts the right
23
+ * half's anchor (`shiftAnchor`); a crop that keeps the left half keeps a stamp
24
+ * that is already true. Laying the text out again moves every cell: a
25
+ * `RichText` does that, so it admits no anchor (`admitStyle` in `./text.ts`),
26
+ * and the owner that renders it stamps what it draws.
27
+ */
28
+ /**
29
+ * The anchor of the cell `cells` to the right of the one `anchor` names, at
30
+ * every level of the nesting — each level names the same cell.
31
+ */
32
+ export function shiftAnchor(anchor, cells) {
33
+ return {
34
+ owner: anchor.owner,
35
+ row: anchor.row,
36
+ col: anchor.col + cells,
37
+ inner: anchor.inner && shiftAnchor(anchor.inner, cells),
38
+ };
39
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ANSI bytes back into styled text — a port of Rich's `AnsiDecoder`
3
+ * (rich/ansi.py) and `Text.from_ansi`.
4
+ *
5
+ * The rendering pipeline runs one way, `Style` → SGR bytes; this runs it
6
+ * backwards, for output some program already wrote: a capture of an example's
7
+ * own `new Console()`, a CLI's output shown inside a Panel, a log replayed into
8
+ * a Table cell.
9
+ *
10
+ * [LAW:types-are-the-program] Every colour decodes to the `ColorSpec` its
11
+ * escape names — `31` the standard colour 1, `38;5;n` palette index n,
12
+ * `38;2;r;g;b` that truecolor — and none is resolved to RGB here. A standard
13
+ * colour is a slot in whatever theme draws it, so one captured run exports
14
+ * correctly under a light theme and a dark one; resolved here, dark mode would
15
+ * show light mode's colours. Resolution belongs to `export-lines`.
16
+ *
17
+ * [LAW:types-are-the-program] The link is decoder state of its own, beside
18
+ * the SGR style, not a field of it: a terminal ends a hyperlink only at an OSC
19
+ * 8 close, and an SGR reset inside the link leaves it open. Rich keeps the
20
+ * link in the SGR style, so its reset drops it.
21
+ *
22
+ * Carriage returns are the other departure, and the same argument. Rich splits
23
+ * lines with Python's `splitlines`, which also breaks at `\r`, so its `a\rb`
24
+ * is two lines. Here a line ends only at `\n`, and `\r` returns to the first
25
+ * column, so later text overwrites earlier text character by character:
26
+ * `50%\r100%` is `100%`, `Downloading\rDone` is `Doneloading`, and `done\r`
27
+ * is still `done`. Columns are counted in characters, not cells, so a wide
28
+ * character overwritten by a narrow one is where this and a terminal differ.
29
+ * Erase in line (`\x1b[K`) is honoured for the same reason, because a redraw
30
+ * is usually `\r\x1b[K` and the old text must not show through a shorter new
31
+ * one.
32
+ *
33
+ * Everything else follows Rich: malformed SGR parameters are skipped rather
34
+ * than refused, and every other escape is dropped. It is not a terminal
35
+ * emulator — any other cursor movement is dropped, not performed.
36
+ *
37
+ * Tier 5 of `src/core/`: it builds `RichText`, so it sits above `text`, and
38
+ * `RichText` cannot offer a `fromAnsi` static without importing upward.
39
+ */
40
+ import { RichText } from "./text.js";
41
+ import type { RichTextOptions } from "./text.js";
42
+ /**
43
+ * A stateful decoder: the style an escape sets carries on across lines, and
44
+ * across calls, as it does on a terminal. Use one decoder per stream and hand
45
+ * it whole lines, so output read a line at a time decodes as it would have
46
+ * all at once. It buffers nothing: a chunk cut mid-line or mid-escape decodes
47
+ * as the line it appears to be.
48
+ */
49
+ export declare class AnsiDecoder {
50
+ private sgr;
51
+ private link;
52
+ /** One `RichText` per line of `ansi`; a final newline ends a line rather than starting one. */
53
+ decode(ansi: string): RichText[];
54
+ /**
55
+ * One line of output, holding no `\n`, written the way a terminal writes
56
+ * it: `\r` returns to the first column, and text overwrites from there.
57
+ */
58
+ decodeLine(line: string): RichText;
59
+ }
60
+ /**
61
+ * `ansi` as one `RichText`, its lines joined by `\n` — Rich's
62
+ * `Text.from_ansi`. `options` describe the result the way they describe any
63
+ * `RichText`.
64
+ */
65
+ export declare function decodeAnsi(ansi: string, options?: RichTextOptions): RichText;
@@ -0,0 +1,231 @@
1
+ /**
2
+ * ANSI bytes back into styled text — a port of Rich's `AnsiDecoder`
3
+ * (rich/ansi.py) and `Text.from_ansi`.
4
+ *
5
+ * The rendering pipeline runs one way, `Style` → SGR bytes; this runs it
6
+ * backwards, for output some program already wrote: a capture of an example's
7
+ * own `new Console()`, a CLI's output shown inside a Panel, a log replayed into
8
+ * a Table cell.
9
+ *
10
+ * [LAW:types-are-the-program] Every colour decodes to the `ColorSpec` its
11
+ * escape names — `31` the standard colour 1, `38;5;n` palette index n,
12
+ * `38;2;r;g;b` that truecolor — and none is resolved to RGB here. A standard
13
+ * colour is a slot in whatever theme draws it, so one captured run exports
14
+ * correctly under a light theme and a dark one; resolved here, dark mode would
15
+ * show light mode's colours. Resolution belongs to `export-lines`.
16
+ *
17
+ * [LAW:types-are-the-program] The link is decoder state of its own, beside
18
+ * the SGR style, not a field of it: a terminal ends a hyperlink only at an OSC
19
+ * 8 close, and an SGR reset inside the link leaves it open. Rich keeps the
20
+ * link in the SGR style, so its reset drops it.
21
+ *
22
+ * Carriage returns are the other departure, and the same argument. Rich splits
23
+ * lines with Python's `splitlines`, which also breaks at `\r`, so its `a\rb`
24
+ * is two lines. Here a line ends only at `\n`, and `\r` returns to the first
25
+ * column, so later text overwrites earlier text character by character:
26
+ * `50%\r100%` is `100%`, `Downloading\rDone` is `Doneloading`, and `done\r`
27
+ * is still `done`. Columns are counted in characters, not cells, so a wide
28
+ * character overwritten by a narrow one is where this and a terminal differ.
29
+ * Erase in line (`\x1b[K`) is honoured for the same reason, because a redraw
30
+ * is usually `\r\x1b[K` and the old text must not show through a shorter new
31
+ * one.
32
+ *
33
+ * Everything else follows Rich: malformed SGR parameters are skipped rather
34
+ * than refused, and every other escape is dropped. It is not a terminal
35
+ * emulator — any other cursor movement is dropped, not performed.
36
+ *
37
+ * Tier 5 of `src/core/`: it builds `RichText`, so it sits above `text`, and
38
+ * `RichText` cannot offer a `fromAnsi` static without importing upward.
39
+ */
40
+ import { ColorSpec } from "./color.js";
41
+ import { osc8Sequences } from "./osc8.js";
42
+ import { ATTRIBUTE_NAMES, ATTRIBUTE_SGR, NULL_STYLE, Style } from "./style.js";
43
+ import { RichText } from "./text.js";
44
+ /**
45
+ * Every escape but OSC 8, which `osc8Sequences` reads before this runs. In
46
+ * order: a carriage return; an SGR sequence (group 1, its parameters — a
47
+ * private marker such as the `>` of `\x1b[>4;2m` makes it some other CSI);
48
+ * erase in line (group 2, its mode); any other CSI sequence; any other string
49
+ * escape — OSC, DCS, APC, PM, SOS — run to the terminators OSC 8 accepts or
50
+ * cut off by the end of the line; any other escape, ECMA-48's intermediates
51
+ * and one final byte (`\x1b(B`, `\x1b)0`, `\x1b#8`, `\x1bc`). Only the first
52
+ * three become tokens.
53
+ */
54
+ const ESCAPE = /\r|\x1b\[([0-9;:]*)m|\x1b\[([012]?)K|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[\]P_^X][\s\S]*?(?:\x07|\x1b\\|\x9c|$)|\x1b[ -/]*[0-~]/g;
55
+ function* escapeTokens(bytes) {
56
+ let at = 0;
57
+ for (const match of bytes.matchAll(ESCAPE)) {
58
+ if (match.index > at)
59
+ yield { kind: "text", text: bytes.slice(at, match.index) };
60
+ if (match[0] === "\r")
61
+ yield { kind: "return" };
62
+ if (match[1] !== undefined)
63
+ yield { kind: "sgr", params: match[1] };
64
+ if (match[2] !== undefined)
65
+ yield { kind: "erase", mode: (match[2] || "0") };
66
+ at = match.index + match[0].length;
67
+ }
68
+ if (at < bytes.length)
69
+ yield { kind: "text", text: bytes.slice(at) };
70
+ }
71
+ // [LAW:one-source-of-truth] OSC 8 is read by the reader `osc8.ts` owns, so
72
+ // the terminators a link may end with cannot differ between writing and
73
+ // reading it back.
74
+ function* tokens(line) {
75
+ let at = 0;
76
+ for (const sequence of osc8Sequences(line)) {
77
+ yield* escapeTokens(line.slice(at, sequence.index));
78
+ yield { kind: "link", uri: sequence.uri };
79
+ at = sequence.index + sequence.length;
80
+ }
81
+ yield* escapeTokens(line.slice(at));
82
+ }
83
+ function adding(options) {
84
+ const added = new Style(options);
85
+ return (style) => style.add(added);
86
+ }
87
+ function next(rest) {
88
+ const step = rest.next();
89
+ return step.done ? undefined : step.value;
90
+ }
91
+ /**
92
+ * `5;n` or `2;r;g;b`, the arguments of `38` and `48`. A sequence cut short
93
+ * names no colour, and the codes it did consume are spent, as in Rich.
94
+ */
95
+ function extendedColor(rest) {
96
+ const kind = next(rest);
97
+ if (kind === 5) {
98
+ const n = next(rest);
99
+ return n === undefined ? undefined : ColorSpec.fromAnsi(n);
100
+ }
101
+ if (kind === 2) {
102
+ const [r, g, b] = [next(rest), next(rest), next(rest)];
103
+ return b === undefined ? undefined : ColorSpec.fromRgb(r, g, b);
104
+ }
105
+ return undefined;
106
+ }
107
+ // [LAW:dataflow-not-control-flow] Every code is an operation on the style,
108
+ // reset included; a code this table does not name is the identity.
109
+ const SGR_OPS = new Map([
110
+ [0, () => NULL_STYLE],
111
+ // [LAW:one-source-of-truth] The codes that turn an attribute on are the
112
+ // ones `Style.toSgrCodes` writes; only the codes that turn one off are ours.
113
+ ...ATTRIBUTE_NAMES.map((name) => [ATTRIBUTE_SGR[name], adding({ [name]: true })]),
114
+ [22, adding({ bold: false, dim: false })],
115
+ [23, adding({ italic: false })],
116
+ // ECMA-48: 24 is "neither singly nor doubly underlined", 25 "steady" at
117
+ // either speed. Rich's table clears only the first of each pair.
118
+ [24, adding({ underline: false, underline2: false })],
119
+ [25, adding({ blink: false, blink2: false })],
120
+ [27, adding({ reverse: false })],
121
+ [28, adding({ conceal: false })],
122
+ [29, adding({ strike: false })],
123
+ [38, (style, rest) => style.add(new Style({ color: extendedColor(rest) }))],
124
+ [39, adding({ color: ColorSpec.default() })],
125
+ [48, (style, rest) => style.add(new Style({ bgcolor: extendedColor(rest) }))],
126
+ [49, adding({ bgcolor: ColorSpec.default() })],
127
+ // Underline colour has no field on Style; its arguments are still spent, or
128
+ // `58;2;1;2;3` would read on as bold, dim and italic.
129
+ [58, (style, rest) => (extendedColor(rest), style)],
130
+ [54, adding({ frame: false, encircle: false })],
131
+ [55, adding({ overline: false })],
132
+ ...Array.from({ length: 8 }, (_, n) => [
133
+ [30 + n, adding({ color: ColorSpec.fromAnsi(n) })],
134
+ [40 + n, adding({ bgcolor: ColorSpec.fromAnsi(n) })],
135
+ [90 + n, adding({ color: ColorSpec.fromAnsi(n + 8) })],
136
+ [100 + n, adding({ bgcolor: ColorSpec.fromAnsi(n + 8) })],
137
+ ]).flat(),
138
+ ]);
139
+ const identity = (style) => style;
140
+ /**
141
+ * `params` applied to `style`. An empty parameter is 0 and one above 255 is
142
+ * 255; one that is not a number — a colon sub-parameter — is skipped, as in
143
+ * Rich.
144
+ */
145
+ function applySgr(style, params) {
146
+ const codes = params
147
+ .split(";")
148
+ .filter((code) => /^\d*$/.test(code))
149
+ .map((code) => Math.min(255, Number(code)))
150
+ .values();
151
+ let result = style;
152
+ for (const code of codes)
153
+ result = (SGR_OPS.get(code) ?? identity)(result, codes);
154
+ return result;
155
+ }
156
+ const BLANK = [" ", NULL_STYLE];
157
+ /** Erase in line: the cells before the cursor go blank, the ones from it on go. */
158
+ function erase(cells, column, mode) {
159
+ if (mode !== "0")
160
+ cells.fill(BLANK, 0, Math.min(column + 1, cells.length));
161
+ if (mode !== "1")
162
+ cells.length = Math.min(column, cells.length);
163
+ }
164
+ /**
165
+ * A stateful decoder: the style an escape sets carries on across lines, and
166
+ * across calls, as it does on a terminal. Use one decoder per stream and hand
167
+ * it whole lines, so output read a line at a time decodes as it would have
168
+ * all at once. It buffers nothing: a chunk cut mid-line or mid-escape decodes
169
+ * as the line it appears to be.
170
+ */
171
+ export class AnsiDecoder {
172
+ sgr = NULL_STYLE;
173
+ link = undefined;
174
+ /** One `RichText` per line of `ansi`; a final newline ends a line rather than starting one. */
175
+ decode(ansi) {
176
+ return (ansi.match(/[^\n]*\n|[^\n]+/g) ?? []).map((line) => this.decodeLine(line.replace(/\r?\n$/, "")));
177
+ }
178
+ /**
179
+ * One line of output, holding no `\n`, written the way a terminal writes
180
+ * it: `\r` returns to the first column, and text overwrites from there.
181
+ */
182
+ decodeLine(line) {
183
+ const cells = [];
184
+ let column = 0;
185
+ for (const token of tokens(line)) {
186
+ switch (token.kind) {
187
+ case "text": {
188
+ const style = this.sgr.withLink(this.link);
189
+ for (const char of token.text)
190
+ cells[column++] = [char, style];
191
+ break;
192
+ }
193
+ case "return":
194
+ column = 0;
195
+ break;
196
+ case "erase":
197
+ erase(cells, column, token.mode);
198
+ break;
199
+ case "sgr":
200
+ this.sgr = applySgr(this.sgr, token.params);
201
+ break;
202
+ case "link":
203
+ this.link = token.uri === "" ? undefined : token.uri;
204
+ break;
205
+ }
206
+ }
207
+ const runs = [];
208
+ for (const [char, style] of cells) {
209
+ const last = runs.at(-1);
210
+ if (last?.[1].equals(style))
211
+ last[0] += char;
212
+ else
213
+ runs.push([char, style]);
214
+ }
215
+ return RichText.assemble(runs);
216
+ }
217
+ }
218
+ /**
219
+ * `ansi` as one `RichText`, its lines joined by `\n` — Rich's
220
+ * `Text.from_ansi`. `options` describe the result the way they describe any
221
+ * `RichText`.
222
+ */
223
+ export function decodeAnsi(ansi, options) {
224
+ const result = new RichText("", options);
225
+ new AnsiDecoder().decode(ansi).forEach((line, index) => {
226
+ if (index > 0)
227
+ result.append("\n");
228
+ result.append(line);
229
+ });
230
+ return result;
231
+ }
@@ -51,7 +51,6 @@ export declare class Box {
51
51
  readonly top: EdgeChars;
52
52
  readonly bottom: EdgeChars;
53
53
  private readonly grid;
54
- private readonly ascii;
55
54
  private readonly headContent;
56
55
  private readonly headSeparator;
57
56
  private readonly bodyContent;
package/dist/core/box.js CHANGED
@@ -26,19 +26,9 @@
26
26
  */
27
27
  import { cellLen } from "./cells.js";
28
28
  import { Segment } from "./segment.js";
29
+ import { drawable } from "./protocol.js";
29
30
  const GRID_ROWS = 8;
30
31
  const GRID_COLUMNS = 4;
31
- /**
32
- * A grid a terminal without unicode support can already draw as written.
33
- *
34
- * [LAW:one-source-of-truth] Derived from the grid rather than declared per
35
- * constant the way the reference's `ascii=True` is. A `Box` is wholly its grid,
36
- * so the grid already answers this; a second, hand-written answer could
37
- * disagree with it. It also reaches boxes no constant can speak for —
38
- * `safeSubstitute` builds a fresh `Box` from an edited grid, and a flag carried
39
- * on the shipped constants would say nothing about that one.
40
- */
41
- const ASCII_GRID = /^[\x00-\x7F]*$/;
42
32
  /** Corners that a legacy Windows terminal cannot draw, and their square kin. */
43
33
  const SAFE_SUBSTITUTIONS = {
44
34
  "╭": "┌",
@@ -62,7 +52,6 @@ export class Box {
62
52
  top;
63
53
  bottom;
64
54
  grid;
65
- ascii;
66
55
  headContent;
67
56
  headSeparator;
68
57
  bodyContent;
@@ -91,7 +80,6 @@ export class Box {
91
80
  }
92
81
  const rows = lines.map((line) => Array.from(line));
93
82
  this.grid = grid;
94
- this.ascii = ASCII_GRID.test(grid);
95
83
  this.top = edgeOf(rows[0]);
96
84
  this.headContent = contentOf(rows[1]);
97
85
  this.headSeparator = edgeOf(rows[2]);
@@ -148,11 +136,11 @@ export class Box {
148
136
  * rounded corners a legacy Windows terminal draws as blanks.
149
137
  */
150
138
  substitute(options = {}) {
151
- if (options.asciiOnly && !this.ascii)
152
- return ASCII;
153
- if (options.safe)
154
- return this.safeSubstitute();
155
- return this;
139
+ // [LAW:one-source-of-truth] A box is wholly its grid, so the grid is what
140
+ // says whether it is already drawable — for a box built by `safeSubstitute`
141
+ // as much as for a shipped constant.
142
+ const drawn = drawable(options, this, ASCII, (box) => box.grid);
143
+ return drawn === this && options.safe === true ? this.safeSubstitute() : drawn;
156
144
  }
157
145
  safeSubstitute() {
158
146
  return new Box(Array.from(this.grid, (char) => SAFE_SUBSTITUTIONS[char] ?? char).join(""));
@@ -80,14 +80,25 @@ export declare function splitText(text: string, position: CellCol): [string, str
80
80
  */
81
81
  export declare function chopCells(text: string, maxWidth: CellCol): string[];
82
82
  /**
83
- * Returns the largest prefix of `text` whose cell width fits within `cap` cells.
84
- * No padding — the returned string may be narrower than `cap` when the next
85
- * character is wide and would overshoot. Never wider than `cap` cells.
83
+ * The grapheme clusters of `text`, in order: the unit `string-width` measures,
84
+ * and so the unit `cellLen` of a whole string is the sum of. Summing `cellLen`
85
+ * over code points instead disagrees with it on every glyph built from several:
86
+ * `❤️` is 1 + 0 by code point and 2 as a cluster, `👨‍👩‍👧` is 6 and 2, and a cut
87
+ * between two of its code points leaves half a glyph.
88
+ */
89
+ export declare function graphemes(text: string): string[];
90
+ /**
91
+ * Returns the largest prefix of `text` whose cell width fits within `cap` cells,
92
+ * cut between grapheme clusters. No padding — the returned string may be
93
+ * narrower than `cap` when the next glyph is wide and would overshoot. Never
94
+ * wider than `cap` cells.
86
95
  *
87
- * When the first character already exceeds `cap` cells, returns "" (the caller
88
- * must decide whether to force-take the character or skip it).
96
+ * When the first glyph already exceeds `cap` cells, returns "" (the caller
97
+ * must decide whether to force-take the glyph or skip it).
89
98
  */
90
99
  export declare function cellFit(text: string, cap: CellCol): string;
100
+ /** `cellFit` from the other end: the largest suffix of `text` within `cap` cells. */
101
+ export declare function cellFitEnd(text: string, cap: CellCol): string;
91
102
  /**
92
103
  * Returns the largest code-unit end offset starting from `startCU` whose
93
104
  * prefix (from `startCU`) has cell width ≤ `cap`. Iterates from the given
@@ -127,25 +127,46 @@ export function chopCells(text, maxWidth) {
127
127
  }
128
128
  return lines;
129
129
  }
130
+ // [LAW:no-shared-mutable-globals] A private memo, written only by `graphemes`.
131
+ let segmenter;
130
132
  /**
131
- * Returns the largest prefix of `text` whose cell width fits within `cap` cells.
132
- * No padding — the returned string may be narrower than `cap` when the next
133
- * character is wide and would overshoot. Never wider than `cap` cells.
133
+ * The grapheme clusters of `text`, in order: the unit `string-width` measures,
134
+ * and so the unit `cellLen` of a whole string is the sum of. Summing `cellLen`
135
+ * over code points instead disagrees with it on every glyph built from several:
136
+ * `❤️` is 1 + 0 by code point and 2 as a cluster, `👨‍👩‍👧` is 6 and 2, and a cut
137
+ * between two of its code points leaves half a glyph.
138
+ */
139
+ export function graphemes(text) {
140
+ segmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
141
+ return Array.from(segmenter.segment(text), (s) => s.segment);
142
+ }
143
+ /**
144
+ * Returns the largest prefix of `text` whose cell width fits within `cap` cells,
145
+ * cut between grapheme clusters. No padding — the returned string may be
146
+ * narrower than `cap` when the next glyph is wide and would overshoot. Never
147
+ * wider than `cap` cells.
134
148
  *
135
- * When the first character already exceeds `cap` cells, returns "" (the caller
136
- * must decide whether to force-take the character or skip it).
149
+ * When the first glyph already exceeds `cap` cells, returns "" (the caller
150
+ * must decide whether to force-take the glyph or skip it).
137
151
  */
138
152
  export function cellFit(text, cap) {
153
+ return fitClusters(graphemes(text), cap).join("");
154
+ }
155
+ /** `cellFit` from the other end: the largest suffix of `text` within `cap` cells. */
156
+ export function cellFitEnd(text, cap) {
157
+ return fitClusters(graphemes(text).reverse(), cap).reverse().join("");
158
+ }
159
+ function fitClusters(clusters, cap) {
139
160
  let w = 0;
140
- let i = 0;
141
- for (const ch of text) {
142
- const cw = cellLen(ch);
161
+ let n = 0;
162
+ for (const cluster of clusters) {
163
+ const cw = cellLen(cluster);
143
164
  if (w + cw > cap)
144
165
  break;
145
166
  w += cw;
146
- i += ch.length;
167
+ n++;
147
168
  }
148
- return text.slice(0, i);
169
+ return clusters.slice(0, n);
149
170
  }
150
171
  /**
151
172
  * Returns the largest code-unit end offset starting from `startCU` whose
@@ -215,14 +215,10 @@ export declare function blendRgb(color1: ColorRgba, color2: ColorRgba, crossFade
215
215
  * A terminal theme — surface/foreground baseline, the ANSI 16/256 LUT, and a
216
216
  * semantic palette.
217
217
  *
218
- * **`ansiColors` is the canonical ANSI table, not a per-theme override.** All
219
- * preset themes in this codebase use `STANDARD_TABLE` for `ansiColors`. Theme
220
- * identity lives entirely in `palette` (primary, accent, text-*, surface, ...);
221
- * `ColorSpec.parse("red")` is intentionally consistent across themes — same
222
- * design as Textual itself, which models theming at the truecolor/semantic
223
- * layer rather than overloading the ANSI 16. Custom callers building bespoke
224
- * themes that *do* want to retint ANSI 16 are free to pass their own
225
- * `ColorTable`; that path is supported but not how the built-in presets work.
218
+ * **`ansiColors` is the theme's own sixteen colours**, the shades a terminal
219
+ * showing this theme draws `red`, `blue` and the rest in. `ColorSpec.parse("red")`
220
+ * is ANSI colour 1 under every theme; which red that is, is the theme's to say,
221
+ * as it is in Rich, where each `TerminalTheme` carries its own table.
226
222
  */
227
223
  export declare class TerminalTheme {
228
224
  readonly backgroundColor: ColorRgba;