@promptctl/rich-js 0.18.1 → 0.20.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 (169) hide show
  1. package/README.md +11 -8
  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 +99 -18
  9. package/dist/core/color.js +334 -146
  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/noise.d.ts +31 -0
  33. package/dist/core/noise.js +94 -0
  34. package/dist/core/osc8.d.ts +6 -6
  35. package/dist/core/osc8.js +9 -21
  36. package/dist/core/paint.d.ts +107 -0
  37. package/dist/core/paint.js +161 -0
  38. package/dist/core/place.d.ts +1 -1
  39. package/dist/core/place.js +4 -6
  40. package/dist/core/pretty.d.ts +126 -2
  41. package/dist/core/pretty.js +571 -105
  42. package/dist/core/protocol.d.ts +43 -3
  43. package/dist/core/protocol.js +23 -2
  44. package/dist/core/render.d.ts +33 -30
  45. package/dist/core/render.js +43 -83
  46. package/dist/core/segment.d.ts +6 -5
  47. package/dist/core/segment.js +11 -15
  48. package/dist/core/selection.d.ts +12 -0
  49. package/dist/core/selection.js +14 -0
  50. package/dist/core/strip.d.ts +116 -74
  51. package/dist/core/strip.js +266 -217
  52. package/dist/core/style.d.ts +27 -7
  53. package/dist/core/style.js +106 -41
  54. package/dist/core/text.d.ts +43 -16
  55. package/dist/core/text.js +180 -59
  56. package/dist/core/wrap.d.ts +5 -0
  57. package/dist/core/wrap.js +16 -3
  58. package/dist/host/app.d.ts +95 -17
  59. package/dist/host/app.js +201 -61
  60. package/dist/host/index.d.ts +4 -1
  61. package/dist/host/index.js +1 -0
  62. package/dist/host/input.d.ts +84 -0
  63. package/dist/host/input.js +498 -0
  64. package/dist/host/terminal-host.d.ts +15 -2
  65. package/dist/host/terminal-host.js +2 -0
  66. package/dist/index.d.ts +26 -14
  67. package/dist/index.js +17 -7
  68. package/dist/node/prompt.d.ts +13 -0
  69. package/dist/node/prompt.js +30 -9
  70. package/dist/node/save.d.ts +4 -3
  71. package/dist/node/save.js +6 -3
  72. package/dist/node/terminal-host.d.ts +10 -2
  73. package/dist/node/terminal-host.js +7 -0
  74. package/dist/node/traceback.d.ts +2 -1
  75. package/dist/node/traceback.js +3 -39
  76. package/dist/renderables/align.js +2 -9
  77. package/dist/renderables/columns.d.ts +45 -29
  78. package/dist/renderables/columns.js +142 -103
  79. package/dist/renderables/constrain.js +6 -12
  80. package/dist/renderables/effect.d.ts +113 -0
  81. package/dist/renderables/effect.js +182 -0
  82. package/dist/renderables/effects.d.ts +252 -0
  83. package/dist/renderables/effects.js +565 -0
  84. package/dist/renderables/embed.d.ts +94 -31
  85. package/dist/renderables/embed.js +141 -45
  86. package/dist/renderables/group.d.ts +6 -3
  87. package/dist/renderables/group.js +5 -0
  88. package/dist/renderables/layout.d.ts +6 -23
  89. package/dist/renderables/layout.js +24 -113
  90. package/dist/renderables/list-view.d.ts +42 -0
  91. package/dist/renderables/list-view.js +78 -0
  92. package/dist/renderables/live.d.ts +40 -16
  93. package/dist/renderables/live.js +96 -83
  94. package/dist/renderables/markdown.d.ts +16 -2
  95. package/dist/renderables/markdown.js +521 -118
  96. package/dist/renderables/padding.d.ts +22 -1
  97. package/dist/renderables/padding.js +30 -24
  98. package/dist/renderables/panel.d.ts +31 -33
  99. package/dist/renderables/panel.js +137 -141
  100. package/dist/renderables/progress.d.ts +110 -16
  101. package/dist/renderables/progress.js +212 -81
  102. package/dist/renderables/progressBar.d.ts +24 -4
  103. package/dist/renderables/progressBar.js +78 -23
  104. package/dist/renderables/prompt.d.ts +37 -15
  105. package/dist/renderables/prompt.js +125 -85
  106. package/dist/renderables/ratio.d.ts +123 -0
  107. package/dist/renderables/ratio.js +213 -0
  108. package/dist/renderables/rule.d.ts +3 -2
  109. package/dist/renderables/rule.js +50 -52
  110. package/dist/renderables/spinner.d.ts +28 -2
  111. package/dist/renderables/spinner.js +52 -19
  112. package/dist/renderables/status.d.ts +13 -7
  113. package/dist/renderables/status.js +15 -29
  114. package/dist/renderables/syntax.d.ts +37 -7
  115. package/dist/renderables/syntax.js +134 -83
  116. package/dist/renderables/table.d.ts +58 -20
  117. package/dist/renderables/table.js +373 -197
  118. package/dist/renderables/traceback.d.ts +5 -4
  119. package/dist/renderables/traceback.js +239 -68
  120. package/dist/renderables/tree.d.ts +10 -2
  121. package/dist/renderables/tree.js +61 -34
  122. package/dist/renderables/viewport.d.ts +30 -0
  123. package/dist/renderables/viewport.js +61 -7
  124. package/dist/template-bindings/color-funcs.d.ts +31 -9
  125. package/dist/template-bindings/color-funcs.js +40 -14
  126. package/dist/template-bindings/helpers.d.ts +4 -2
  127. package/dist/template-bindings/helpers.js +5 -20
  128. package/dist/template-bindings/index.d.ts +29 -17
  129. package/dist/template-bindings/index.js +36 -20
  130. package/dist/template-bindings/palette-funcs.d.ts +3 -1
  131. package/dist/template-bindings/palette-funcs.js +11 -7
  132. package/dist/template-bindings/style-funcs.js +4 -2
  133. package/dist/themes/buildPalette.d.ts +24 -3
  134. package/dist/themes/buildPalette.js +52 -8
  135. package/dist/themes/colorMath.d.ts +29 -17
  136. package/dist/themes/colorMath.js +74 -48
  137. package/dist/themes/colorRef.d.ts +2 -17
  138. package/dist/themes/colorRef.js +13 -31
  139. package/dist/themes/palette.d.ts +24 -0
  140. package/dist/themes/palette.js +46 -3
  141. package/dist/themes/ramp.d.ts +21 -34
  142. package/dist/themes/ramp.js +23 -42
  143. package/dist/themes/registry.js +39 -28
  144. package/dist/widgets/button.d.ts +2 -6
  145. package/dist/widgets/button.js +9 -40
  146. package/dist/widgets/checkbox.d.ts +2 -5
  147. package/dist/widgets/checkbox.js +9 -31
  148. package/dist/widgets/dropdown.d.ts +2 -5
  149. package/dist/widgets/dropdown.js +17 -51
  150. package/dist/widgets/event-router.d.ts +31 -40
  151. package/dist/widgets/event-router.js +52 -483
  152. package/dist/widgets/index.d.ts +2 -1
  153. package/dist/widgets/ink.d.ts +22 -0
  154. package/dist/widgets/ink.js +31 -0
  155. package/dist/widgets/slider.d.ts +2 -5
  156. package/dist/widgets/slider.js +11 -34
  157. package/dist/widgets/text-input.d.ts +19 -10
  158. package/dist/widgets/text-input.js +156 -147
  159. package/dist/widgets/themed-widget.d.ts +20 -0
  160. package/dist/widgets/themed-widget.js +89 -0
  161. package/dist/widgets/toggle.d.ts +2 -5
  162. package/dist/widgets/toggle.js +8 -31
  163. package/dist/widgets/types.d.ts +8 -25
  164. package/dist/widgets/types.js +8 -0
  165. package/dist/widgets/widget-app.d.ts +24 -7
  166. package/dist/widgets/widget-app.js +35 -18
  167. package/dist/widgets/widget-base.d.ts +2 -0
  168. package/dist/widgets/widget-base.js +9 -2
  169. package/package.json +31 -17
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Rich's `_ratio.py`: integer totals split into parts by ratio, in exact
3
+ * arithmetic, each by the reference's own rounding.
4
+ *
5
+ * The rounding is the reference's and is kept for that reason alone. A
6
+ * largest-remainder split is fairer, and it is a different answer: 17 cells
7
+ * over ratios 2 and 1 are 12 + 5 by `ratioDistribute` and 11 + 6 by largest
8
+ * remainder, and 5 cells at 1:1 are 2 + 3 by `ratioResolve` and 3 + 2 by
9
+ * largest remainder with ties to the left. A renderable that splits by any
10
+ * other rule cannot print the bytes Rich prints for the same call.
11
+ *
12
+ * The arithmetic is the reference's too: Python's integers and `Fraction`s are
13
+ * exact at any size, and a double's `ratio * remaining` rounds once it passes
14
+ * 2^53, so the parts stopped summing to the total near
15
+ * `Number.MAX_SAFE_INTEGER`.
16
+ */
17
+ /**
18
+ * Rich's `ratio_distribute`: each part the ceiling of its share of what the
19
+ * parts before it left, and never below its minimum, so the rounding lands on
20
+ * the leftmost parts. Given `minimums`, a part whose minimum is 0 takes no
21
+ * share, as the reference zeroes its ratio first. The parts sum to the total
22
+ * whenever the minimums leave room; a minimum that does not fit is paid anyway,
23
+ * as the reference pays it, and the parts then overrun the total. The ratios
24
+ * are weights in one exact proportion (`exactWeights`), so a fractional one
25
+ * divides as it reads, where the reference takes only whole ones.
26
+ */
27
+ export function ratioDistribute(total, ratios, minimums) {
28
+ const weights = exactWeights(minimums === undefined ? ratios : ratios.map((ratio, index) => (minimums[index] > 0 ? ratio : 0)));
29
+ let totalRatio = weights.reduce((sum, ratio) => sum + ratio, 0n);
30
+ let remaining = BigInt(total);
31
+ return weights.map((ratio, index) => {
32
+ // A zero ratio sum is the reference's `else` arm: whatever is left goes to
33
+ // the part at hand, so the total is still handed out in full.
34
+ const share = totalRatio > 0n ? ceilDiv(ratio * remaining, totalRatio) : remaining;
35
+ const minimum = BigInt(minimums?.[index] ?? 0);
36
+ const part = share > minimum ? share : minimum;
37
+ totalRatio -= ratio;
38
+ remaining -= part;
39
+ return Number(part);
40
+ });
41
+ }
42
+ /**
43
+ * Rich's `ratio_reduce`: `total` taken back from `values` by ratio, each part
44
+ * giving up at most its `maximum`, and a part whose maximum is 0 giving up
45
+ * nothing, as the reference zeroes its ratio first. Each part gives up its
46
+ * share of what the parts before it left, rounded half to even as Python's
47
+ * `round` is, so 9 at 1:1 is 4 + 5. A part held to its maximum leaves the rest
48
+ * to the parts after it; when they cannot take it all, less than `total` is
49
+ * taken back, as in the reference.
50
+ */
51
+ export function ratioReduce(total, ratios, maximums, values) {
52
+ const weights = exactWeights(ratios.map((ratio, index) => (maximums[index] > 0 ? ratio : 0)));
53
+ let totalRatio = weights.reduce((sum, ratio) => sum + ratio, 0n);
54
+ let remaining = BigInt(total);
55
+ return weights.map((ratio, index) => {
56
+ if (ratio === 0n)
57
+ return values[index];
58
+ const share = roundDiv(ratio * remaining, totalRatio);
59
+ const maximum = BigInt(maximums[index]);
60
+ const given = share < maximum ? share : maximum;
61
+ remaining -= given;
62
+ totalRatio -= ratio;
63
+ return values[index] - Number(given);
64
+ });
65
+ }
66
+ /** Python's `round(a / b)` for a positive `b`: the nearest integer, a tie to the even one. */
67
+ function roundDiv(a, b) {
68
+ const floor = a / b - (a % b < 0n ? 1n : 0n);
69
+ const twice = 2n * (a - floor * b);
70
+ return twice > b || (twice === b && floor % 2n !== 0n) ? floor + 1n : floor;
71
+ }
72
+ /** Python's `ceil(a / b)` for a positive `b`: `BigInt` division truncates toward zero, which is the ceiling for a negative `a`. */
73
+ function ceilDiv(a, b) {
74
+ const quotient = a / b;
75
+ return a % b > 0n ? quotient + 1n : quotient;
76
+ }
77
+ /**
78
+ * A share weight, not a cell count: fractions divide space meaningfully, so
79
+ * this parses where `cellCount` would floor. A weight that cannot name a share
80
+ * — negative, NaN, infinite — reads as zero, which already means "this part
81
+ * does not grow" and is set aside before any division.
82
+ */
83
+ export function shareWeight(ratio) {
84
+ return (Number.isFinite(ratio) && ratio > 0 ? ratio : 0);
85
+ }
86
+ /** Whether an edge takes a share by ratio; one that does not is paid `size ?? minimumSize`. */
87
+ const grows = (edge) => edge.size === undefined && edge.ratio !== 0;
88
+ /**
89
+ * Rich's `ratio_resolve`: `total` cells split across `edges`, the declared
90
+ * sizes paid first and what is left shared by ratio.
91
+ *
92
+ * A growing edge whose share would not reach its `minimumSize` is paid that
93
+ * minimum and the rest are shared again without it, one edge at a time, first
94
+ * in order first. The shares then round by carrying each fraction into the edge
95
+ * after it, so they sum to what was left and the spare cells land on the
96
+ * rightmost edges: 5 cells at 1:1 are 2 + 3.
97
+ *
98
+ * Three departures, all the port's, not the reference's. Nothing is paid past
99
+ * what `total` still holds: Rich hands every edge its minimum even when that
100
+ * overruns the total and leaves the overflow for the screen to clip; here an
101
+ * edge is paid out of what is left, so the parts never sum past `total` and two
102
+ * panes of a layout asked to fit one cell cannot merge into a two-cell row. And
103
+ * a declared `size` of 0 is a pane of no cells, where Rich's `edge.size or
104
+ * None` reads it as undeclared and lets the pane grow. And a `ratio` of 0 is
105
+ * paid its minimum before any share is cut, where Rich keeps it among the
106
+ * growing parts and counts it as 1 in the sum (`edge.ratio or 1`), which thins
107
+ * every other share and can leave cells unassigned.
108
+ */
109
+ export function ratioResolve(total, edges) {
110
+ const sizes = edges.map(() => 0);
111
+ let remaining = total;
112
+ const pay = (index, cells) => {
113
+ sizes[index] = Math.min(cells, remaining);
114
+ remaining -= sizes[index];
115
+ };
116
+ const growing = [];
117
+ edges.forEach((edge, index) => {
118
+ if (grows(edge))
119
+ growing.push(index);
120
+ else
121
+ pay(index, edge.size ?? edge.minimumSize);
122
+ });
123
+ const weights = exactWeights(growing.map((index) => edges[index].ratio));
124
+ let open = growing.map((index, slot) => ({
125
+ index,
126
+ weight: weights[slot],
127
+ minimum: BigInt(edges[index].minimumSize),
128
+ }));
129
+ // `remaining * weight / sum` is the edge's share; compared across the
130
+ // division so the test is exact. A budget already spent fails every edge's
131
+ // test, which pays each its minimum out of nothing — zero, the clip above.
132
+ let sum = open.reduce((acc, { weight }) => acc + weight, 0n);
133
+ const firstShort = () => open.find(({ weight, minimum }) => BigInt(remaining) * weight <= minimum * sum);
134
+ for (let short = firstShort(); short !== undefined; short = firstShort()) {
135
+ pay(short.index, Number(short.minimum));
136
+ sum -= short.weight;
137
+ open = open.filter((edge) => edge !== short);
138
+ }
139
+ // Carrying each edge's fraction into the next is the same as flooring the
140
+ // running total of the shares: an edge gets the whole cells its share moves
141
+ // that running total across.
142
+ const budget = BigInt(remaining);
143
+ let weightSoFar = 0n;
144
+ let cellsSoFar = 0n;
145
+ for (const { index, weight } of open) {
146
+ const before = cellsSoFar;
147
+ weightSoFar += weight;
148
+ cellsSoFar = (budget * weightSoFar) / sum;
149
+ sizes[index] = Number(cellsSoFar - before);
150
+ }
151
+ return sizes;
152
+ }
153
+ /**
154
+ * A `total` at which `ratioResolve` gives every growing edge at least
155
+ * `wants[i]` cells, and every other edge its declared size or minimum.
156
+ *
157
+ * [LAW:one-source-of-truth] It inverts the split above and sits beside it for
158
+ * that reason. A growing edge is never handed less than the floor of its share,
159
+ * `total * ratio / totalRatio`, because the running total it is carved from
160
+ * floors once on each side. So a budget at which every share's floor reaches
161
+ * its want is enough, and one budget serves them all: the row needs the largest
162
+ * such demand, never their total.
163
+ *
164
+ * Enough, and not always the least. Carried rounding hands spare cells to the
165
+ * right, so "left" and "right" at 1:1 fit in 9, as 4 + 5, where this answers
166
+ * 10. The least total is no closed formula's answer — an edge's cells are not
167
+ * monotone in the total — and searching for it costs a split per cell climbed,
168
+ * a climb that grows with how far apart the ratios are. The spare cells are
169
+ * cheaper.
170
+ *
171
+ * At that budget every share already covers its edge's minimum — the want
172
+ * includes it — so the re-share pass pays at most an edge whose share is
173
+ * exactly that minimum, which leaves every other share where it was. A want is
174
+ * whole cells, a fraction rounded up; an unbounded one, a pane with no width of
175
+ * its own offered all of it, is an unbounded budget, given before the exact
176
+ * arithmetic, which has no infinity.
177
+ */
178
+ export function ratioBudget(edges, wants) {
179
+ const growing = edges.flatMap((edge, index) => (grows(edge) ? [index] : []));
180
+ const owed = growing.map((index) => Math.ceil(Math.max(edges[index].minimumSize, wants[index])));
181
+ if (!owed.every(Number.isFinite))
182
+ return Infinity;
183
+ const weights = exactWeights(growing.map((index) => edges[index].ratio));
184
+ const sum = weights.reduce((acc, weight) => acc + weight, 0n);
185
+ const pinned = edges.reduce((acc, edge) => acc + (grows(edge) ? 0 : (edge.size ?? edge.minimumSize)), 0);
186
+ const budget = owed.reduce((most, cells, slot) => {
187
+ const need = ceilDiv(BigInt(cells) * sum, weights[slot]);
188
+ return need > most ? need : most;
189
+ }, 0n);
190
+ return pinned + Number(budget);
191
+ }
192
+ /**
193
+ * Weights as integers in one exact proportion. Each is read as the decimal it
194
+ * prints as — the number its caller wrote, `0.1` rather than the binary double
195
+ * nearest it — and all are scaled by the one power of ten that makes them
196
+ * whole, so `0.1 : 0.3 : 1` is exactly `1 : 3 : 10`. A weight that does not
197
+ * print as a decimal, `Infinity`, is refused by `BigInt` with a SyntaxError:
198
+ * each caller parses its weights finite first, and this is the loud end of
199
+ * that.
200
+ *
201
+ * The common exponent is accumulated rather than spread into `Math.max`: a
202
+ * layout holds as many panes as its caller made, and one argument per pane
203
+ * overruns the engine's argument limit.
204
+ */
205
+ export function exactWeights(weights) {
206
+ const decimals = weights.map((weight) => {
207
+ const [digits = "", power = "0"] = String(weight).split("e");
208
+ const [whole = "", fraction = ""] = digits.split(".");
209
+ return { mantissa: BigInt(whole + fraction), exponent: fraction.length - Number(power) };
210
+ });
211
+ const common = decimals.reduce((most, { exponent }) => Math.max(most, exponent), 0);
212
+ return decimals.map(({ mantissa, exponent }) => mantissa * 10n ** BigInt(common - exponent));
213
+ }
@@ -2,8 +2,8 @@
2
2
  * Rule — a horizontal divider line, optionally with a centered title.
3
3
  */
4
4
  import { Segment } from "../core/segment.js";
5
- import type { RichText } from "../core/text.js";
6
- import { Style } from "../core/style.js";
5
+ import { type RichText } from "../core/text.js";
6
+ import type { Style } from "../core/style.js";
7
7
  import type { Renderable, Measurable, RenderOptions } from "../core/protocol.js";
8
8
  export type RuleAlign = "left" | "center" | "right";
9
9
  export interface RuleOptions {
@@ -16,6 +16,7 @@ export declare class Rule implements Renderable, Measurable {
16
16
  readonly characters: string;
17
17
  readonly align: RuleAlign;
18
18
  readonly style: string | Style;
19
+ private readonly _label;
19
20
  constructor(title?: string | RichText, options?: RuleOptions);
20
21
  render(options: RenderOptions): Iterable<Segment>;
21
22
  measure(_options: RenderOptions): {
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Rule — a horizontal divider line, optionally with a centered title.
3
3
  */
4
- import { cellLen } from "../core/cells.js";
4
+ import { asCellCol, cellLen, setCellSize } from "../core/cells.js";
5
5
  import { Segment } from "../core/segment.js";
6
- import { NULL_STYLE } from "../core/style.js";
7
- import { drawable, getStyle } from "../core/protocol.js";
8
- import { inlineLabel } from "./embed.js";
6
+ import { resolveStyle } from "../core/text.js";
7
+ import { drawable } from "../core/protocol.js";
8
+ import { cutLabel, drawLabel, inlineLabel } from "./embed.js";
9
9
  const ASCII_RULE_CHAR = "-";
10
10
  const DEFAULT_RULE_CHAR = "\u2500"; // ─
11
11
  export class Rule {
@@ -13,53 +13,67 @@ export class Rule {
13
13
  characters;
14
14
  align;
15
15
  style;
16
+ _label;
16
17
  constructor(title, options) {
17
18
  const chars = options?.characters ?? DEFAULT_RULE_CHAR;
18
- if (chars.length === 0) {
19
- throw new Error("Rule characters must not be empty");
19
+ // [LAW:parse-dont-validate] Rich's rule: a rule drawn from zero-width
20
+ // characters would advance no cells, so `repeatToWidth` never meets one.
21
+ if (cellLen(chars) < 1) {
22
+ throw new Error("Rule characters must have a cell width of at least 1");
20
23
  }
21
24
  const align = options?.align;
22
25
  if (align !== undefined && align !== "left" && align !== "center" && align !== "right") {
23
26
  throw new Error(`Invalid align value: "${align}"`);
24
27
  }
25
28
  this.title = title;
29
+ this._label = inlineLabel(title);
26
30
  this.characters = chars;
27
31
  this.align = align ?? "center";
28
- this.style = options?.style ?? NULL_STYLE;
32
+ this.style = options?.style ?? "rule.line";
29
33
  }
30
34
  *render(options) {
31
35
  const maxWidth = options.maxWidth;
32
36
  const ruleChar = drawable(options, this.characters, ASCII_RULE_CHAR);
33
- const style = getStyle(options, this.style);
37
+ // [LAW:single-enforcer] Resolved as Rich resolves the span it appends the
38
+ // line in: a name the theme lacks draws unstyled rather than throwing.
39
+ const style = resolveStyle(options, this.style);
34
40
  const ruleStyle = style.isNull ? undefined : style;
35
- const title = inlineLabel(this.title, options, ruleStyle);
36
- const titleWidth = Segment.getLineLength(title);
37
- if (titleWidth === 0) {
38
- // No title — just a line of repeated characters
39
- yield new Segment(repeatToWidth(ruleChar, maxWidth), ruleStyle);
40
- yield Segment.line();
41
- return;
42
- }
43
- if (titleWidth >= maxWidth) {
44
- // Title fills the whole width; adjustLineLength cuts by cells, not code units.
45
- yield* Segment.adjustLineLength(title, maxWidth, ruleStyle);
41
+ const line = (width) => new Segment(repeatToWidth(ruleChar, width), ruleStyle);
42
+ // Rich's `required_space`: a centred title keeps a rule cell and a space
43
+ // either side of it, an aligned one a space and a rule cell on its open
44
+ // side. A rule with no room past that draws no title, as one without a title.
45
+ const room = Math.max(0, maxWidth - (this.align === "center" ? 4 : 2));
46
+ if (this._label === undefined || room === 0) {
47
+ yield line(maxWidth);
46
48
  yield Segment.line();
47
49
  return;
48
50
  }
49
- const remaining = maxWidth - titleWidth;
50
- // [LAW:dataflow-not-control-flow] Always compute both sides; alignment determines distribution
51
- const leftWidth = this.align === "right"
52
- ? remaining
53
- : this.align === "center"
54
- ? Math.floor(remaining / 2)
55
- : 0;
56
- const rightWidth = remaining - leftWidth;
57
- if (leftWidth > 0) {
58
- yield new Segment(repeatToWidth(ruleChar, leftWidth), ruleStyle);
59
- }
60
- yield* title;
61
- if (rightWidth > 0) {
62
- yield new Segment(repeatToWidth(ruleChar, rightWidth), ruleStyle);
51
+ // Rich's `render_str(title, style="rule.text")`: the title is read in its
52
+ // own style and set beside the line, never in the line's style.
53
+ const text = this._label.text(options, "rule.text");
54
+ cutLabel(text, room, drawable(options, "\u2026", "."));
55
+ const title = drawLabel(text, options, undefined);
56
+ const titleWidth = Segment.getLineLength(title);
57
+ // As Rich lays the line out: the gaps of a centred title are the rule's,
58
+ // styled with it; an aligned title's one gap is unstyled.
59
+ switch (this.align) {
60
+ case "center": {
61
+ const left = Math.floor((maxWidth - titleWidth) / 2) - 1;
62
+ yield new Segment(`${repeatToWidth(ruleChar, left)} `, ruleStyle);
63
+ yield* title;
64
+ yield new Segment(` ${repeatToWidth(ruleChar, maxWidth - left - titleWidth - 2)}`, ruleStyle);
65
+ break;
66
+ }
67
+ case "left":
68
+ yield* title;
69
+ yield new Segment(" ");
70
+ yield line(maxWidth - titleWidth - 1);
71
+ break;
72
+ case "right":
73
+ yield line(maxWidth - titleWidth - 1);
74
+ yield new Segment(" ");
75
+ yield* title;
76
+ break;
63
77
  }
64
78
  yield Segment.line();
65
79
  }
@@ -67,24 +81,8 @@ export class Rule {
67
81
  return { minimum: 1, maximum: _options.maxWidth };
68
82
  }
69
83
  }
84
+ // [LAW:one-source-of-truth] `setCellSize` crops between the grapheme clusters
85
+ // `cellLen` measures, so `❤️` or `👨‍👩‍👧` is never cut in half or over-counted.
70
86
  function repeatToWidth(char, width) {
71
- const charWidth = cellLen(char);
72
- if (charWidth === 0)
73
- return " ".repeat(width);
74
- const repeats = Math.ceil(width / charWidth);
75
- const full = char.repeat(repeats);
76
- // Trim to exact width
77
- let w = 0;
78
- let i = 0;
79
- for (const c of full) {
80
- const cw = cellLen(c);
81
- if (w + cw > width)
82
- break;
83
- w += cw;
84
- i += c.length;
85
- }
86
- const result = full.slice(0, i);
87
- // Pad if needed (wide char couldn't fill exact width)
88
- const gap = width - w;
89
- return gap > 0 ? result + " ".repeat(gap) : result;
87
+ return setCellSize(char.repeat(Math.ceil(width / cellLen(char))), asCellCol(width));
90
88
  }
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { Segment } from "../core/segment.js";
5
5
  import { Style } from "../core/style.js";
6
+ import { RichText } from "../core/text.js";
6
7
  import type { Renderable, Measurable, RenderOptions } from "../core/protocol.js";
7
8
  export interface SpinnerOptions {
8
9
  speed?: number;
@@ -10,20 +11,45 @@ export interface SpinnerOptions {
10
11
  }
11
12
  export declare class Spinner implements Renderable, Measurable {
12
13
  readonly name: string;
13
- readonly text: string | undefined;
14
14
  readonly speed: number;
15
15
  readonly style: string | Style;
16
+ /**
17
+ * The label drawn after the frame. It is read through `embed`'s one crossing
18
+ * each time it is drawn, so a string is markup under the console that draws
19
+ * it, as a string handed to `print` is — where Rich's `Spinner` parses it
20
+ * whatever the console says — and a `RichText` changed in place draws as
21
+ * changed, as the `Text` Rich keeps does. [LAW:single-enforcer]
22
+ */
23
+ text: string | RichText;
16
24
  private readonly _data;
17
25
  private _frameIndex;
18
26
  private _lastUpdate;
19
- constructor(name?: string, text?: string, options?: SpinnerOptions);
27
+ constructor(name?: string, text?: string | RichText, options?: SpinnerOptions);
20
28
  get frames(): readonly string[];
21
29
  get interval(): number;
22
30
  /** Advance the frame count by the time elapsed, and return it. */
23
31
  private _advance;
24
32
  /** The frames this output can draw: the `line` spinner stands in for any other on an ASCII-only one. */
25
33
  private _drawnFrames;
34
+ /**
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.
38
+ */
26
39
  render(options: RenderOptions): Iterable<Segment>;
40
+ private _currentLine;
41
+ /**
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.
46
+ */
47
+ 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
+ */
27
53
  measure(options: RenderOptions): {
28
54
  minimum: number;
29
55
  maximum: number;
@@ -4,18 +4,27 @@
4
4
  import { cellLen } from "../core/cells.js";
5
5
  import { Segment } from "../core/segment.js";
6
6
  import { NULL_STYLE } from "../core/style.js";
7
+ import { RichText } from "../core/text.js";
7
8
  import { SPINNERS, DEFAULT_SPINNER, LINE_SPINNER } from "../core/spinnerData.js";
8
- import { drawable, getStyle } from "../core/protocol.js";
9
+ import { drawable } from "../core/protocol.js";
10
+ import { EmbeddedText } from "./embed.js";
9
11
  const spinnerGlyphs = ({ frames }) => frames.join("");
10
12
  export class Spinner {
11
13
  name;
12
- text;
13
14
  speed;
14
15
  style;
16
+ /**
17
+ * The label drawn after the frame. It is read through `embed`'s one crossing
18
+ * each time it is drawn, so a string is markup under the console that draws
19
+ * it, as a string handed to `print` is — where Rich's `Spinner` parses it
20
+ * whatever the console says — and a `RichText` changed in place draws as
21
+ * changed, as the `Text` Rich keeps does. [LAW:single-enforcer]
22
+ */
23
+ text;
15
24
  _data;
16
25
  _frameIndex;
17
26
  _lastUpdate;
18
- constructor(name, text, options) {
27
+ constructor(name, text = "", options) {
19
28
  const spinnerName = name ?? DEFAULT_SPINNER;
20
29
  const data = SPINNERS[spinnerName];
21
30
  if (!data) {
@@ -38,11 +47,12 @@ export class Spinner {
38
47
  /** Advance the frame count by the time elapsed, and return it. */
39
48
  _advance() {
40
49
  const now = Date.now();
41
- const elapsed = now - this._lastUpdate;
42
- const effectiveInterval = this.interval / this.speed;
43
- if (elapsed >= effectiveInterval) {
44
- const steps = Math.floor(elapsed / effectiveInterval);
45
- this._frameIndex = (this._frameIndex + steps) % this._data.frames.length;
50
+ // Whole frames passed at this speed; a negative speed steps backwards, as
51
+ // Rich's does, so the index wraps with a modulo that is never negative.
52
+ const steps = Math.trunc(((now - this._lastUpdate) * this.speed) / this.interval);
53
+ if (steps !== 0) {
54
+ const count = this._data.frames.length;
55
+ this._frameIndex = (((this._frameIndex + steps) % count) + count) % count;
46
56
  this._lastUpdate = now;
47
57
  }
48
58
  return this._frameIndex;
@@ -51,20 +61,43 @@ export class Spinner {
51
61
  _drawnFrames(options) {
52
62
  return drawable(options, this._data, LINE_SPINNER, spinnerGlyphs).frames;
53
63
  }
64
+ /**
65
+ * The current frame and its label as one line wrapped to the width and
66
+ * ended, as the reference's `Text` is — printed, stacked in a `Group`, or
67
+ * laid out in a `Progress` cell alike.
68
+ */
54
69
  *render(options) {
70
+ yield* this._currentLine(options).render(options);
71
+ yield Segment.line();
72
+ }
73
+ _currentLine(options) {
55
74
  const frames = this._drawnFrames(options);
56
- const frame = frames[this._advance() % frames.length];
57
- const style = getStyle(options, this.style);
58
- const spinStyle = style.isNull ? undefined : style;
59
- yield new Segment(frame, spinStyle);
60
- if (this.text) {
61
- yield new Segment(` ${this.text}`);
62
- }
75
+ return this._line(frames[this._advance() % frames.length], options);
76
+ }
77
+ /**
78
+ * `frame` and the label as one text, as Rich's `Text.assemble(frame, " ",
79
+ * text)`: a long label wraps as one line with its frame. A label that draws
80
+ * nothing — the default `""`, or markup that styles nothing — is no label,
81
+ * not a trailing space, as an empty `Text` is falsy there.
82
+ */
83
+ _line(frame, options) {
84
+ const line = new RichText("", { end: "" }).append(frame, this.style);
85
+ // Unhighlighted: Rich's label is a `Text` from the moment it is handed over, and a `Text` is never highlighted.
86
+ const label = new EmbeddedText(this.text).text({ ...options, highlight: false });
87
+ if (label.plain !== "")
88
+ line.append(" ").append(label);
89
+ return line;
63
90
  }
91
+ /**
92
+ * Measured at its widest frame, so a column holding it never narrows as it
93
+ * turns, and never offered less than that frame: a frame is one picture,
94
+ * which the reference's `Text` measure would let wrap apart at its spaces.
95
+ */
64
96
  measure(options) {
65
- const frameWidth = Math.max(...this._drawnFrames(options).map((f) => cellLen(f)));
66
- const textWidth = this.text ? cellLen(this.text) + 1 : 0;
67
- const total = frameWidth + textWidth;
68
- return { minimum: frameWidth, maximum: total };
97
+ const frames = this._drawnFrames(options);
98
+ const widths = frames.map((frame) => cellLen(frame));
99
+ const frameWidth = Math.max(...widths);
100
+ const { minimum, maximum } = this._line(frames[widths.indexOf(frameWidth)], options).measure(options);
101
+ return { minimum: Math.max(minimum, Math.min(frameWidth, maximum)), maximum };
69
102
  }
70
103
  }
@@ -1,23 +1,29 @@
1
1
  /**
2
2
  * Status — displays a spinner animation with a status message.
3
+ *
4
+ * Its renderable is a `Spinner` whose label is the message, as Rich's is, so
5
+ * the message is drawn the way the spinner draws its label: a string is markup
6
+ * under the console that draws it.
3
7
  */
4
- import { Style } from "../core/style.js";
8
+ import type { Style } from "../core/style.js";
9
+ import type { RichText } from "../core/text.js";
5
10
  import { Console } from "../core/console.js";
6
11
  export interface StatusOptions {
7
12
  spinner?: string;
8
13
  speed?: number;
9
- style?: string | Style;
14
+ /** The spinner's frame style; the message styles itself with its markup. */
15
+ spinnerStyle?: string | Style;
10
16
  console?: Console;
11
17
  }
12
18
  export declare class Status {
13
19
  private _live;
14
- private _renderable;
20
+ private _spinner;
15
21
  private _console;
16
- constructor(message: string, options?: StatusOptions);
22
+ constructor(message: string | RichText, options?: StatusOptions);
17
23
  get console(): Console;
18
- get message(): string;
19
- set message(value: string);
24
+ get message(): string | RichText;
25
+ set message(value: string | RichText);
20
26
  start(): void;
21
27
  stop(): void;
22
- update(message: string): void;
28
+ update(message: string | RichText): void;
23
29
  }
@@ -1,38 +1,24 @@
1
1
  /**
2
2
  * Status — displays a spinner animation with a status message.
3
+ *
4
+ * Its renderable is a `Spinner` whose label is the message, as Rich's is, so
5
+ * the message is drawn the way the spinner draws its label: a string is markup
6
+ * under the console that draws it.
3
7
  */
4
- import { Segment } from "../core/segment.js";
5
- import { NULL_STYLE } from "../core/style.js";
6
8
  import { Console } from "../core/console.js";
7
9
  import { Spinner } from "./spinner.js";
8
10
  import { Live } from "./live.js";
9
- import { getStyle } from "../core/protocol.js";
10
- /** A renderable that shows spinner + message */
11
- class StatusRenderable {
12
- message;
13
- _spinner;
14
- _style;
15
- constructor(message, spinner, style) {
16
- this.message = message;
17
- this._spinner = spinner;
18
- this._style = style;
19
- }
20
- *render(options) {
21
- yield* this._spinner.render(options);
22
- const style = getStyle(options, this._style);
23
- const msgStyle = style.isNull ? undefined : style;
24
- yield new Segment(` ${this.message}`, msgStyle);
25
- }
26
- }
27
11
  export class Status {
28
12
  _live;
29
- _renderable;
13
+ _spinner;
30
14
  _console;
31
15
  constructor(message, options) {
32
- this._console = options?.console ?? new Console({ forceTerminal: true });
33
- const spinner = new Spinner(options?.spinner ?? "dots", undefined, { speed: options?.speed });
34
- this._renderable = new StatusRenderable(message, spinner, options?.style ?? NULL_STYLE);
35
- this._live = new Live(this._renderable, {
16
+ this._console = options?.console ?? new Console();
17
+ this._spinner = new Spinner(options?.spinner ?? "dots", message, {
18
+ speed: options?.speed,
19
+ style: options?.spinnerStyle ?? "status.spinner",
20
+ });
21
+ this._live = new Live(this._spinner, {
36
22
  console: this._console,
37
23
  transient: true,
38
24
  refreshPerSecond: 12.5,
@@ -42,10 +28,10 @@ export class Status {
42
28
  return this._console;
43
29
  }
44
30
  get message() {
45
- return this._renderable.message;
31
+ return this._spinner.text;
46
32
  }
47
33
  set message(value) {
48
- this._renderable.message = value;
34
+ this._spinner.text = value;
49
35
  }
50
36
  start() {
51
37
  this._live.start();
@@ -54,7 +40,7 @@ export class Status {
54
40
  this._live.stop();
55
41
  }
56
42
  update(message) {
57
- this._renderable.message = message;
58
- this._live.update(this._renderable, { refresh: true });
43
+ this._spinner.text = message;
44
+ this._live.update(this._spinner, { refresh: true });
59
45
  }
60
46
  }