@promptctl/rich-js 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +9 -7
  2. package/dist/core/box.d.ts +3 -1
  3. package/dist/core/box.js +6 -13
  4. package/dist/core/cells.d.ts +61 -28
  5. package/dist/core/cells.js +171 -124
  6. package/dist/core/clock.d.ts +48 -0
  7. package/dist/core/clock.js +42 -0
  8. package/dist/core/color.d.ts +98 -18
  9. package/dist/core/color.js +331 -144
  10. package/dist/core/console.d.ts +38 -7
  11. package/dist/core/console.js +159 -90
  12. package/dist/core/easing.d.ts +86 -0
  13. package/dist/core/easing.js +171 -0
  14. package/dist/core/env.d.ts +16 -0
  15. package/dist/core/env.js +16 -0
  16. package/dist/core/export-html.js +1 -4
  17. package/dist/core/export-lines.d.ts +22 -13
  18. package/dist/core/export-lines.js +37 -19
  19. package/dist/core/export-svg.d.ts +49 -0
  20. package/dist/core/export-svg.js +213 -0
  21. package/dist/core/fnv1a.d.ts +10 -0
  22. package/dist/core/fnv1a.js +17 -0
  23. package/dist/core/highlighter.d.ts +2 -2
  24. package/dist/core/highlighter.js +113 -47
  25. package/dist/core/json.js +1 -1
  26. package/dist/core/markup.d.ts +28 -7
  27. package/dist/core/markup.js +235 -312
  28. package/dist/core/measure.d.ts +3 -3
  29. package/dist/core/measure.js +12 -7
  30. package/dist/core/memo.d.ts +19 -0
  31. package/dist/core/memo.js +34 -0
  32. package/dist/core/osc8.d.ts +6 -6
  33. package/dist/core/osc8.js +9 -21
  34. package/dist/core/paint.d.ts +107 -0
  35. package/dist/core/paint.js +150 -0
  36. package/dist/core/place.d.ts +1 -1
  37. package/dist/core/place.js +4 -6
  38. package/dist/core/pretty.d.ts +105 -2
  39. package/dist/core/pretty.js +527 -97
  40. package/dist/core/protocol.d.ts +41 -3
  41. package/dist/core/protocol.js +20 -0
  42. package/dist/core/render.d.ts +33 -30
  43. package/dist/core/render.js +43 -83
  44. package/dist/core/segment.d.ts +6 -5
  45. package/dist/core/segment.js +11 -15
  46. package/dist/core/strip.d.ts +13 -2
  47. package/dist/core/strip.js +44 -23
  48. package/dist/core/style.d.ts +27 -7
  49. package/dist/core/style.js +97 -34
  50. package/dist/core/text.d.ts +43 -16
  51. package/dist/core/text.js +180 -59
  52. package/dist/core/wrap.d.ts +5 -0
  53. package/dist/core/wrap.js +16 -3
  54. package/dist/host/app.d.ts +31 -19
  55. package/dist/host/app.js +90 -60
  56. package/dist/host/index.d.ts +2 -1
  57. package/dist/host/terminal-host.d.ts +3 -2
  58. package/dist/index.d.ts +17 -10
  59. package/dist/index.js +11 -4
  60. package/dist/node/prompt.d.ts +13 -0
  61. package/dist/node/prompt.js +30 -9
  62. package/dist/node/save.d.ts +4 -3
  63. package/dist/node/save.js +6 -3
  64. package/dist/node/terminal-host.d.ts +3 -2
  65. package/dist/node/traceback.d.ts +2 -1
  66. package/dist/node/traceback.js +3 -39
  67. package/dist/renderables/align.js +2 -9
  68. package/dist/renderables/columns.d.ts +45 -29
  69. package/dist/renderables/columns.js +142 -103
  70. package/dist/renderables/constrain.js +6 -12
  71. package/dist/renderables/effect.d.ts +98 -0
  72. package/dist/renderables/effect.js +160 -0
  73. package/dist/renderables/embed.d.ts +87 -30
  74. package/dist/renderables/embed.js +134 -45
  75. package/dist/renderables/group.d.ts +6 -3
  76. package/dist/renderables/group.js +5 -0
  77. package/dist/renderables/layout.d.ts +6 -23
  78. package/dist/renderables/layout.js +24 -113
  79. package/dist/renderables/live.d.ts +40 -16
  80. package/dist/renderables/live.js +96 -83
  81. package/dist/renderables/markdown.d.ts +16 -2
  82. package/dist/renderables/markdown.js +521 -118
  83. package/dist/renderables/padding.d.ts +22 -1
  84. package/dist/renderables/padding.js +30 -24
  85. package/dist/renderables/panel.d.ts +31 -33
  86. package/dist/renderables/panel.js +137 -141
  87. package/dist/renderables/progress.d.ts +104 -16
  88. package/dist/renderables/progress.js +205 -81
  89. package/dist/renderables/progressBar.d.ts +2 -2
  90. package/dist/renderables/progressBar.js +30 -19
  91. package/dist/renderables/prompt.d.ts +37 -15
  92. package/dist/renderables/prompt.js +125 -85
  93. package/dist/renderables/ratio.d.ts +123 -0
  94. package/dist/renderables/ratio.js +213 -0
  95. package/dist/renderables/rule.d.ts +1 -0
  96. package/dist/renderables/rule.js +44 -48
  97. package/dist/renderables/spinner.d.ts +28 -2
  98. package/dist/renderables/spinner.js +52 -19
  99. package/dist/renderables/status.d.ts +13 -7
  100. package/dist/renderables/status.js +15 -29
  101. package/dist/renderables/syntax.d.ts +37 -7
  102. package/dist/renderables/syntax.js +134 -83
  103. package/dist/renderables/table.d.ts +58 -20
  104. package/dist/renderables/table.js +370 -192
  105. package/dist/renderables/traceback.d.ts +5 -4
  106. package/dist/renderables/traceback.js +239 -68
  107. package/dist/renderables/tree.d.ts +10 -2
  108. package/dist/renderables/tree.js +61 -34
  109. package/dist/renderables/viewport.js +2 -4
  110. package/dist/template-bindings/color-funcs.d.ts +31 -9
  111. package/dist/template-bindings/color-funcs.js +40 -14
  112. package/dist/template-bindings/helpers.d.ts +4 -2
  113. package/dist/template-bindings/helpers.js +5 -20
  114. package/dist/template-bindings/index.d.ts +29 -17
  115. package/dist/template-bindings/index.js +36 -20
  116. package/dist/template-bindings/palette-funcs.d.ts +3 -1
  117. package/dist/template-bindings/palette-funcs.js +11 -7
  118. package/dist/template-bindings/style-funcs.js +4 -2
  119. package/dist/themes/buildPalette.d.ts +24 -3
  120. package/dist/themes/buildPalette.js +52 -8
  121. package/dist/themes/colorMath.d.ts +29 -17
  122. package/dist/themes/colorMath.js +74 -48
  123. package/dist/themes/colorRef.d.ts +2 -17
  124. package/dist/themes/colorRef.js +13 -31
  125. package/dist/themes/palette.d.ts +24 -0
  126. package/dist/themes/palette.js +46 -3
  127. package/dist/themes/ramp.d.ts +21 -34
  128. package/dist/themes/ramp.js +23 -42
  129. package/dist/themes/registry.js +39 -28
  130. package/dist/widgets/button.d.ts +2 -6
  131. package/dist/widgets/button.js +9 -40
  132. package/dist/widgets/checkbox.d.ts +2 -5
  133. package/dist/widgets/checkbox.js +9 -31
  134. package/dist/widgets/dropdown.d.ts +2 -5
  135. package/dist/widgets/dropdown.js +14 -48
  136. package/dist/widgets/ink.d.ts +22 -0
  137. package/dist/widgets/ink.js +31 -0
  138. package/dist/widgets/slider.d.ts +2 -5
  139. package/dist/widgets/slider.js +11 -34
  140. package/dist/widgets/text-input.d.ts +19 -10
  141. package/dist/widgets/text-input.js +156 -147
  142. package/dist/widgets/themed-widget.d.ts +20 -0
  143. package/dist/widgets/themed-widget.js +89 -0
  144. package/dist/widgets/toggle.d.ts +2 -5
  145. package/dist/widgets/toggle.js +8 -31
  146. package/dist/widgets/widget-app.d.ts +2 -0
  147. package/dist/widgets/widget-app.js +13 -6
  148. package/dist/widgets/widget-base.d.ts +2 -0
  149. package/dist/widgets/widget-base.js +9 -2
  150. package/package.json +11 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Traceback — renders error tracebacks with formatting.
3
3
  *
4
- * [LAW:effects-at-boundaries] Pure rendering: an `Error` in, `Segment`s out.
4
+ * [LAW:effects-at-boundaries] Pure rendering: a caught value in, `Segment`s out.
5
5
  * Installing this as the process-wide crash handler touches `process.on` and
6
6
  * `process.exit`, so that lives behind the node seam as `installTraceback` in
7
7
  * `src/node/traceback.ts` — which is what keeps this module, and therefore the
@@ -22,10 +22,11 @@ export interface TracebackOptions {
22
22
  maxFrames?: number;
23
23
  }
24
24
  export declare class Traceback implements Renderable {
25
- readonly error: Error;
25
+ /** What was caught, as it was handed in. */
26
+ readonly error: unknown;
26
27
  readonly maxFrames: number;
27
28
  readonly suppress: string[];
28
- constructor(error: Error, options?: TracebackOptions);
29
+ private readonly caught;
30
+ constructor(error: unknown, options?: TracebackOptions);
29
31
  render(options: RenderOptions): Iterable<Segment>;
30
- private _renderFrame;
31
32
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Traceback — renders error tracebacks with formatting.
3
3
  *
4
- * [LAW:effects-at-boundaries] Pure rendering: an `Error` in, `Segment`s out.
4
+ * [LAW:effects-at-boundaries] Pure rendering: a caught value in, `Segment`s out.
5
5
  * Installing this as the process-wide crash handler touches `process.on` and
6
6
  * `process.exit`, so that lives behind the node seam as `installTraceback` in
7
7
  * `src/node/traceback.ts` — which is what keeps this module, and therefore the
@@ -16,93 +16,264 @@
16
16
  * sized and coloured a source-code excerpt this renderer does not produce.
17
17
  */
18
18
  import { Segment } from "../core/segment.js";
19
- import { Style } from "../core/style.js";
20
- import { getStyle } from "../core/protocol.js";
21
- function parseStack(error) {
22
- const stack = error.stack ?? "";
23
- const lines = stack.split("\n");
24
- const frames = [];
25
- for (const line of lines) {
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());
32
- if (match) {
33
- frames.push({
34
- function: match[1] || undefined,
35
- file: match[2],
36
- line: parseInt(match[3], 10),
37
- });
38
- }
19
+ import { RichText } from "../core/text.js";
20
+ import { Pretty, UNSEEN_DATA_BOUNDS } from "../core/pretty.js";
21
+ import { layoutPadding } from "./padding.js";
22
+ /**
23
+ * [LAW:parse-dont-validate] The checkpoint between whatever a `catch` caught —
24
+ * JavaScript lets a program throw any value — and the report. Anything with a
25
+ * string `name` and a `message` is reported as the error it presents itself
26
+ * as: that is every native error from any realm, which `instanceof Error`
27
+ * would refuse for one from a `node:vm` context or an iframe, and any thrower
28
+ * shaped like one. A message that is not a string — a native error's, assigned
29
+ * after construction — is formatted by `Pretty` under the error's own name.
30
+ * Anything else is reported as the value it is, under `NonError`, formatted by
31
+ * `Pretty` so it reads the same in a browser as in node — with the frames of a
32
+ * stack it carries, when it carries one.
33
+ */
34
+ function readCaught(value) {
35
+ const fields = readFields(value);
36
+ if (fields !== undefined && typeof fields.name === "string" && fields.hasMessage) {
37
+ const { name, message } = fields;
38
+ const text = typeof message === "string" ? message : undefined;
39
+ return {
40
+ name: name || "Error",
41
+ message: text === undefined
42
+ ? prettyMessage(message)
43
+ : () => new RichText(text, { style: "traceback.text", end: "" }),
44
+ frames: parseStack(fields.stack, name, text),
45
+ };
39
46
  }
40
- return frames;
47
+ return { name: "NonError", message: prettyMessage(value), frames: parseStack(fields?.stack ?? "", undefined, undefined) };
48
+ }
49
+ /**
50
+ * The fields the report reads off a caught object, or `undefined` when it is
51
+ * not an object or reading them throws — a getter that throws, a `Proxy` whose
52
+ * trap does. Such a value is reported under `NonError`, where `Pretty` reads
53
+ * it again and marks the read that threw as `[Threw: …]`, so the fault stays
54
+ * in the report instead of taking the crash handler down with it.
55
+ */
56
+ function readFields(value) {
57
+ if (typeof value !== "object" || value === null)
58
+ return undefined;
59
+ try {
60
+ const record = value;
61
+ const stack = record.stack;
62
+ return {
63
+ name: record.name,
64
+ hasMessage: "message" in record,
65
+ message: record.message,
66
+ stack: typeof stack === "string" ? stack : "",
67
+ };
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ function prettyMessage(value) {
74
+ const pretty = new Pretty(value, UNSEEN_DATA_BOUNDS);
75
+ return (options) => pretty.toText(options);
76
+ }
77
+ /** The first line V8 writes into an error's stack: `String(error)` at the throw. */
78
+ function header(name, message) {
79
+ if (!name)
80
+ return message;
81
+ if (!message)
82
+ return name;
83
+ return `${name}: ${message}`;
84
+ }
85
+ /**
86
+ * Where the header that opens an error's stack ends, or `undefined` when the
87
+ * stack does not open with the header the error now has.
88
+ *
89
+ * V8 writes `String(error)`; node writes its own errors' code into the name
90
+ * there (`RangeError [ERR_X]: message`), so after the name may come one line of
91
+ * anything before `: ` and the message.
92
+ */
93
+ function headerEnd(stack, name, message) {
94
+ const endsLine = (at) => at === stack.length || stack[at] === "\n";
95
+ const plain = header(name, message);
96
+ if (stack.startsWith(plain) && endsLine(plain.length))
97
+ return plain.length;
98
+ if (!name || !stack.startsWith(name))
99
+ return undefined;
100
+ const tail = `: ${message}`;
101
+ const at = stack.indexOf(tail, name.length);
102
+ if (at < 0 || stack.slice(name.length, at).includes("\n"))
103
+ return undefined;
104
+ return endsLine(at + tail.length) ? at + tail.length : undefined;
105
+ }
106
+ /**
107
+ * Whether a stack line is shaped as V8's header — a name, then `:` or nothing —
108
+ * rather than as a SpiderMonkey or JavaScriptCore frame, whose `@` comes before
109
+ * the first `:` of its location. Read by shape, not by the error's name,
110
+ * because the name may have been reassigned after V8 wrote the header too.
111
+ */
112
+ function isHeaderShaped(line) {
113
+ const colon = line.indexOf(":");
114
+ return !(colon < 0 ? line : line.slice(0, colon)).includes("@");
115
+ }
116
+ /**
117
+ * The frames of a stack, in order.
118
+ *
119
+ * V8 opens a stack with the error's header, and a message can span lines that
120
+ * look like frames, so the header comes off first, as the exact text it is.
121
+ * When it does not open the stack, the message was reassigned after the throw
122
+ * and what precedes the first V8 frame is that stale header — all of it, when
123
+ * V8 recorded no frame, as under `Error.stackTraceLimit = 0`. SpiderMonkey and
124
+ * JavaScriptCore write no header, so a stack that opens with one of their
125
+ * frames is all frames. A value that is not an error has no name, so its stack is
126
+ * read from its first V8 frame, or whole when it has none.
127
+ */
128
+ function parseStack(stack, name, message) {
129
+ const end = name !== undefined && message !== undefined ? headerEnd(stack, name, message) : undefined;
130
+ const lines = stack.slice(end ?? 0).split("\n").map((line) => line.trim()).filter((line) => line !== "");
131
+ if (end !== undefined)
132
+ return lines.map(parseFrame);
133
+ const firstV8 = lines.findIndex((line) => V8_FRAME.test(line));
134
+ if (firstV8 >= 0)
135
+ return lines.slice(firstV8).map(parseFrame);
136
+ return name !== undefined && lines[0] !== undefined && isHeaderShaped(lines[0]) ? [] : lines.map(parseFrame);
137
+ }
138
+ /**
139
+ * V8 writes `at [async] [fn (]location[)]`. The `async` marker is not part of
140
+ * any name, so it is read and dropped; the lookahead keeps `at async (…)` — a
141
+ * function named `async` — a name.
142
+ */
143
+ const V8_FRAME = /^at\s+(?:async\s+(?!\())?(.*)$/;
144
+ /**
145
+ * SpiderMonkey and JavaScriptCore write `[fn]@location`, Firefox prefixing an
146
+ * awaited call's name with a cause up to a `*` (`async*`, `promise callback*`),
147
+ * which is dropped as V8's `async` is.
148
+ */
149
+ const AT_SIGN_FRAME = /^(?:[^@]*\*)?([^@]*)@(.*)$/;
150
+ function parseFrame(text) {
151
+ const v8 = V8_FRAME.exec(text);
152
+ if (v8) {
153
+ const body = v8[1];
154
+ const open = openingParen(body);
155
+ return open > 0
156
+ ? locate(body.slice(0, open).trim() || undefined, body.slice(open + 1, -1))
157
+ : locate(undefined, body);
158
+ }
159
+ const atSign = AT_SIGN_FRAME.exec(text);
160
+ if (atSign)
161
+ return locate(atSign[1] || undefined, atSign[2]);
162
+ return { function: undefined, location: text, line: undefined };
163
+ }
164
+ /**
165
+ * Where the parenthesised location that closes a V8 frame opens, or -1 when
166
+ * the frame does not end in one. Matched from the end, because an eval
167
+ * frame's location nests parentheses: `eval (eval at f (file:8:22), …)`.
168
+ */
169
+ function openingParen(body) {
170
+ if (!body.endsWith(")"))
171
+ return -1;
172
+ let depth = 0;
173
+ for (let i = body.length - 1; i >= 0; i--) {
174
+ if (body[i] === ")")
175
+ depth++;
176
+ else if (body[i] === "(" && --depth === 0)
177
+ return i;
178
+ }
179
+ return -1;
180
+ }
181
+ /**
182
+ * The shapes a location takes, each read as its file and line, first match
183
+ * wins. Code run by `eval` or `new Function` has no file of its own, so its
184
+ * frame is placed at the call that ran it, which is where the source is: V8
185
+ * nests that site inside the location (`eval at f (file:8:22), <anonymous>:1:1`,
186
+ * innermost first-parenthesised for an eval inside an eval), and Firefox appends
187
+ * the code to it (`file line 8 > eval:1:1`).
188
+ */
189
+ const LOCATIONS = [
190
+ /^eval at [^]*?\(([^()]+):(\d+):\d+\)/,
191
+ /^(.*?) line (\d+) > (?:eval|Function)/,
192
+ /^(.*?):(\d+)(?::\d+)?$/,
193
+ ];
194
+ function locate(fn, location) {
195
+ for (const shape of LOCATIONS) {
196
+ const match = shape.exec(location);
197
+ if (match)
198
+ return { function: fn, location: match[1], line: parseInt(match[2], 10) };
199
+ }
200
+ return { function: fn, location, line: undefined };
41
201
  }
42
202
  export class Traceback {
203
+ /** What was caught, as it was handed in. */
43
204
  error;
44
205
  maxFrames;
45
206
  suppress;
207
+ caught;
46
208
  constructor(error, options) {
47
209
  this.error = error;
210
+ this.caught = readCaught(error);
48
211
  this.maxFrames = options?.maxFrames ?? 100;
49
212
  this.suppress = options?.suppress ?? [];
50
213
  }
214
+ // [LAW:single-enforcer] The report is `RichText`, so fitting it to the width
215
+ // is the one fitting every text in the library gets: a line wider than the
216
+ // report wraps, and a location wider than the report folds across lines
217
+ // whole, as Rich's does. Written as bare segments, each line was cropped at
218
+ // the edge instead, and a frame lost its file and line to a long path.
51
219
  *render(options) {
52
- const excTypeStyle = getStyle(options, "traceback.exc_type");
53
- const textStyle = getStyle(options, "traceback.text");
54
- // Error type and message
55
- const errorName = this.error.name || "Error";
56
- const errorMessage = this.error.message || "";
57
- yield new Segment(errorName, excTypeStyle);
58
- yield new Segment(": ");
59
- yield new Segment(errorMessage, textStyle);
60
- yield Segment.line();
61
- yield Segment.line();
62
- // Stack frames — suppressed frames show file/line only (not removed)
63
- const frames = parseStack(this.error);
64
- const filteredFrames = this.suppress.length > 0
65
- ? frames.map((f) => ({
66
- ...f,
67
- suppressed: this.suppress.some((s) => f.file.includes(s)),
68
- }))
69
- : frames;
70
- let displayFrames = filteredFrames;
71
- if (this.maxFrames > 0 && displayFrames.length > this.maxFrames) {
220
+ const header = new RichText("", { end: "" });
221
+ const { name, message } = this.caught;
222
+ header.append(name, "traceback.exc_type");
223
+ header.append(": ");
224
+ // The message starts after the name, so a value `Pretty` lays out fits the
225
+ // width that is left on that line rather than overrunning it.
226
+ header.append(message({ ...options, maxWidth: Math.max(1, options.maxWidth - header.cellLength) }));
227
+ header.append("\n\n");
228
+ yield* header.render(options);
229
+ // A suppressed frame keeps its place and shows its file and line only: it
230
+ // is the same frame with no function name to show.
231
+ const frames = this.caught.frames.map((frame) => this.suppress.some((s) => frame.location.includes(s)) ? { ...frame, function: undefined } : frame);
232
+ // One entry per row of the frame block, so a report with no frames has no
233
+ // rows: empty text is one line, as in Rich, and a block built as one text
234
+ // drew a blank indented row under every frameless report.
235
+ let entries;
236
+ if (this.maxFrames > 0 && frames.length > this.maxFrames) {
72
237
  // Spend the budget exactly: the tail takes `maxFrames - head` rather than
73
238
  // a second `head`, so an odd budget shows every frame it counts, and it
74
239
  // slices from an index because `slice(-0)` is the whole array.
75
240
  const head = Math.floor(this.maxFrames / 2);
76
- const first = displayFrames.slice(0, head);
77
- const last = displayFrames.slice(displayFrames.length - (this.maxFrames - head));
78
- const omitted = displayFrames.length - this.maxFrames;
79
- for (const frame of first) {
80
- yield* this._renderFrame(frame, options);
81
- }
82
- yield new Segment(` ... ${omitted} frames omitted ...`, textStyle);
83
- yield Segment.line();
84
- for (const frame of last) {
85
- yield* this._renderFrame(frame, options);
86
- }
241
+ const omitted = frames.length - this.maxFrames;
242
+ entries = [
243
+ ...frames.slice(0, head).map(frameEntry),
244
+ new RichText(`... ${omitted} frames omitted ...`, { style: "traceback.text", end: "" }),
245
+ ...frames.slice(frames.length - (this.maxFrames - head)).map(frameEntry),
246
+ ];
87
247
  }
88
248
  else {
89
- for (const frame of displayFrames) {
90
- yield* this._renderFrame(frame, options);
249
+ entries = frames.map(frameEntry);
250
+ }
251
+ // The frames are one block set in from the report's edge, so a frame that
252
+ // wraps continues under itself instead of at the edge, where it read as a
253
+ // frame of its own. The width is divided as `Padding` divides it; the rows
254
+ // are not padded out on the right, as `Padding`'s are, because trailing
255
+ // blanks on every line of a crash report are bytes nobody asked for.
256
+ const { left, contentWidth } = layoutPadding(options.maxWidth, 2, 0);
257
+ const indent = new Segment(" ".repeat(left));
258
+ for (const entry of entries) {
259
+ for (const line of Segment.splitLines(entry.render({ ...options, maxWidth: contentWidth }))) {
260
+ yield indent;
261
+ yield* line;
262
+ yield Segment.line();
91
263
  }
92
264
  }
93
265
  }
94
- *_renderFrame(frame, options) {
95
- const pathStyle = Style.parse("dim");
96
- const lineNoStyle = getStyle(options, "traceback.offset");
97
- yield new Segment(" ");
98
- // Spec: suppressed frames show file and line only — no function name
99
- if (frame.function && !frame.suppressed) {
100
- yield new Segment(frame.function, Style.parse("bold"));
101
- yield new Segment(" ");
102
- }
103
- yield new Segment(frame.file, pathStyle);
104
- yield new Segment(":");
105
- yield new Segment(String(frame.line), lineNoStyle);
106
- yield Segment.line();
266
+ }
267
+ function frameEntry(frame) {
268
+ const entry = new RichText("", { end: "" });
269
+ if (frame.function) {
270
+ entry.append(frame.function, "bold");
271
+ entry.append(" ");
272
+ }
273
+ entry.append(frame.location, "dim");
274
+ if (frame.line !== undefined) {
275
+ entry.append(":");
276
+ entry.append(String(frame.line), "traceback.offset");
107
277
  }
278
+ return entry;
108
279
  }
@@ -39,7 +39,9 @@ export declare class Tree implements Renderable, Measurable {
39
39
  *
40
40
  * [LAW:dataflow-not-control-flow] Every node, at every depth, is walked by
41
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.
42
+ * branch of their own, so no depth is drawn by a rule of its own. A node's
43
+ * style and its guide style are handed down the same way, as one more entry
44
+ * on the stack its ancestors built.
43
45
  */
44
46
  private _rows;
45
47
  /**
@@ -58,7 +60,13 @@ export declare class Tree implements Renderable, Measurable {
58
60
  * depending on label renderables to invent trailing newlines: the label's
59
61
  * output is cut into lines and every line ends where Tree ends it. A label
60
62
  * that emits nothing still holds its row, and one that ends in a newline of
61
- * its own does not add a blank one.
63
+ * its own gets the blank row that line is, as in Rich — padded to the label's
64
+ * width when the label is justified.
65
+ *
66
+ * The node's style sits under the label's own, as Rich's `Styled` puts it, so
67
+ * markup in the label still wins; its background alone reaches under the
68
+ * guides, so a row with a background is filled from its first guide to the
69
+ * end of its label.
62
70
  */
63
71
  private _renderRow;
64
72
  measure(options: RenderOptions): {
@@ -5,25 +5,31 @@ import { Segment } from "../core/segment.js";
5
5
  import { cellFit, cellLen, asCellCol } from "../core/cells.js";
6
6
  import { Style, NULL_STYLE } from "../core/style.js";
7
7
  import { embed } from "./embed.js";
8
- import { drawable, getStyle, isMeasurable, stackedHeight, withBoundedWidth, withCellWidth } from "../core/protocol.js";
8
+ import { drawable, getStyle, stackedHeight, withBoundedWidth, withCellWidth } from "../core/protocol.js";
9
9
  import { Measurement } from "../core/measure.js";
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;
10
+ /** Every glyph of every set is this wide, so a row's guides are as wide however they are drawn. */
11
+ const GUIDE_CELLS = 4;
12
+ const LIGHT_GUIDES = { fork: "├── ", end: "└── ", continue: "│ ", space: " " };
13
+ const HEAVY_GUIDES = { fork: "┣━━ ", end: "┗━━ ", continue: "┃ ", space: " " };
14
+ const DOUBLE_GUIDES = { fork: "╠══ ", end: "╚══ ", continue: "║ ", space: " " };
15
+ const ASCII_GUIDES = { fork: "+-- ", end: "`-- ", continue: "| ", space: " " };
19
16
  /**
20
- * How wide a label wants to be. A label with no `measure` cannot say, and the
21
- * offer is the honest stand-in — including when the offer is unbounded, which
22
- * is where `withBoundedWidth` reports it rather than guessing a number.
17
+ * The guide attributes a glyph set stands for, switched off on the guide itself
18
+ * as Rich's `remove_guide_styles` does: a bold guide is drawn heavy, not bold.
23
19
  */
20
+ const REMOVE_GUIDE_STYLES = new Style({ bold: false, underline2: false });
21
+ const guideGlyphs = (glyphs) => Object.values(glyphs).join("");
22
+ /**
23
+ * The glyphs a column of guide is drawn with, read off its resolved guide style
24
+ * as Rich does: heavy when bold, double when underline2, light otherwise — and
25
+ * ASCII whenever the terminal cannot draw the chosen set.
26
+ */
27
+ function glyphsFor(options, style) {
28
+ const weight = style.bold === true ? HEAVY_GUIDES : style.underline2 === true ? DOUBLE_GUIDES : LIGHT_GUIDES;
29
+ return drawable(options, weight, ASCII_GUIDES, guideGlyphs);
30
+ }
31
+ /** How wide a label wants to be. */
24
32
  function labelWidth(options, label) {
25
- if (!isMeasurable(label))
26
- return options.maxWidth;
27
33
  return Measurement.get(options, label).maximum;
28
34
  }
29
35
  export class Tree {
@@ -52,7 +58,7 @@ export class Tree {
52
58
  }
53
59
  *render(rawOptions) {
54
60
  const options = withBoundedWidth(rawOptions, this);
55
- for (const row of this._walk(options)) {
61
+ for (const row of this._walk()) {
56
62
  yield* this._renderRow(options, row);
57
63
  }
58
64
  }
@@ -62,8 +68,8 @@ export class Tree {
62
68
  * and the column its children hang from with it, as Rich does — its children
63
69
  * stand at the left edge, and its guide style still reaches their guides.
64
70
  */
65
- _walk(options) {
66
- const rows = [...this._rows(drawable(options, UNICODE_GUIDES, ASCII_GUIDES, guideGlyphs), [], [])];
71
+ _walk() {
72
+ const rows = [...this._rows([], [], [])];
67
73
  return this.hideRoot ? rows.slice(1).map((row) => ({ ...row, guides: row.guides.slice(1) })) : rows;
68
74
  }
69
75
  /**
@@ -77,16 +83,21 @@ export class Tree {
77
83
  *
78
84
  * [LAW:dataflow-not-control-flow] Every node, at every depth, is walked by
79
85
  * 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.
86
+ * branch of their own, so no depth is drawn by a rule of its own. A node's
87
+ * style and its guide style are handed down the same way, as one more entry
88
+ * on the stack its ancestors built.
81
89
  */
82
- *_rows(glyphs, guides, inherited) {
83
- yield { guides, label: this.label };
90
+ *_rows(guides, inheritedGuideStyles, inheritedStyles) {
91
+ const styles = [...inheritedStyles, this.style];
92
+ yield { guides, label: this.label, styles };
84
93
  const children = this.expanded ? this.children : [];
85
- const styles = [...inherited, this.guideStyle];
94
+ const guideStyles = [...inheritedGuideStyles, this.guideStyle];
86
95
  const above = guides.map((guide) => ({ ...guide, first: guide.rest }));
87
96
  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);
97
+ const branch = i === children.length - 1
98
+ ? { first: "end", rest: "space", styles: guideStyles }
99
+ : { first: "fork", rest: "continue", styles: guideStyles };
100
+ yield* children[i]._rows([...above, branch], guideStyles, styles);
90
101
  }
91
102
  }
92
103
  /**
@@ -105,23 +116,40 @@ export class Tree {
105
116
  * depending on label renderables to invent trailing newlines: the label's
106
117
  * output is cut into lines and every line ends where Tree ends it. A label
107
118
  * that emits nothing still holds its row, and one that ends in a newline of
108
- * its own does not add a blank one.
119
+ * its own gets the blank row that line is, as in Rich — padded to the label's
120
+ * width when the label is justified.
121
+ *
122
+ * The node's style sits under the label's own, as Rich's `Styled` puts it, so
123
+ * markup in the label still wins; its background alone reaches under the
124
+ * guides, so a row with a background is filled from its first guide to the
125
+ * end of its label.
109
126
  */
110
127
  *_renderRow(options, row) {
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));
128
+ const resolve = (styles) => Style.combine(styles.map((style) => getStyle(options, style)));
129
+ const style = resolve(row.styles);
130
+ const drawn = row.guides.map((guide) => {
131
+ const guideStyle = resolve(guide.styles);
132
+ return {
133
+ glyphs: glyphsFor(options, guideStyle),
134
+ style: style.backgroundStyle.add(guideStyle).add(REMOVE_GUIDE_STYLES),
135
+ };
136
+ });
137
+ const width = Math.max(0, options.maxWidth - row.guides.length * GUIDE_CELLS);
113
138
  // The guides are cropped by `cellFit`; the label is cropped too, so a label
114
139
  // 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));
140
+ // guides were fitted into. No highlighter: a label is drawn plain, as
141
+ // Rich's `Tree` hands its labels `highlight=False`.
142
+ const labelOptions = { ...options, highlight: false, maxWidth: width, height: stackedHeight(options.height) };
143
+ const lines = Segment.splitLines(Segment.applyStyle(Segment.cropLines(row.label.render(labelOptions), width), style));
117
144
  if (lines.length === 0)
118
145
  lines.push([]);
119
146
  for (let i = 0; i < lines.length; i++) {
120
147
  let left = options.maxWidth;
121
148
  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));
149
+ const { glyphs, style: guideStyle } = drawn[g];
150
+ const piece = cellFit(glyphs[i === 0 ? row.guides[g].first : row.guides[g].rest], asCellCol(left));
123
151
  if (piece.length > 0)
124
- yield new Segment(piece, styles[g]);
152
+ yield new Segment(piece, guideStyle);
125
153
  left -= cellLen(piece);
126
154
  }
127
155
  yield* lines[i];
@@ -138,9 +166,8 @@ export class Tree {
138
166
  // mode drew a 40-cell frame around nine cells of tree, and an unbounded
139
167
  // offer came back unbounded.
140
168
  let natural = 0;
141
- for (const row of this._walk(parsed)) {
142
- const guideWidth = row.guides.reduce((sum, guide) => sum + cellLen(guide.first), 0);
143
- natural = Math.max(natural, guideWidth + labelWidth(parsed, row.label));
169
+ for (const row of this._walk()) {
170
+ natural = Math.max(natural, row.guides.length * GUIDE_CELLS + labelWidth(parsed, row.label));
144
171
  }
145
172
  const maximum = Math.min(natural, ceiling);
146
173
  // A row is its guides plus at least one cell of label — but never more than
@@ -35,7 +35,7 @@ import { Segment } from "../core/segment.js";
35
35
  import { Measurement } from "../core/measure.js";
36
36
  import { cellCount, cellLen } from "../core/cells.js";
37
37
  import { NULL_STYLE } from "../core/style.js";
38
- import { drawable, fitHeight, getStyle, isMeasurable, regionRows, withBoundedWidth, withCellWidth, } from "../core/protocol.js";
38
+ import { drawable, fitHeight, getStyle, regionRows, withBoundedWidth, withCellWidth, } from "../core/protocol.js";
39
39
  /** A heavy line for the thumb on a light line for the track. */
40
40
  export const SCROLLBAR = {
41
41
  thumb: { glyph: "┃", style: "scrollbar.thumb" },
@@ -155,9 +155,7 @@ export class Viewport {
155
155
  const options = withCellWidth(rawOptions);
156
156
  const gutter = gutterWidth(this.scrollbar);
157
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);
158
+ const content = Measurement.get(inner, this.content);
161
159
  return new Measurement(content.minimum + gutter, content.maximum + gutter).withMaximum(options.maxWidth);
162
160
  }
163
161
  }
@@ -26,7 +26,7 @@
26
26
  *
27
27
  * ### The carrier is a hex string, deliberately
28
28
  *
29
- * A color crosses the template seam as `#RRGGBB`, not as an opaque object,
29
+ * A color crosses the template seam as `#RRGGBB` (or `#RRGGBBAA`), not as an opaque object,
30
30
  * for three reasons:
31
31
  *
32
32
  * 1. **The type separates.** `"string"` is the strictest arg slot the template
@@ -56,12 +56,34 @@
56
56
  * the full color vocabulary by feeding it hex literals. [LAW:one-way-deps]
57
57
  */
58
58
  import type { FuncMap, TemplateFunc } from "@promptctl/go-template-js";
59
- import { ColorDepth } from "../core/color.js";
59
+ import { ColorDepth, type ColorRgba, type TerminalTheme } from "../core/color.js";
60
60
  /**
61
- * The `readableOn` binding, measuring at the depth `drawnAt` names when it is
62
- * evaluated — a consumer that renders at a per-call depth registers its own.
61
+ * A colour the colour functions can compute with. A fully transparent one — a
62
+ * theme's "no background", `#00000000` — has no colour of its own: what shows
63
+ * through it is whatever surface it lands on, which a template cannot know.
64
+ * Darkening, mixing or contrasting it would answer for a surface nobody named,
65
+ * so it is refused here, for every colour function and every `ramp` stop.
66
+ * [LAW:single-enforcer]
63
67
  */
64
- export declare const readableOnFunc: (drawnAt: () => ColorDepth) => TemplateFunc;
68
+ export declare function computable(colour: ColorRgba, func: string, written: string): ColorRgba;
69
+ /**
70
+ * Where `readableOn`'s text will be drawn: the depth the output is encoded at,
71
+ * and the terminal that draws it when that is known. At sixteen colours a
72
+ * terminal paints each slot in its own theme's colour, so the floor is measured
73
+ * in `terminal`'s sixteen; without one, in the VGA defaults (the Windows
74
+ * console's at `WINDOWS`). Both are facts about the one place the text lands,
75
+ * so they arrive together.
76
+ */
77
+ export interface DrawnAt {
78
+ readonly depth: ColorDepth;
79
+ readonly terminal?: TerminalTheme;
80
+ }
81
+ /**
82
+ * The `readableOn` binding, measuring where `drawnAt` says the text lands when
83
+ * it is evaluated — a consumer that renders to a per-call destination
84
+ * registers its own.
85
+ */
86
+ export declare const readableOnFunc: (drawnAt: () => DrawnAt) => TemplateFunc;
65
87
  /**
66
88
  * The palette-free color vocabulary: every function takes colors and returns a
67
89
  * color, so they compose by nesting.
@@ -80,8 +102,8 @@ export declare const readableOnFunc: (drawnAt: () => ColorDepth) => TemplateFunc
80
102
  * Pair with `paletteFuncs()` to name colors from a theme, and with
81
103
  * `richTextStyleFuncs()`'s `fg`/`bg` to paint them onto text.
82
104
  *
83
- * @param drawnAt the depth the terminal will draw at, read on every
84
- * `readableOn` evaluation so a host that learns its depth per render passes
85
- * one getter; defaults to truecolor.
105
+ * @param drawnAt where the text will be drawn — the depth, and the terminal
106
+ * when known — read on every `readableOn` evaluation so a host that learns
107
+ * its destination per render passes one getter; defaults to truecolor.
86
108
  */
87
- export declare function colorFuncs(drawnAt?: () => ColorDepth): FuncMap;
109
+ export declare function colorFuncs(drawnAt?: () => DrawnAt): FuncMap;