@promptctl/rich-js 0.22.0 → 0.24.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 (51) hide show
  1. package/dist/core/clock.d.ts +9 -1
  2. package/dist/core/clock.js +22 -2
  3. package/dist/core/color.d.ts +10 -0
  4. package/dist/core/color.js +36 -1
  5. package/dist/core/easing.d.ts +31 -0
  6. package/dist/core/easing.js +106 -1
  7. package/dist/core/segment.d.ts +10 -0
  8. package/dist/core/segment.js +15 -0
  9. package/dist/core/strip.d.ts +108 -31
  10. package/dist/core/strip.js +240 -131
  11. package/dist/core/style.d.ts +18 -1
  12. package/dist/core/style.js +32 -3
  13. package/dist/core/text.js +4 -1
  14. package/dist/host/app.js +2 -4
  15. package/dist/host/index.d.ts +1 -1
  16. package/dist/host/input.d.ts +37 -13
  17. package/dist/host/input.js +146 -56
  18. package/dist/host/terminal-host.d.ts +16 -0
  19. package/dist/host/terminal-host.js +1 -0
  20. package/dist/index.d.ts +11 -9
  21. package/dist/index.js +5 -4
  22. package/dist/node/terminal-host.d.ts +8 -1
  23. package/dist/node/terminal-host.js +2 -0
  24. package/dist/renderables/effect.d.ts +91 -18
  25. package/dist/renderables/effect.js +158 -46
  26. package/dist/renderables/effects.d.ts +73 -26
  27. package/dist/renderables/effects.js +412 -92
  28. package/dist/renderables/embed.d.ts +10 -0
  29. package/dist/renderables/embed.js +15 -7
  30. package/dist/renderables/frames.d.ts +95 -0
  31. package/dist/renderables/frames.js +106 -0
  32. package/dist/renderables/live.d.ts +23 -7
  33. package/dist/renderables/live.js +68 -34
  34. package/dist/renderables/markdown.d.ts +5 -2
  35. package/dist/renderables/markdown.js +210 -19
  36. package/dist/renderables/progress.d.ts +1 -1
  37. package/dist/renderables/progress.js +3 -1
  38. package/dist/renderables/progressBar.js +3 -3
  39. package/dist/renderables/spinner.d.ts +62 -28
  40. package/dist/renderables/spinner.js +99 -52
  41. package/dist/renderables/status.d.ts +8 -3
  42. package/dist/renderables/status.js +8 -5
  43. package/dist/template-bindings/animation-funcs.d.ts +37 -0
  44. package/dist/template-bindings/animation-funcs.js +83 -0
  45. package/dist/template-bindings/color-funcs.d.ts +13 -0
  46. package/dist/template-bindings/color-funcs.js +1 -1
  47. package/dist/template-bindings/helpers.d.ts +6 -0
  48. package/dist/template-bindings/helpers.js +12 -3
  49. package/dist/template-bindings/index.d.ts +6 -2
  50. package/dist/template-bindings/index.js +6 -2
  51. package/package.json +1 -1
@@ -5,14 +5,19 @@
5
5
  * Block structure is read as CommonMark reads it: a blockquote and a list
6
6
  * item are both containers, their lines tokenized as Markdown of their own
7
7
  * once the container's marker or indentation is cut off, and drawn beside a
8
- * gutter — the quote's bar, the item's marker then its hang. Where CommonMark
9
- * leaves the drawing open, Rich's is followed.
8
+ * gutter — the quote's bar, the item's marker then its hang. A table, which
9
+ * CommonMark has none of, and an HTML block are read as markdown-it — Rich's
10
+ * parser — reads them. Where CommonMark leaves the drawing open, Rich's is
11
+ * followed: HTML, a block of it or a tag inline, is drawn as Rich draws it,
12
+ * not at all.
10
13
  */
11
14
  import { cellLen, expandTabs, parseTabSize } from "../core/cells.js";
12
15
  import { Segment } from "../core/segment.js";
13
16
  import { Style } from "../core/style.js";
14
17
  import { RichText, resolveStyle } from "../core/text.js";
15
18
  import { Rule } from "./rule.js";
19
+ import { Table } from "./table.js";
20
+ import { SIMPLE } from "../core/box.js";
16
21
  import { drawable, getStyle, groundedStyle, stackedHeight } from "../core/protocol.js";
17
22
  const HEADING = /^(#{1,6})\s+(.+)$/;
18
23
  /** A thematic break: three or more of one of `*`, `-` or `_`, spaces and tabs between them allowed. */
@@ -24,11 +29,43 @@ const ITEM_MARKER = /^(?:[*+-]|(\d{1,9})\.)/;
24
29
  const LEAD = /^[ \t]*/;
25
30
  const HARD_BREAK = /(?: {2,}|\\)$/;
26
31
  const BLOCK_STARTS = [HEADING, FENCE, QUOTE, RULE];
32
+ /** A line that may be a table's delimiter row, screened as markdown-it screens one: pipes, dashes, colons and space only, and not a dash then a space, which opens a list item. */
33
+ const DELIMITER_ROW = /^(?!-[ \t])[-:|][-:| \t]+$/;
34
+ const DELIMITER_CELL = /^:?-+:?$/;
35
+ /**
36
+ * How many empty cells a table's short rows may be filled out with before the
37
+ * table ends, as markdown-it caps them: a header of many columns over many
38
+ * one-cell rows would otherwise grow a few kilobytes of source into millions of cells.
39
+ */
40
+ const MAX_AUTOCOMPLETED_CELLS = 0x10000;
27
41
  /** Indentation that makes a line indented code when it opens a block, as CommonMark counts it. */
28
42
  const CODE_INDENT = 4;
29
43
  /** The tab stop code is drawn with, as Rich's `Syntax` draws it. */
30
44
  const CODE_TAB_SIZE = parseTabSize(8);
31
45
  const BLANK_LINE = { text: "", margin: 0, lazy: false };
46
+ /** The block-level element names an HTML block of the sixth kind opens with, as markdown-it-py lists them. */
47
+ const HTML_BLOCK_NAMES = "address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|" +
48
+ "fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|" +
49
+ "main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|" +
50
+ "title|tr|track|ul";
51
+ // The tag grammar of markdown-it-py's common/html_re, shared by the HTML block a lone tag opens and the inline HTML it reads in text.
52
+ const HTML_ATTRIBUTE = String.raw `(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^"'=<>\`\x00-\x20]+|'[^']*'|"[^"]*"))?)`;
53
+ const HTML_OPEN_TAG = String.raw `<[A-Za-z][A-Za-z0-9-]*${HTML_ATTRIBUTE}*\s*\/?>`;
54
+ const HTML_CLOSE_TAG = String.raw `<\/[A-Za-z][A-Za-z0-9-]*\s*>`;
55
+ const HTML_BLOCKS = [
56
+ { start: /^<(?:script|pre|style|textarea)(?=\s|>|$)/i, end: /<\/(?:script|pre|style|textarea)>/i, interrupts: true },
57
+ { start: /^<!--/, end: /-->/, interrupts: true },
58
+ { start: /^<\?/, end: /\?>/, interrupts: true },
59
+ // Uppercase only, as markdown-it-py has it, where markdown-it in JS and CommonMark 0.31 take `<!doctype` too.
60
+ { start: /^<![A-Z]/, end: />/, interrupts: true },
61
+ { start: /^<!\[CDATA\[/, end: /\]\]>/, interrupts: true },
62
+ { start: new RegExp(String.raw `^<\/?(?:${HTML_BLOCK_NAMES})(?=\s|\/?>|$)`, "i"), end: "blank", interrupts: true },
63
+ {
64
+ start: new RegExp(String.raw `^(?:${HTML_OPEN_TAG}|${HTML_CLOSE_TAG})\s*$`),
65
+ end: "blank",
66
+ interrupts: false,
67
+ },
68
+ ];
32
69
  /** The column `ws` ends at when it starts at column `from`, a tab reaching the next stop of 4 as CommonMark counts it. */
33
70
  function advance(from, ws) {
34
71
  return [...ws].reduce((col, ch) => (ch === "\t" ? col + 4 - (col % 4) : col + 1), from);
@@ -90,6 +127,57 @@ function itemStart(line) {
90
127
  first: rest === "" ? undefined : { text: lead + " ".repeat(m[0].length) + after, margin: content, lazy: false },
91
128
  };
92
129
  }
130
+ /** Whether `line` may be a table's row: a line of its container's own, neither lazy paragraph text nor indented code. */
131
+ function tableLine(line) {
132
+ return !line.lazy && indentOf(line) < CODE_INDENT;
133
+ }
134
+ /**
135
+ * A table row's cells, as markdown-it splits one: at every `|` no backslash escapes,
136
+ * a pipe at either end of the row closing it rather than opening an empty
137
+ * cell, and each cell trimmed. An escaped `\|` is a `|` in its cell, inside a
138
+ * code span too.
139
+ */
140
+ function rowCells(row) {
141
+ const cells = [""];
142
+ for (const ch of row.trim()) {
143
+ const last = cells.length - 1;
144
+ if (ch !== "|")
145
+ cells[last] += ch;
146
+ else if (cells[last].endsWith("\\"))
147
+ cells[last] = `${cells[last].slice(0, -1)}|`;
148
+ else
149
+ cells.push("");
150
+ }
151
+ const from = cells[0] === "" ? 1 : 0;
152
+ const to = cells.length > from && cells[cells.length - 1] === "" ? -1 : undefined;
153
+ return cells.slice(from, to).map((cell) => cell.trim());
154
+ }
155
+ /** The alignment of each column `line` declares, if it is a table's delimiter row: a run of dashes per column, a colon on the side its cells sit. */
156
+ function alignments(line) {
157
+ const text = body(line);
158
+ if (!tableLine(line) || !DELIMITER_ROW.test(text))
159
+ return undefined;
160
+ const cells = rowCells(text);
161
+ if (!cells.every((cell) => DELIMITER_CELL.test(cell)))
162
+ return undefined;
163
+ return cells.map((cell) => (cell.endsWith(":") ? (cell.startsWith(":") ? "center" : "right") : cell.startsWith(":") ? "left" : undefined));
164
+ }
165
+ /**
166
+ * The columns of the table opening at `start`, if one does, as markdown-it
167
+ * reads one: a header row with a pipe in it, then a delimiter row naming as
168
+ * many columns.
169
+ */
170
+ function tableAt(lines, start) {
171
+ const head = lines[start];
172
+ const delimiter = lines[start + 1];
173
+ if (delimiter === undefined || !tableLine(head) || !body(head).includes("|"))
174
+ return undefined;
175
+ const aligns = alignments(delimiter);
176
+ if (!aligns)
177
+ return undefined;
178
+ const headers = rowCells(body(head));
179
+ return headers.length === aligns.length ? headers.map((header, n) => ({ header, align: aligns[n] })) : undefined;
180
+ }
93
181
  /** Whether two item starts belong to one list: the same bullet character, or both numbered. */
94
182
  function sameList(a, b) {
95
183
  return a.bullet === b.bullet;
@@ -104,13 +192,18 @@ function quoted(line) {
104
192
  const spaced = /[ \t]/.test(line.text[at + 1] ?? "");
105
193
  return { text: `${line.text.slice(0, at)} ${line.text.slice(at + 1)}`, margin: leadEnd(line) + 1 + (spaced ? 1 : 0), lazy: false };
106
194
  }
195
+ /** The kind of HTML block `line` opens, if it opens one: the first kind whose opening it matches, as markdown-it tries them in order. */
196
+ function htmlBlockAt(line) {
197
+ return HTML_BLOCKS.find((kind) => startsWith(line, kind.start));
198
+ }
107
199
  /**
108
- * Whether `line` opens a block of its own — a heading, a fence, a quote, a
109
- * rule or a list item. A line that does not is text, and under an open
200
+ * Whether `line` opens a block of its own that may cut short the block before
201
+ * it — a heading, a fence, a quote, a rule, a list item or an HTML block of
202
+ * any kind but a lone tag. A line that does not is text, and under an open
110
203
  * paragraph it continues that paragraph.
111
204
  */
112
205
  function opensBlock(line) {
113
- return BLOCK_STARTS.some((re) => startsWith(line, re)) || itemStart(line) !== undefined;
206
+ return BLOCK_STARTS.some((re) => startsWith(line, re)) || itemStart(line) !== undefined || htmlBlockAt(line)?.interrupts === true;
114
207
  }
115
208
  /**
116
209
  * Whether `line` ends a paragraph: `opensBlock`, read as CommonMark reads it
@@ -160,7 +253,7 @@ function continuesLazily(inner, line, afterLazy) {
160
253
  }
161
254
  /**
162
255
  * The text of the paragraph begun at `start`, every line after it up to a
163
- * blank or a line that ends a paragraph, and the index of the line that ended
256
+ * blank, a line that ends a paragraph or a table's header row, and the index of the line that ended
164
257
  * it. Soft-broken lines are joined by one space, as a Markdown renderer
165
258
  * reflows them, with their own indentation dropped; a line ending in two
166
259
  * spaces or a backslash breaks hard, and its break is kept.
@@ -168,7 +261,7 @@ function continuesLazily(inner, line, afterLazy) {
168
261
  function continuation(lines, start) {
169
262
  const parts = [lines[start].text];
170
263
  let i = start + 1;
171
- for (; i < lines.length && !isBlank(lines[i]) && !endsParagraph(lines[i]); i++)
264
+ for (; i < lines.length && !isBlank(lines[i]) && !endsParagraph(lines[i]) && !tableAt(lines, i); i++)
172
265
  parts.push(lines[i].text);
173
266
  const text = parts
174
267
  .map((part, n) => {
@@ -247,6 +340,23 @@ function tokenize(lines) {
247
340
  i++;
248
341
  continue;
249
342
  }
343
+ // Table: before every other block, as markdown-it tries it, so a header
344
+ // row that reads as a heading or a list item is still a header. Its body
345
+ // rows run to a blank line or a line that opens a block.
346
+ const columns = tableAt(lines, i);
347
+ if (columns) {
348
+ const rows = [];
349
+ let autocompleted = 0;
350
+ for (i += 2; i < lines.length && !isBlank(lines[i]) && tableLine(lines[i]) && !opensBlock(lines[i]); i++) {
351
+ const cells = rowCells(body(lines[i]));
352
+ autocompleted += columns.length - cells.length;
353
+ if (autocompleted > MAX_AUTOCOMPLETED_CELLS)
354
+ break;
355
+ rows.push(columns.map((_, n) => cells[n] ?? ""));
356
+ }
357
+ tokens.push({ type: "table", columns, rows });
358
+ continue;
359
+ }
250
360
  // Indented code: its lines, and the blank lines between them, with the indent cut off
251
361
  if (indentOf(line) >= CODE_INDENT) {
252
362
  const code = [];
@@ -305,6 +415,19 @@ function tokenize(lines) {
305
415
  i = next;
306
416
  continue;
307
417
  }
418
+ // HTML block: to the line that closes its kind, or to the end of its
419
+ // container when none does; a closing blank line is left to what follows
420
+ const html = htmlBlockAt(line);
421
+ if (html) {
422
+ const { end } = html;
423
+ const closes = end === "blank" ? isBlank : (closing) => end.test(body(closing));
424
+ while (i < lines.length && !closes(lines[i]))
425
+ i++;
426
+ if (i < lines.length && end !== "blank")
427
+ i++;
428
+ tokens.push({ type: "html_block" });
429
+ continue;
430
+ }
308
431
  // Paragraph, or a setext heading when a line of `=` or `-` underlines it
309
432
  const { text, next } = continuation(lines, i);
310
433
  const setext = underline(lines[next]);
@@ -316,10 +439,20 @@ function tokenize(lines) {
316
439
  return tokens;
317
440
  }
318
441
  const TICKS = /`+/y;
319
- const STRONG = /\*\*(.+?)\*\*/y;
320
- const EMPHASIS = /\*(.+?)\*/y;
321
442
  const ANGLED_DESTINATION = /\(\s*<([^<>\n]*)>/y;
322
443
  const LINK_TAIL = /\s*(?:"[^"]*"|'[^']*'|\([^()]*\))?\s*\)/y;
444
+ /** A backslash escape, as markdown-it reads one: the ASCII punctuation character after it, standing for itself. */
445
+ const ESCAPE = /\\([!-\/:-@[-`{-~])/y;
446
+ const ESCAPES = new RegExp(ESCAPE.source, "g");
447
+ /** Inline HTML, as markdown-it-py's HTML_TAG_RE reads it: a tag, a comment, a processing instruction, a declaration or a CDATA section. */
448
+ const HTML_TAG = new RegExp([
449
+ HTML_OPEN_TAG,
450
+ HTML_CLOSE_TAG,
451
+ String.raw `<!---?>|<!--(?:[^-]|-[^-]|--[^>])*-->`,
452
+ String.raw `<[?][\s\S]*?[?]>`,
453
+ String.raw `<![A-Za-z][^>]*>`,
454
+ String.raw `<!\[CDATA\[[\s\S]*?\]\]>`,
455
+ ].join("|"), "y");
323
456
  /** `re`, a sticky pattern, matched exactly at `i` in `text`. */
324
457
  function matchAt(re, text, i) {
325
458
  re.lastIndex = i;
@@ -327,8 +460,10 @@ function matchAt(re, text, i) {
327
460
  }
328
461
  /**
329
462
  * Each `[` in `text` that a `]` closes, to the index of that `]`: brackets
330
- * nest, and a backslash escapes the character after it. One pass, so a run of
331
- * text full of brackets that close nothing costs no more than one without.
463
+ * nest, a backslash escapes the character after it, and a bracket inside a
464
+ * code span or inline HTML, which bind tighter than a link, is not one. One
465
+ * pass, so a run of text full of brackets that close nothing costs no more
466
+ * than one without.
332
467
  */
333
468
  function bracketPairs(text) {
334
469
  const pairs = new Map();
@@ -340,6 +475,9 @@ function bracketPairs(text) {
340
475
  open.push(i);
341
476
  else if (text[i] === "]" && open.length > 0)
342
477
  pairs.set(open.pop(), i);
478
+ // Neither construct reads the brackets, so the pairs found so far serve.
479
+ else if (text[i] === "`" || text[i] === "<")
480
+ i = (inlineAt(text, i, pairs)?.end ?? i + 1) - 1;
343
481
  }
344
482
  return pairs;
345
483
  }
@@ -347,7 +485,8 @@ function bracketPairs(text) {
347
485
  * The `(destination "title")` of a link opening at `open`, as CommonMark reads
348
486
  * it: a destination in angle brackets, or a run of non-space characters whose
349
487
  * parentheses balance — so a URL may itself hold `(…)` — then an optional
350
- * quoted title, then the `)`. The destination and the index past the `)`.
488
+ * quoted title, then the `)`. The destination, its backslash escapes read, and
489
+ * the index past the `)`.
351
490
  */
352
491
  function linkDestination(text, open) {
353
492
  if (text[open] !== "(")
@@ -375,7 +514,22 @@ function linkDestination(text, open) {
375
514
  url = text.slice(from, i);
376
515
  }
377
516
  const tail = matchAt(LINK_TAIL, text, i);
378
- return tail ? { url, end: i + tail[0].length } : undefined;
517
+ return tail ? { url: url.replace(ESCAPES, "$1"), end: i + tail[0].length } : undefined;
518
+ }
519
+ /**
520
+ * The index of the `delimiter` that closes emphasis whose text starts at
521
+ * `from`, after at least one character of it. Every other inline construct
522
+ * binds tighter than emphasis, as CommonMark has it, so a delimiter inside an
523
+ * escape, a code span, inline HTML or a link closes nothing.
524
+ */
525
+ function emphasisClose(text, from, delimiter, brackets) {
526
+ for (let i = from; i < text.length;) {
527
+ if (i > from && text.startsWith(delimiter, i))
528
+ return i;
529
+ const tighter = text[i] === "*" ? undefined : inlineAt(text, i, brackets);
530
+ i = tighter ? tighter.end : i + 1;
531
+ }
532
+ return undefined;
379
533
  }
380
534
  /** The inline construct opening at `i` in `text`, if one does; `brackets` is `text`'s `bracketPairs`. */
381
535
  function inlineAt(text, i, brackets) {
@@ -390,11 +544,25 @@ function inlineAt(text, i, brackets) {
390
544
  : { kind: "literal", text: ticks, end: i + ticks.length };
391
545
  }
392
546
  case "*": {
393
- const strong = matchAt(STRONG, text, i);
394
- const emphasis = strong ?? matchAt(EMPHASIS, text, i);
395
- return emphasis
396
- ? { kind: "emphasis", style: strong ? "bold" : "italic", inner: emphasis[1], end: i + emphasis[0].length }
397
- : undefined;
547
+ const strong = text.startsWith("**", i) ? emphasisClose(text, i + 2, "**", brackets) : undefined;
548
+ const delimiter = strong === undefined ? "*" : "**";
549
+ const close = strong ?? emphasisClose(text, i + 1, "*", brackets);
550
+ return close === undefined
551
+ ? undefined
552
+ : {
553
+ kind: "emphasis",
554
+ style: strong === undefined ? "italic" : "bold",
555
+ inner: text.slice(i + delimiter.length, close),
556
+ end: close + delimiter.length,
557
+ };
558
+ }
559
+ case "\\": {
560
+ const escape = matchAt(ESCAPE, text, i);
561
+ return escape ? { kind: "literal", text: escape[1], end: i + escape[0].length } : undefined;
562
+ }
563
+ case "<": {
564
+ const tag = matchAt(HTML_TAG, text, i);
565
+ return tag ? { kind: "html", end: i + tag[0].length } : undefined;
398
566
  }
399
567
  case "!":
400
568
  case "[": {
@@ -426,7 +594,8 @@ function styled(text, style, settings, options) {
426
594
  /**
427
595
  * `text` as inline Markdown appended to `result`: code spans, emphasis, links
428
596
  * and images, the text inside emphasis, links and images parsed the same way,
429
- * and everything else as it is.
597
+ * a backslash escape as the character it escapes, inline HTML as nothing, and
598
+ * everything else as it is.
430
599
  */
431
600
  function appendInline(result, text, settings, options) {
432
601
  const brackets = bracketPairs(text);
@@ -445,6 +614,9 @@ function appendInline(result, text, settings, options) {
445
614
  case "literal":
446
615
  result.append(found.text);
447
616
  break;
617
+ case "html":
618
+ // Rich draws nothing for inline HTML.
619
+ break;
448
620
  case "emphasis":
449
621
  result.append(styled(found.inner, found.style, settings, options));
450
622
  break;
@@ -558,6 +730,25 @@ function* renderTokens(tokens, options, settings) {
558
730
  yield* guttered(body, options, bar, bar, Style.parse("dim italic"));
559
731
  break;
560
732
  }
733
+ case "table": {
734
+ // Rich's TableElement: each cell inline Markdown placed by its
735
+ // column's alignment, the header's in `markdown.table.header` on top.
736
+ const cell = (text, justify) => {
737
+ const content = new RichText("", { justify });
738
+ appendInline(content, text, settings, options);
739
+ return content;
740
+ };
741
+ const table = new Table({ box: SIMPLE, padEdge: false, style: "markdown.table.border", showEdge: true, collapsePadding: true });
742
+ for (const { header, align } of token.columns)
743
+ table.addColumn(cell(header, align).stylize("markdown.table.header"));
744
+ for (const row of token.rows)
745
+ table.addRow(...row.map((text, n) => cell(text, token.columns[n].align)));
746
+ yield* table.render(options);
747
+ break;
748
+ }
749
+ case "html_block":
750
+ // Rich draws nothing for one.
751
+ break;
561
752
  case "blank":
562
753
  yield Segment.line();
563
754
  break;
@@ -124,7 +124,7 @@ export interface SpinnerColumnOptions {
124
124
  }
125
125
  export declare class SpinnerColumn implements ProgressColumn {
126
126
  readonly tableColumn: ColumnOptions;
127
- private _spinner;
127
+ private readonly _spinner;
128
128
  private _finishedText;
129
129
  constructor(spinnerName?: string, options?: SpinnerColumnOptions);
130
130
  render(task: Task): Renderable;
@@ -173,8 +173,10 @@ export class SpinnerColumn {
173
173
  const finishedText = options?.finishedText ?? " ";
174
174
  this._finishedText = typeof finishedText === "string" ? readStr(finishedText, true) : finishedText.copy();
175
175
  }
176
+ // Rich's `self.spinner.render(task.get_time())`: the frame for the time the
177
+ // task is drawn at, which `Progress` read off its clock once for the frame.
176
178
  render(task) {
177
- return finished(task) ? this._finishedText : this._spinner;
179
+ return finished(task) ? this._finishedText : this._spinner.at(task.time);
178
180
  }
179
181
  }
180
182
  /**
@@ -59,17 +59,17 @@ export class ProgressBar {
59
59
  // [LAW:no-ambient-temporal-coupling] The moment is the caller's; the bar
60
60
  // only says what it looks like then. An empty track, lit by the shimmer.
61
61
  const track = { render: (o) => this.track(o, width, 0, back) };
62
- yield* new Effected(track, this.pulseEffect(options, back, width, depth, theme), { t: this.pulse.t, key: "progress-bar", theme }).render(options);
62
+ yield* new Effected(track, this.pulseEffect(options, back, depth, theme), { t: this.pulse.t, key: "progress-bar", theme }).render(options);
63
63
  }
64
64
  /**
65
65
  * The shimmer over this bar's back colour as `Effected` sees it drawn at
66
66
  * `depth`, toward `bar.pulse`. A bar has no words to keep legible, so its
67
67
  * back takes the whole of the light.
68
68
  */
69
- pulseEffect(options, back, width, depth, theme) {
69
+ pulseEffect(options, back, depth, theme) {
70
70
  const ink = back.drawnColors(depth).color ?? ColorSpec.default();
71
71
  const glow = getStyle(options, "bar.pulse").drawnColors(depth).color ?? ColorSpec.default();
72
- const loop = shimmer(PULSE_CURVE, width, PULSE_REACH, glow.getTruecolor(theme, true), 0);
72
+ const loop = shimmer(PULSE_CURVE, PULSE_REACH, glow.getTruecolor(theme, true), 0);
73
73
  const whole = new Map([[ink.getTruecolor(theme, true).hex, 1]]);
74
74
  return atShares(loop, () => whole);
75
75
  }
@@ -1,15 +1,59 @@
1
1
  /**
2
- * Spinner — animated terminal spinner with optional text label.
2
+ * Spinner — a named spinner, asked for its frame at a time, with an optional
3
+ * label after it.
4
+ *
5
+ * A spinner is a `FrameSequence` (`./frames.ts`) over one entry of the named spinner
6
+ * data (`core/spinnerData.ts`): each frame held for the spinner's interval,
7
+ * looping, the frame in progress at `t` drawn. The data names the frames and
8
+ * the interval; the beat, the width every frame draws at and which frame `t`
9
+ * lands on are the frames primitive's, so a spinner is one more sequence on
10
+ * it and not a second way of turning frames. Custom frames are a `FrameSequence`
11
+ * drawn through `Frames` directly. [LAW:one-source-of-truth]
12
+ *
13
+ * It is built once and asked for each frame, as Rich's `Spinner` is built
14
+ * once and has `render(time)` called on it: the name is parsed and the
15
+ * styled frames are prepared where the spinner is given, and `at(t)` is what
16
+ * draws, so a `Status` or `SpinnerColumn` holding one pays for the name and
17
+ * the styling once, and a bad name fails at the call that gave it.
18
+ * [LAW:parse-dont-validate] What a `FrameSequence` makes of the frames —
19
+ * the beat, the width, each frame padded to it — is made there too, so a
20
+ * drawn frame costs only finding which one `t` lands on.
21
+ *
22
+ * [LAW:no-ambient-temporal-coupling] A Spinner draws nothing on its own:
23
+ * only `at(t)` is a renderable, so a spinner cannot be drawn without being
24
+ * handed its time, and nothing here reads a clock. Whoever draws frames
25
+ * hands each one its time (`core/clock.ts`) — `SpinnerColumn` the task's
26
+ * `time`, a `Live` the frame's time on its clock — as Rich's `Spinner.render`
27
+ * is handed `console.get_time()` or `task.get_time()`. `t` is the clock's
28
+ * time, as `App` and `Task.time` hand it, where Rich's spinner counts from
29
+ * its first draw: here nothing is kept between frames, so the same `t` draws
30
+ * the same bytes at one redraw a second or sixty, two spinners on one clock
31
+ * agree at every `t`, a `Progress` on a hand-moved clock turns its spinners
32
+ * on that clock, and a draw costs the same at `t = 0` and `t = 10⁶`.
33
+ *
34
+ * `speed` scales the holds, not the time: at speed 2 each frame is held half
35
+ * the interval, and a negative speed is the frames in reverse order, each held
36
+ * the interval over the speed's size. [LAW:one-source-of-truth] Rich scales
37
+ * the time instead, `t × speed` into the beat. At a positive speed the frame
38
+ * drawn is the same; but when the frame next changes (`Style.nextChange`) is
39
+ * a time on the host's timeline, and a beat over scaled time answers on the
40
+ * scaled one — so the sequence is scaled where it is built, and the
41
+ * `FrameSequence` stamps host time with nothing to map back. Backwards, this is the plain
42
+ * reversed sequence from `t = 0`, where Rich's `int()` truncation holds
43
+ * frame 0 for a first interval and then counts down: a departure, made so a
44
+ * negative speed is one more sequence on the beat and not a second rule
45
+ * about time. A speed of zero would hold a frame forever, which is not a
46
+ * beat, and is refused where it is given.
3
47
  */
4
- import { Segment } from "../core/segment.js";
5
48
  import { Style } from "../core/style.js";
6
49
  import { RichText } from "../core/text.js";
7
- import type { Renderable, Measurable, RenderOptions } from "../core/protocol.js";
50
+ import type { Renderable, Measurable } from "../core/protocol.js";
8
51
  export interface SpinnerOptions {
52
+ /** Multiplies the spinner's rate; negative runs it backwards; zero is refused. Defaults to 1. */
9
53
  speed?: number;
10
54
  style?: string | Style;
11
55
  }
12
- export declare class Spinner implements Renderable, Measurable {
56
+ export declare class Spinner {
13
57
  readonly name: string;
14
58
  readonly speed: number;
15
59
  readonly style: string | Style;
@@ -22,36 +66,26 @@ export declare class Spinner implements Renderable, Measurable {
22
66
  */
23
67
  text: string | RichText;
24
68
  private readonly _data;
25
- private _frameIndex;
26
- private _lastUpdate;
69
+ private readonly _named;
70
+ private readonly _ascii;
27
71
  constructor(name?: string, text?: string | RichText, options?: SpinnerOptions);
28
72
  get frames(): readonly string[];
73
+ /** Milliseconds each frame is held, as the spinner data gives it. */
29
74
  get interval(): number;
30
- /** Advance the frame count by the time elapsed, and return it. */
31
- private _advance;
32
- /** The frames this output can draw: the `line` spinner stands in for any other on an ASCII-only one. */
33
- private _drawnFrames;
34
75
  /**
35
- * The current frame and its label as one line wrapped to the width and
36
- * ended, as the reference's `Text` is — printed, stacked in a `Group`, or
37
- * laid out in a `Progress` cell alike.
76
+ * The frame in progress at `t` seconds and the label, as one renderable:
77
+ * the line wrapped to the width and ended, as the reference's `Text` is —
78
+ * printed, stacked in a `Group`, or laid out in a `Progress` cell alike.
79
+ * The frames this output can draw are chosen where the output is known.
38
80
  */
39
- render(options: RenderOptions): Iterable<Segment>;
40
- private _currentLine;
81
+ at(t: number): Renderable & Measurable;
82
+ private _render;
41
83
  /**
42
- * `frame` and the label as one text, as Rich's `Text.assemble(frame, " ",
43
- * text)`: a long label wraps as one line with its frame. A label that draws
44
- * nothing — the default `""`, or markup that styles nothing — is no label,
45
- * not a trailing space, as an empty `Text` is falsy there.
84
+ * The frame in progress and the label as one text, as Rich's
85
+ * `Text.assemble(frame, " ", text)`: a long label wraps as one line with its
86
+ * frame. A label that draws nothing — the default `""`, or markup that
87
+ * styles nothing — is no label, not a trailing space, as an empty `Text` is
88
+ * falsy there.
46
89
  */
47
90
  private _line;
48
- /**
49
- * Measured at its widest frame, so a column holding it never narrows as it
50
- * turns, and never offered less than that frame: a frame is one picture,
51
- * which the reference's `Text` measure would let wrap apart at its spaces.
52
- */
53
- measure(options: RenderOptions): {
54
- minimum: number;
55
- maximum: number;
56
- };
57
91
  }