@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
@@ -23,16 +23,17 @@ function parseStack(error) {
23
23
  const lines = stack.split("\n");
24
24
  const frames = [];
25
25
  for (const line of lines) {
26
- const trimmed = line.trim();
27
- // Node.js format: " at functionName (file:line:column)"
28
- // or: " at file:line:column"
29
- const match = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?/.exec(trimmed);
26
+ // The two V8 frame shapes located by file:line:column: "at fn (file:…)"
27
+ // and "at file:…", either one prefixed "async " when the frame is an
28
+ // awaited call. The marker is not part of any name, so it is read and
29
+ // dropped; the lookahead keeps "at async (file:…)" — a function named
30
+ // `async` — a name.
31
+ const match = /^at\s+(?:async\s+(?!\())?(?:(.+?)\s+\()?(.+?):(\d+):\d+\)?/.exec(line.trim());
30
32
  if (match) {
31
33
  frames.push({
32
34
  function: match[1] || undefined,
33
35
  file: match[2],
34
36
  line: parseInt(match[3], 10),
35
- column: parseInt(match[4], 10),
36
37
  });
37
38
  }
38
39
  }
@@ -100,10 +101,8 @@ export class Traceback {
100
101
  yield new Segment(" ");
101
102
  }
102
103
  yield new Segment(frame.file, pathStyle);
103
- if (frame.line !== undefined) {
104
- yield new Segment(":");
105
- yield new Segment(String(frame.line), lineNoStyle);
106
- }
104
+ yield new Segment(":");
105
+ yield new Segment(String(frame.line), lineNoStyle);
107
106
  yield Segment.line();
108
107
  }
109
108
  }
@@ -21,6 +21,13 @@ export declare class Tree implements Renderable, Measurable {
21
21
  constructor(label: string | RichText | Renderable, options?: TreeOptions);
22
22
  add(label: string | RichText | Renderable, options?: TreeOptions): Tree;
23
23
  render(rawOptions: RenderOptions): Iterable<Segment>;
24
+ /**
25
+ * The rows `render` and `measure` both read, entered in exactly one place so
26
+ * the two cannot start the walk differently. A hidden root takes its own row
27
+ * and the column its children hang from with it, as Rich does — its children
28
+ * stand at the left edge, and its guide style still reaches their guides.
29
+ */
30
+ private _walk;
24
31
  /**
25
32
  * The tree walked into the rows it emits, one row per label, guides first.
26
33
  *
@@ -29,11 +36,15 @@ export declare class Tree implements Renderable, Measurable {
29
36
  * `measure` reads each row's width off the same sequence, so the two cannot
30
37
  * come to disagree about how many rows there are or how wide the guides on
31
38
  * them run — which they would the moment a second walk existed.
39
+ *
40
+ * [LAW:dataflow-not-control-flow] Every node, at every depth, is walked by
41
+ * this one recursion: its children get the columns above it, settled, plus a
42
+ * branch of their own, so no depth is drawn by a rule of its own.
32
43
  */
33
44
  private _rows;
34
45
  /**
35
- * One row of the tree: its guides, then its label in whatever width the
36
- * guides left.
46
+ * One row of the tree: its label in whatever width the guides left, each of
47
+ * the label's lines led by the guides.
37
48
  *
38
49
  * [LAW:single-enforcer] The row's width is divided in exactly one place, here.
39
50
  * Before this, each call site emitted its guides and then handed the label
@@ -44,7 +55,10 @@ export declare class Tree implements Renderable, Measurable {
44
55
  * its share off the division. `options` arrives parsed from `render`.
45
56
  *
46
57
  * [LAW:single-enforcer] Tree also owns its row boundaries here rather than
47
- * depending on label renderables to invent trailing newlines.
58
+ * depending on label renderables to invent trailing newlines: the label's
59
+ * output is cut into lines and every line ends where Tree ends it. A label
60
+ * that emits nothing still holds its row, and one that ends in a newline of
61
+ * its own does not add a blank one.
48
62
  */
49
63
  private _renderRow;
50
64
  measure(options: RenderOptions): {
@@ -3,30 +3,19 @@
3
3
  */
4
4
  import { Segment } from "../core/segment.js";
5
5
  import { cellFit, cellLen, asCellCol } from "../core/cells.js";
6
- import { NULL_STYLE } from "../core/style.js";
7
- import { RichText } from "../core/text.js";
8
- import { getStyle, isMeasurable, withBoundedWidth, withCellWidth } from "../core/protocol.js";
6
+ import { Style, NULL_STYLE } from "../core/style.js";
7
+ import { embed } from "./embed.js";
8
+ import { drawable, getStyle, isMeasurable, stackedHeight, withBoundedWidth, withCellWidth } from "../core/protocol.js";
9
9
  import { Measurement } from "../core/measure.js";
10
- // Guide characters
11
- const GUIDE_BRANCH = "├── ";
12
- const GUIDE_LAST = "└── ";
13
- const GUIDE_VERT = "│ ";
14
- const GUIDE_SPACE = " ";
15
- // ASCII fallback
16
- const GUIDE_BRANCH_ASCII = "+-- ";
17
- const GUIDE_LAST_ASCII = "+-- ";
18
- const GUIDE_VERT_ASCII = "| ";
19
- function toRenderable(label) {
20
- if (typeof label === "string")
21
- return new RichText(label, { end: "" });
22
- if (label instanceof RichText) {
23
- // Ensure no trailing newline in tree labels
24
- const copy = label.copy();
25
- copy.end = "";
26
- return copy;
27
- }
28
- return label;
29
- }
10
+ const UNICODE_GUIDES = {
11
+ fork: { first: "├── ", rest: "│ " },
12
+ end: { first: "└── ", rest: " " },
13
+ };
14
+ const ASCII_GUIDES = {
15
+ fork: { first: "+-- ", rest: "| " },
16
+ end: { first: "`-- ", rest: " " },
17
+ };
18
+ const guideGlyphs = ({ fork, end }) => fork.first + fork.rest + end.first + end.rest;
30
19
  /**
31
20
  * How wide a label wants to be. A label with no `measure` cannot say, and the
32
21
  * offer is the honest stand-in — including when the offer is unbounded, which
@@ -45,7 +34,7 @@ export class Tree {
45
34
  guideStyle;
46
35
  style;
47
36
  constructor(label, options) {
48
- this.label = toRenderable(label);
37
+ this.label = embed(label);
49
38
  this.children = [];
50
39
  this.expanded = options?.expanded !== false;
51
40
  this.hideRoot = options?.hideRoot ?? false;
@@ -63,10 +52,20 @@ export class Tree {
63
52
  }
64
53
  *render(rawOptions) {
65
54
  const options = withBoundedWidth(rawOptions, this);
66
- for (const row of this._rows(options, [], !this.hideRoot)) {
55
+ for (const row of this._walk(options)) {
67
56
  yield* this._renderRow(options, row);
68
57
  }
69
58
  }
59
+ /**
60
+ * The rows `render` and `measure` both read, entered in exactly one place so
61
+ * the two cannot start the walk differently. A hidden root takes its own row
62
+ * and the column its children hang from with it, as Rich does — its children
63
+ * stand at the left edge, and its guide style still reaches their guides.
64
+ */
65
+ _walk(options) {
66
+ const rows = [...this._rows(drawable(options, UNICODE_GUIDES, ASCII_GUIDES, guideGlyphs), [], [])];
67
+ return this.hideRoot ? rows.slice(1).map((row) => ({ ...row, guides: row.guides.slice(1) })) : rows;
68
+ }
70
69
  /**
71
70
  * The tree walked into the rows it emits, one row per label, guides first.
72
71
  *
@@ -75,41 +74,24 @@ export class Tree {
75
74
  * `measure` reads each row's width off the same sequence, so the two cannot
76
75
  * come to disagree about how many rows there are or how wide the guides on
77
76
  * them run — which they would the moment a second walk existed.
77
+ *
78
+ * [LAW:dataflow-not-control-flow] Every node, at every depth, is walked by
79
+ * this one recursion: its children get the columns above it, settled, plus a
80
+ * branch of their own, so no depth is drawn by a rule of its own.
78
81
  */
79
- *_rows(options, prefixes, showLabel) {
80
- const ascii = options.asciiOnly ?? false;
81
- if (showLabel) {
82
- yield { guides: prefixes, guideStyles: [this.guideStyle], label: this.label };
83
- }
84
- if (!this.expanded)
85
- return;
86
- for (let i = 0; i < this.children.length; i++) {
87
- const child = this.children[i];
88
- const isLast = i === this.children.length - 1;
89
- const branch = ascii
90
- ? (isLast ? GUIDE_LAST_ASCII : GUIDE_BRANCH_ASCII)
91
- : (isLast ? GUIDE_LAST : GUIDE_BRANCH);
92
- const continuation = ascii
93
- ? (isLast ? GUIDE_SPACE : GUIDE_VERT_ASCII)
94
- : (isLast ? GUIDE_SPACE : GUIDE_VERT);
95
- // Child label with branch guide
96
- yield {
97
- guides: [...prefixes, branch],
98
- guideStyles: [child.guideStyle, this.guideStyle],
99
- label: child.label,
100
- };
101
- // Grandchildren with continuation prefix
102
- if (child.expanded && child.children.length > 0) {
103
- const grandPrefixes = [...prefixes, continuation];
104
- for (let j = 0; j < child.children.length; j++) {
105
- yield* child.children[j]._rows(options, grandPrefixes, true);
106
- }
107
- }
82
+ *_rows(glyphs, guides, inherited) {
83
+ yield { guides, label: this.label };
84
+ const children = this.expanded ? this.children : [];
85
+ const styles = [...inherited, this.guideStyle];
86
+ const above = guides.map((guide) => ({ ...guide, first: guide.rest }));
87
+ for (let i = 0; i < children.length; i++) {
88
+ const branch = i === children.length - 1 ? glyphs.end : glyphs.fork;
89
+ yield* children[i]._rows(glyphs, [...above, { ...branch, styles }], styles);
108
90
  }
109
91
  }
110
92
  /**
111
- * One row of the tree: its guides, then its label in whatever width the
112
- * guides left.
93
+ * One row of the tree: its label in whatever width the guides left, each of
94
+ * the label's lines led by the guides.
113
95
  *
114
96
  * [LAW:single-enforcer] The row's width is divided in exactly one place, here.
115
97
  * Before this, each call site emitted its guides and then handed the label
@@ -120,22 +102,31 @@ export class Tree {
120
102
  * its share off the division. `options` arrives parsed from `render`.
121
103
  *
122
104
  * [LAW:single-enforcer] Tree also owns its row boundaries here rather than
123
- * depending on label renderables to invent trailing newlines.
105
+ * depending on label renderables to invent trailing newlines: the label's
106
+ * output is cut into lines and every line ends where Tree ends it. A label
107
+ * that emits nothing still holds its row, and one that ends in a newline of
108
+ * its own does not add a blank one.
124
109
  */
125
110
  *_renderRow(options, row) {
126
- const guideStyle = row.guideStyles.map((style) => getStyle(options, style)).find((style) => !style.isNull);
127
- let left = options.maxWidth;
128
- for (const text of row.guides) {
129
- const piece = cellFit(text, asCellCol(left));
130
- if (piece.length > 0)
131
- yield new Segment(piece, guideStyle);
132
- left -= cellLen(piece);
111
+ const styles = row.guides.map((guide) => Style.combine(guide.styles.map((style) => getStyle(options, style))));
112
+ const width = Math.max(0, options.maxWidth - row.guides.reduce((sum, guide) => sum + cellLen(guide.first), 0));
113
+ // The guides are cropped by `cellFit`; the label is cropped too, so a label
114
+ // that ignores the width it is handed cannot push the row past the offer the
115
+ // guides were fitted into.
116
+ const lines = Segment.splitLines(Segment.cropLines(row.label.render({ ...options, maxWidth: width, height: stackedHeight(options.height) }), width));
117
+ if (lines.length === 0)
118
+ lines.push([]);
119
+ for (let i = 0; i < lines.length; i++) {
120
+ let left = options.maxWidth;
121
+ for (let g = 0; g < row.guides.length; g++) {
122
+ const piece = cellFit(i === 0 ? row.guides[g].first : row.guides[g].rest, asCellCol(left));
123
+ if (piece.length > 0)
124
+ yield new Segment(piece, styles[g]);
125
+ left -= cellLen(piece);
126
+ }
127
+ yield* lines[i];
128
+ yield Segment.line();
133
129
  }
134
- // The guides above are cropped by `cellFit`; the label was not, so a label
135
- // that ignores the width it is handed pushed the row past the offer the
136
- // guides had just been fitted into.
137
- yield* Segment.cropLines(row.label.render({ ...options, maxWidth: left }), left);
138
- yield Segment.line();
139
130
  }
140
131
  measure(options) {
141
132
  // The ceiling is parsed for the same reason the rows are: an unparsed NaN
@@ -147,8 +138,8 @@ export class Tree {
147
138
  // mode drew a 40-cell frame around nine cells of tree, and an unbounded
148
139
  // offer came back unbounded.
149
140
  let natural = 0;
150
- for (const row of this._rows(parsed, [], !this.hideRoot)) {
151
- const guideWidth = row.guides.reduce((sum, text) => sum + cellLen(text), 0);
141
+ for (const row of this._walk(parsed)) {
142
+ const guideWidth = row.guides.reduce((sum, guide) => sum + cellLen(guide.first), 0);
152
143
  natural = Math.max(natural, guideWidth + labelWidth(parsed, row.label));
153
144
  }
154
145
  const maximum = Math.min(natural, ceiling);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Viewport — a fixed number of rows onto a taller renderable, scrolled by an
3
+ * offset that cannot leave the content.
4
+ *
5
+ * Its rows, under the `Height` contract in `../core/protocol.ts`: the region's
6
+ * when it is given one; otherwise its own configured rows, or the content's
7
+ * full length when it has none, capped by any ceiling. It renders its content
8
+ * with no height, because the content's natural length is the scroll extent,
9
+ * and pads or crops what comes back to its rows — its rows are its own height,
10
+ * not a region someone else will shape.
11
+ *
12
+ * Its width is the width it is given, in both directions: each row is cropped
13
+ * or padded to it, so content that ignores its width cannot spill past the
14
+ * viewport's edge and the scrollbar, drawn in a gutter at that edge, lines up
15
+ * down every row. [LAW:dataflow-not-control-flow] A viewport with no scrollbar
16
+ * has a gutter zero cells wide, and every row takes the same path either way.
17
+ *
18
+ * [LAW:no-ambient-temporal-coupling] Neither half of what an offset is clamped
19
+ * against exists before a render: the rows come from the budget the render is
20
+ * handed, and the content's length from rendering it at the width it is handed.
21
+ * So `scrollTo`, `scrollBy` and `ensureVisible` do not move anything when they
22
+ * are called. Each queues a move, and the next render resolves the queue in
23
+ * call order, clamping after every move, against the rows and length that
24
+ * render found. Calling an operation before the first render, or three of them
25
+ * between two renders, is the same code path as calling one.
26
+ *
27
+ * Every cell of its rows, gutter included, is stamped as its own
28
+ * (`Segment.anchorLines`), around whatever anchors its content drew. So the
29
+ * composed frame names the viewport under any of its cells however deep it is
30
+ * nested, and a cell of a widget inside it names both: that is how the wheel
31
+ * finds the viewport to scroll. `canScrollBy` is how it passes over one that
32
+ * cannot move that way, to the viewport around it.
33
+ */
34
+ import { Segment } from "../core/segment.js";
35
+ import type { Style } from "../core/style.js";
36
+ import type { Measurable, Renderable, RenderOptions, Scrollable } from "../core/protocol.js";
37
+ /** How one part of a scrollbar draws each row it covers. */
38
+ export interface ScrollbarPart {
39
+ readonly glyph: string;
40
+ /** A style, or a theme name resolved when the viewport renders. */
41
+ readonly style: string | Style;
42
+ }
43
+ /**
44
+ * The look of a scrollbar: the thumb, over the rows of the track that stand for
45
+ * the lines in view, and the track, over the rest. Its gutter is as wide as the
46
+ * wider of the two glyphs.
47
+ */
48
+ export interface Scrollbar {
49
+ readonly thumb: ScrollbarPart;
50
+ readonly track: ScrollbarPart;
51
+ }
52
+ /** A heavy line for the thumb on a light line for the track. */
53
+ export declare const SCROLLBAR: Scrollbar;
54
+ export interface ViewportOptions {
55
+ /**
56
+ * The rows shown when the viewport is given no region. Absent, it shows the
57
+ * content's full length. A ceiling caps either.
58
+ */
59
+ rows?: number;
60
+ /** A scrollbar down the right edge. Absent, the content has the full width. */
61
+ scrollbar?: Scrollbar;
62
+ }
63
+ export declare class Viewport implements Renderable, Measurable, Scrollable {
64
+ /**
65
+ * What the viewport shows. Replace it to show new content from the same
66
+ * scroll position — a view rebuilt every frame keeps one `Viewport`.
67
+ */
68
+ content: Renderable;
69
+ readonly rows: number | undefined;
70
+ readonly scrollbar: Scrollbar;
71
+ private _offset;
72
+ private _extent;
73
+ private _moves;
74
+ constructor(content: Renderable, options?: ViewportOptions);
75
+ /**
76
+ * The first content line the last render showed. A move requested since is
77
+ * not reflected until the next render resolves it.
78
+ */
79
+ get offset(): number;
80
+ /** Scroll so `line` is the first line shown. */
81
+ scrollTo(line: number): void;
82
+ /** Scroll by `lines`: down when positive, up when negative. */
83
+ scrollBy(lines: number): void;
84
+ /**
85
+ * Whether `scrollBy(lines)` would move the viewport from where the moves
86
+ * queued so far leave it, against the rows and length the last render
87
+ * found.
88
+ */
89
+ canScrollBy(lines: number): boolean;
90
+ /**
91
+ * Scroll the least distance that shows lines `start` up to but not
92
+ * including `end` — lines of the content as it renders at the viewport's
93
+ * width, so an item that wraps spans more than one. A range already in view does not move; one taller than
94
+ * the viewport shows its first line at the top.
95
+ */
96
+ ensureVisible(start: number, end: number): void;
97
+ /**
98
+ * The width its content renders at, out of `width`: what the scrollbar's
99
+ * gutter leaves. Lines handed to `ensureVisible` are lines at this width.
100
+ */
101
+ contentWidth(width: number): number;
102
+ render(rawOptions: RenderOptions): Iterable<Segment>;
103
+ /**
104
+ * [LAW:one-source-of-truth] Where the queued moves leave the offset against
105
+ * `extent`: what a render commits, and what `canScrollBy` asks from.
106
+ * [LAW:dataflow-not-control-flow] The offset is re-clamped with or without
107
+ * queued moves: content that shrank since the last render pulls it back.
108
+ */
109
+ private resolve;
110
+ measure(rawOptions: RenderOptions): {
111
+ minimum: number;
112
+ maximum: number;
113
+ };
114
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Viewport — a fixed number of rows onto a taller renderable, scrolled by an
3
+ * offset that cannot leave the content.
4
+ *
5
+ * Its rows, under the `Height` contract in `../core/protocol.ts`: the region's
6
+ * when it is given one; otherwise its own configured rows, or the content's
7
+ * full length when it has none, capped by any ceiling. It renders its content
8
+ * with no height, because the content's natural length is the scroll extent,
9
+ * and pads or crops what comes back to its rows — its rows are its own height,
10
+ * not a region someone else will shape.
11
+ *
12
+ * Its width is the width it is given, in both directions: each row is cropped
13
+ * or padded to it, so content that ignores its width cannot spill past the
14
+ * viewport's edge and the scrollbar, drawn in a gutter at that edge, lines up
15
+ * down every row. [LAW:dataflow-not-control-flow] A viewport with no scrollbar
16
+ * has a gutter zero cells wide, and every row takes the same path either way.
17
+ *
18
+ * [LAW:no-ambient-temporal-coupling] Neither half of what an offset is clamped
19
+ * against exists before a render: the rows come from the budget the render is
20
+ * handed, and the content's length from rendering it at the width it is handed.
21
+ * So `scrollTo`, `scrollBy` and `ensureVisible` do not move anything when they
22
+ * are called. Each queues a move, and the next render resolves the queue in
23
+ * call order, clamping after every move, against the rows and length that
24
+ * render found. Calling an operation before the first render, or three of them
25
+ * between two renders, is the same code path as calling one.
26
+ *
27
+ * Every cell of its rows, gutter included, is stamped as its own
28
+ * (`Segment.anchorLines`), around whatever anchors its content drew. So the
29
+ * composed frame names the viewport under any of its cells however deep it is
30
+ * nested, and a cell of a widget inside it names both: that is how the wheel
31
+ * finds the viewport to scroll. `canScrollBy` is how it passes over one that
32
+ * cannot move that way, to the viewport around it.
33
+ */
34
+ import { Segment } from "../core/segment.js";
35
+ import { Measurement } from "../core/measure.js";
36
+ import { cellCount, cellLen } from "../core/cells.js";
37
+ import { NULL_STYLE } from "../core/style.js";
38
+ import { drawable, fitHeight, getStyle, isMeasurable, regionRows, withBoundedWidth, withCellWidth, } from "../core/protocol.js";
39
+ /** A heavy line for the thumb on a light line for the track. */
40
+ export const SCROLLBAR = {
41
+ thumb: { glyph: "┃", style: "scrollbar.thumb" },
42
+ track: { glyph: "│", style: "scrollbar.track" },
43
+ };
44
+ /** The absence of a scrollbar, as a gutter zero cells wide. */
45
+ const NO_SCROLLBAR = {
46
+ thumb: { glyph: "", style: NULL_STYLE },
47
+ track: { glyph: "", style: NULL_STYLE },
48
+ };
49
+ export class Viewport {
50
+ /**
51
+ * What the viewport shows. Replace it to show new content from the same
52
+ * scroll position — a view rebuilt every frame keeps one `Viewport`.
53
+ */
54
+ content;
55
+ rows;
56
+ scrollbar;
57
+ _offset = 0;
58
+ // Nothing rendered, nothing shown: no move can take until the first render.
59
+ _extent = { rows: 0, lines: 0 };
60
+ _moves = [];
61
+ constructor(content, options = {}) {
62
+ this.content = content;
63
+ this.rows = options.rows;
64
+ this.scrollbar = options.scrollbar ?? NO_SCROLLBAR;
65
+ }
66
+ /**
67
+ * The first content line the last render showed. A move requested since is
68
+ * not reflected until the next render resolves it.
69
+ */
70
+ get offset() {
71
+ return this._offset;
72
+ }
73
+ /** Scroll so `line` is the first line shown. */
74
+ scrollTo(line) {
75
+ this._moves.push(() => line);
76
+ }
77
+ /** Scroll by `lines`: down when positive, up when negative. */
78
+ scrollBy(lines) {
79
+ this._moves.push((offset) => offset + lines);
80
+ }
81
+ /**
82
+ * Whether `scrollBy(lines)` would move the viewport from where the moves
83
+ * queued so far leave it, against the rows and length the last render
84
+ * found.
85
+ */
86
+ canScrollBy(lines) {
87
+ const offset = this.resolve(this._extent);
88
+ return clampOffset(offset + lines, this._extent) !== offset;
89
+ }
90
+ /**
91
+ * Scroll the least distance that shows lines `start` up to but not
92
+ * including `end` — lines of the content as it renders at the viewport's
93
+ * width, so an item that wraps spans more than one. A range already in view does not move; one taller than
94
+ * the viewport shows its first line at the top.
95
+ */
96
+ ensureVisible(start, end) {
97
+ this._moves.push((offset, { rows }) => Math.min(start, Math.max(offset, end - rows)));
98
+ }
99
+ /**
100
+ * The width its content renders at, out of `width`: what the scrollbar's
101
+ * gutter leaves. Lines handed to `ensureVisible` are lines at this width.
102
+ */
103
+ contentWidth(width) {
104
+ return cellCount(width - gutterWidth(this.scrollbar));
105
+ }
106
+ *render(rawOptions) {
107
+ const { height, ...options } = withBoundedWidth(rawOptions, this);
108
+ const gutter = gutterWidth(this.scrollbar);
109
+ const contentWidth = this.contentWidth(options.maxWidth);
110
+ const lines = Segment.splitLines(this.content.render({ ...options, maxWidth: contentWidth }));
111
+ const extent = { rows: viewRows(height, this.rows, lines.length), lines: lines.length };
112
+ this._extent = extent;
113
+ this._offset = this.resolve(extent);
114
+ this._moves = [];
115
+ const shown = fitHeight(lines.slice(this._offset, this._offset + extent.rows), { rows: extent.rows, exact: true });
116
+ // [LAW:no-ambient-temporal-coupling] The thumb is drawn from the offset
117
+ // this render just resolved, not from `offset` as it stood before it.
118
+ const thumb = thumbRows(extent, this._offset);
119
+ // The content's cells and the gutter's drawn cells sum to the offer: a
120
+ // gutter wider than the whole offer is drawn cropped to it.
121
+ const drawn = Math.min(gutter, options.maxWidth);
122
+ // The style reaches the padding too, so a glyph narrower than the gutter
123
+ // still fills its cell with its part's background.
124
+ const cell = ({ glyph, style }) => {
125
+ const resolved = getStyle(options, style);
126
+ return Segment.adjustLineLength([new Segment(glyph, resolved)], drawn, resolved);
127
+ };
128
+ // The gutter keeps the chosen scrollbar's width, so an ASCII stand-in
129
+ // padded into it leaves the content where `contentWidth` said it would be.
130
+ const bar = drawable(options, this.scrollbar, asciiScrollbar(this.scrollbar), scrollbarGlyphs);
131
+ const thumbCell = cell(bar.thumb);
132
+ const trackCell = cell(bar.track);
133
+ const rows = shown.map((line, row) => [
134
+ ...Segment.adjustLineLength(line, contentWidth),
135
+ ...(row >= thumb.start && row < thumb.end ? thumbCell : trackCell),
136
+ ]);
137
+ for (const line of Segment.anchorLines(rows, this)) {
138
+ yield* line;
139
+ yield Segment.line();
140
+ }
141
+ }
142
+ /**
143
+ * [LAW:one-source-of-truth] Where the queued moves leave the offset against
144
+ * `extent`: what a render commits, and what `canScrollBy` asks from.
145
+ * [LAW:dataflow-not-control-flow] The offset is re-clamped with or without
146
+ * queued moves: content that shrank since the last render pulls it back.
147
+ */
148
+ resolve(extent) {
149
+ return this._moves.reduce((offset, move) => clampOffset(move(offset, extent), extent), clampOffset(this._offset, extent));
150
+ }
151
+ measure(rawOptions) {
152
+ // Its width is its content's and its gutter's. `withCellWidth` and not
153
+ // `withBoundedWidth`: the bounded parse asks this very method for the
154
+ // natural width.
155
+ const options = withCellWidth(rawOptions);
156
+ const gutter = gutterWidth(this.scrollbar);
157
+ const inner = { ...options, maxWidth: this.contentWidth(options.maxWidth) };
158
+ const content = isMeasurable(this.content)
159
+ ? Measurement.get(inner, this.content)
160
+ : new Measurement(Math.min(1, inner.maxWidth), inner.maxWidth);
161
+ return new Measurement(content.minimum + gutter, content.maximum + gutter).withMaximum(options.maxWidth);
162
+ }
163
+ }
164
+ const scrollbarGlyphs = ({ thumb, track }) => thumb.glyph + track.glyph;
165
+ /** `scrollbar` in its own styles, drawn in ASCII. */
166
+ function asciiScrollbar({ thumb, track }) {
167
+ return { thumb: { ...thumb, glyph: "#" }, track: { ...track, glyph: "|" } };
168
+ }
169
+ /** The cells a scrollbar's gutter takes from the content: its wider glyph. */
170
+ function gutterWidth({ thumb, track }) {
171
+ return Math.max(cellLen(thumb.glyph), cellLen(track.glyph));
172
+ }
173
+ /**
174
+ * The rows of the track the thumb covers, from `start` up to but not including
175
+ * `end`: its length is the share of the content in view, and its position the
176
+ * share of the scroll travelled. Content that fits fills the track.
177
+ *
178
+ * An end of the track means an end of the content. Rounded alone, the position
179
+ * reached the bottom while lines were still hidden below — at 10 rows of 20
180
+ * lines, offset 9 rounds onto the last position. So an offset short of an end
181
+ * is held one row off it, wherever the track has a row between its ends.
182
+ */
183
+ function thumbRows({ rows, lines }, offset) {
184
+ const size = Math.min(rows, Math.max(1, Math.round((rows * rows) / Math.max(lines, rows, 1))));
185
+ const travel = rows - size;
186
+ const last = Math.max(1, lines - rows);
187
+ const proportional = Math.round((offset * travel) / last);
188
+ const start = Math.max(Math.min(proportional, travel - Math.min(last - offset, 1)), Math.min(offset, 1, travel));
189
+ return { start, end: start + size };
190
+ }
191
+ /** The rows a viewport shows, from its budget, its own configured rows, and its content's length. */
192
+ function viewRows(height, rows, lines) {
193
+ return regionRows(height) ?? cellCount(Math.min(rows ?? lines, height?.rows ?? Infinity));
194
+ }
195
+ /** An offset held to the lines that exist: never above the first, never past the last full view. */
196
+ function clampOffset(offset, { rows, lines }) {
197
+ return cellCount(Math.min(offset, lines - rows));
198
+ }
@@ -77,20 +77,19 @@ function asColor(value, func) {
77
77
  }
78
78
  return parseHexColor(value);
79
79
  }
80
+ // [LAW:single-enforcer] Every colour function is fixed-arity, so the engine's
81
+ // arity gate refuses a missing or surplus argument before the body runs.
80
82
  function colorFunc(argTypes, fn) {
81
- return { fn, argTypes, returnType: "string" };
83
+ return { fn, argTypes, arity: { kind: "exact" }, returnType: "string" };
82
84
  }
83
85
  /**
84
86
  * The gate every *numeric* argument crosses, with an optional inclusive range.
85
87
  *
86
- * [LAW:single-enforcer] One check for the whole family. The template engine
87
- * treats a trailing slot as variadic, so an under-supplied call
88
- * (`{{ darken "#102030" }}`) reaches the body with `undefined` rather than
89
- * failing at the gate. Without this, that `undefined` propagated into the color
90
- * math and surfaced as `ColorRgba.red must be an integer in [0, 255]; got NaN`
91
- * — loud, but pointing at a channel the author never wrote, three layers from
92
- * the missing argument. An error that does not locate its cause is barely
93
- * better than a silent one. [LAW:no-silent-failure]
88
+ * [LAW:single-enforcer] One check for every `"float"` slot. An `"int"` slot is
89
+ * a safe integer by the engine's gate and needs none, but a `"float"` slot admits
90
+ * NaN and Infinity (legitimate IEEE-754 values), which would otherwise surface
91
+ * as `ColorRgba.red must be an integer in [0, 255]; got NaN` — loud, but
92
+ * pointing at a channel the author never wrote. [LAW:no-silent-failure]
94
93
  */
95
94
  function asAmount(value, func, param, range) {
96
95
  const inRange = range === undefined || (value >= range.min && value <= range.max);
@@ -98,8 +97,7 @@ function asAmount(value, func, param, range) {
98
97
  const bound = range === undefined
99
98
  ? "a finite number"
100
99
  : `within ${range.min}..${range.max} (${range.note})`;
101
- throw new RangeError(`${func}'s ${param} must be ${bound}, got ${value}` +
102
- (value === undefined ? ` — ${func} takes it as its last argument` : ""));
100
+ throw new RangeError(`${func}'s ${param} must be ${bound}, got ${value}`);
103
101
  }
104
102
  return value;
105
103
  }
@@ -109,8 +107,8 @@ function asAmount(value, func, param, range) {
109
107
  // second one (colorMath's `lighten` is literally `darken(c, -levels)`). Both
110
108
  // names ship because "lighten by 2" and "darken by -2" are not equally
111
109
  // readable at a call site, and the operation is genuinely bidirectional.
112
- const darkenFunc = colorFunc(["string", "int"], ((hex, levels) => darken(asColor(hex, "darken"), asAmount(levels, "darken", "levels")).hex));
113
- const lightenFunc = colorFunc(["string", "int"], ((hex, levels) => darken(asColor(hex, "lighten"), -asAmount(levels, "lighten", "levels")).hex));
110
+ const darkenFunc = colorFunc(["string", "int"], ((hex, levels) => darken(asColor(hex, "darken"), levels).hex));
111
+ const lightenFunc = colorFunc(["string", "int"], ((hex, levels) => darken(asColor(hex, "lighten"), -levels).hex));
114
112
  // --- Blending ---
115
113
  //
116
114
  // [LAW:one-type-per-behavior] There is no separate `alphaBlend` binding.
@@ -120,11 +120,13 @@ export function renderTemplate(engine, source, scope = {}, options) {
120
120
  return Array.from(rt.render({
121
121
  maxWidth: options?.maxWidth ?? 400,
122
122
  isTerminal: true,
123
- encoding: "utf-8",
124
123
  }));
125
124
  }
126
125
  catch (e) {
127
- return [new Segment(`[error: ${String(e).slice(0, 80)}]`, safeErrorStyle(options?.errorStyle))];
126
+ // An engine error's `message` is its one-line summary; `String(e)` adds a
127
+ // multi-line code frame, which would break this single segment across lines.
128
+ const summary = e instanceof Error ? e.message : String(e);
129
+ return [new Segment(`[error: ${summary.slice(0, 80)}]`, safeErrorStyle(options?.errorStyle))];
128
130
  }
129
131
  }
130
132
  // [LAW:single-enforcer] The error-path Style must not itself throw, or