@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.
- package/README.md +13 -26
- package/dist/core/anchor.d.ts +45 -0
- package/dist/core/anchor.js +39 -0
- package/dist/core/ansi.d.ts +65 -0
- package/dist/core/ansi.js +231 -0
- package/dist/core/box.d.ts +0 -1
- package/dist/core/box.js +6 -18
- package/dist/core/cells.d.ts +16 -5
- package/dist/core/cells.js +31 -10
- package/dist/core/color.d.ts +4 -8
- package/dist/core/color.js +28 -19
- package/dist/core/console.d.ts +13 -4
- package/dist/core/console.js +91 -36
- package/dist/core/export-html.d.ts +40 -5
- package/dist/core/export-html.js +111 -17
- package/dist/core/markup.d.ts +27 -5
- package/dist/core/markup.js +282 -170
- package/dist/core/measure.d.ts +1 -1
- package/dist/core/measure.js +2 -1
- package/dist/core/place.d.ts +30 -0
- package/dist/core/place.js +51 -0
- package/dist/core/pretty.d.ts +3 -0
- package/dist/core/pretty.js +5 -1
- package/dist/core/protocol.d.ts +136 -6
- package/dist/core/protocol.js +64 -2
- package/dist/core/render.d.ts +2 -2
- package/dist/core/render.js +2 -2
- package/dist/core/segment.d.ts +23 -4
- package/dist/core/segment.js +75 -16
- package/dist/core/spinnerData.d.ts +2 -0
- package/dist/core/spinnerData.js +3 -1
- package/dist/core/strip.d.ts +3 -7
- package/dist/core/strip.js +32 -35
- package/dist/core/style.d.ts +25 -0
- package/dist/core/style.js +61 -89
- package/dist/core/text.d.ts +16 -15
- package/dist/core/text.js +62 -76
- package/dist/host/app.d.ts +121 -0
- package/dist/host/app.js +255 -0
- package/dist/host/host-environment.d.ts +26 -0
- package/dist/host/host-environment.js +44 -0
- package/dist/host/index.d.ts +4 -2
- package/dist/host/index.js +2 -1
- package/dist/host/terminal-host.d.ts +43 -7
- package/dist/host/terminal-host.js +57 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -1
- package/dist/node/terminal-host.d.ts +31 -2
- package/dist/node/terminal-host.js +69 -2
- package/dist/node/traceback.d.ts +16 -6
- package/dist/node/traceback.js +30 -8
- package/dist/renderables/align.d.ts +2 -2
- package/dist/renderables/align.js +6 -19
- package/dist/renderables/columns.js +4 -20
- package/dist/renderables/embed.d.ts +50 -0
- package/dist/renderables/embed.js +71 -0
- package/dist/renderables/group.js +3 -1
- package/dist/renderables/layout.js +31 -34
- package/dist/renderables/live.d.ts +6 -3
- package/dist/renderables/live.js +89 -45
- package/dist/renderables/markdown.d.ts +1 -1
- package/dist/renderables/markdown.js +6 -4
- package/dist/renderables/padding.js +3 -2
- package/dist/renderables/panel.js +26 -41
- package/dist/renderables/progress.js +5 -8
- package/dist/renderables/progressBar.js +6 -5
- package/dist/renderables/rule.d.ts +3 -2
- package/dist/renderables/rule.js +9 -9
- package/dist/renderables/spinner.d.ts +5 -3
- package/dist/renderables/spinner.js +14 -8
- package/dist/renderables/syntax.d.ts +1 -1
- package/dist/renderables/syntax.js +2 -1
- package/dist/renderables/table.d.ts +26 -14
- package/dist/renderables/table.js +239 -141
- package/dist/renderables/traceback.js +8 -9
- package/dist/renderables/tree.d.ts +17 -3
- package/dist/renderables/tree.js +62 -71
- package/dist/renderables/viewport.d.ts +114 -0
- package/dist/renderables/viewport.js +198 -0
- package/dist/template-bindings/color-funcs.js +11 -13
- package/dist/template-bindings/index.js +4 -2
- package/dist/template-bindings/palette-funcs.js +6 -16
- package/dist/template-bindings/style-funcs.js +4 -0
- package/dist/themes/data/ansi-tables.d.ts +14 -0
- package/dist/themes/data/ansi-tables.js +49 -0
- package/dist/themes/data/atom-one-dark.js +19 -0
- package/dist/themes/data/atom-one-light.js +19 -0
- package/dist/themes/data/catppuccin-frappe.js +19 -0
- package/dist/themes/data/catppuccin-latte.js +19 -0
- package/dist/themes/data/catppuccin-macchiato.js +19 -0
- package/dist/themes/data/catppuccin-mocha.js +19 -0
- package/dist/themes/data/cyberpunk.js +2 -0
- package/dist/themes/data/default.js +2 -0
- package/dist/themes/data/dracula.js +19 -0
- package/dist/themes/data/flexoki.js +19 -0
- package/dist/themes/data/gruvbox.js +19 -0
- package/dist/themes/data/monokai.js +19 -0
- package/dist/themes/data/nord.js +19 -0
- package/dist/themes/data/rose-pine-dawn.js +19 -0
- package/dist/themes/data/rose-pine-moon.js +19 -0
- package/dist/themes/data/rose-pine.js +19 -0
- package/dist/themes/data/solarized-dark.js +19 -0
- package/dist/themes/data/solarized-light.js +19 -0
- package/dist/themes/data/svg-export.js +19 -0
- package/dist/themes/data/textual-ansi.js +2 -0
- package/dist/themes/data/textual-dark.js +2 -0
- package/dist/themes/data/textual-light.js +2 -0
- package/dist/themes/data/tokyo-night.js +19 -0
- package/dist/themes/data/types.d.ts +16 -0
- package/dist/themes/data/types.js +10 -1
- package/dist/themes/registry.d.ts +3 -2
- package/dist/themes/registry.js +13 -1
- package/dist/themes/terminalThemes.d.ts +2 -7
- package/dist/themes/terminalThemes.js +4 -9
- package/dist/widgets/button.d.ts +1 -1
- package/dist/widgets/button.js +4 -2
- package/dist/widgets/checkbox.d.ts +2 -2
- package/dist/widgets/checkbox.js +5 -3
- package/dist/widgets/dropdown.d.ts +2 -2
- package/dist/widgets/dropdown.js +13 -16
- package/dist/widgets/event-router.d.ts +21 -28
- package/dist/widgets/event-router.js +80 -73
- package/dist/widgets/focus-manager.d.ts +25 -6
- package/dist/widgets/focus-manager.js +63 -65
- package/dist/widgets/hit.d.ts +64 -0
- package/dist/widgets/hit.js +113 -0
- package/dist/widgets/index.d.ts +5 -7
- package/dist/widgets/index.js +3 -4
- package/dist/widgets/slider.d.ts +2 -2
- package/dist/widgets/slider.js +7 -7
- package/dist/widgets/static-item.d.ts +9 -15
- package/dist/widgets/static-item.js +9 -15
- package/dist/widgets/text-input.d.ts +2 -2
- package/dist/widgets/text-input.js +17 -14
- package/dist/widgets/toggle.d.ts +1 -1
- package/dist/widgets/toggle.js +3 -2
- package/dist/widgets/types.d.ts +14 -35
- package/dist/widgets/types.js +0 -1
- package/dist/widgets/widget-app.d.ts +63 -0
- package/dist/widgets/widget-app.js +199 -0
- package/dist/widgets/widget-base.d.ts +4 -6
- package/dist/widgets/widget-base.js +22 -30
- package/package.json +12 -4
- package/dist/host/host-stream.d.ts +0 -30
- package/dist/host/host-stream.js +0 -35
- package/dist/widgets/screen.d.ts +0 -98
- 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`, `
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
router.start();
|
|
316
|
+
await app.run();
|
|
317
|
+
console.log(`hello, ${name.value}`);
|
|
332
318
|
```
|
|
333
319
|
|
|
334
|
-
`
|
|
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
|
|
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
|
+
}
|
package/dist/core/box.d.ts
CHANGED
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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(""));
|
package/dist/core/cells.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
|
88
|
-
* must decide whether to force-take the
|
|
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
|
package/dist/core/cells.js
CHANGED
|
@@ -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
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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
|
|
136
|
-
* must decide whether to force-take the
|
|
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
|
|
141
|
-
for (const
|
|
142
|
-
const cw = cellLen(
|
|
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
|
-
|
|
167
|
+
n++;
|
|
147
168
|
}
|
|
148
|
-
return
|
|
169
|
+
return clusters.slice(0, n);
|
|
149
170
|
}
|
|
150
171
|
/**
|
|
151
172
|
* Returns the largest code-unit end offset starting from `startCU` whose
|
package/dist/core/color.d.ts
CHANGED
|
@@ -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
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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;
|