@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
@@ -3,19 +3,10 @@
3
3
  */
4
4
  import { Segment } from "../core/segment.js";
5
5
  import { embed } from "./embed.js";
6
- import { fitHeight, isMeasurable, regionRows, stackedHeight, withBoundedWidth, withCellWidth, } from "../core/protocol.js";
6
+ import { fitHeight, regionRows, stackedHeight, withBoundedWidth, withCellWidth, } from "../core/protocol.js";
7
7
  import { Measurement } from "../core/measure.js";
8
8
  import { cellCount } from "../core/cells.js";
9
- /**
10
- * A share weight, not a cell count: fractions divide space meaningfully, so
11
- * this parses where `cellCount` would floor. A weight that cannot name a share
12
- * — negative, NaN, infinite — reads as zero, which already means "this pane
13
- * does not grow" and is filtered out before any division. That is what makes
14
- * every ratio reaching `_distributeSpace` and `_rowBudgetFor` positive.
15
- */
16
- function growthRatio(ratio) {
17
- return Number.isFinite(ratio) && ratio > 0 ? ratio : 0;
18
- }
9
+ import { ratioBudget, ratioResolve, shareWeight } from "./ratio.js";
19
10
  /**
20
11
  * A pane rendered into a region of `rows` and held to exactly that many lines,
21
12
  * or — with `rows` undefined — rendered under the layout's own budget, as a
@@ -55,7 +46,7 @@ export class Layout {
55
46
  * constructor. All three are public and a caller reaches them long after
56
47
  * construction — `layout.getByName("pane")!.ratio = -1` walked straight past a
57
48
  * constructor-only parse and put a negative weight back into the division that
58
- * `_rowBudgetFor` and `_distributeSpace` are written to trust.
49
+ * `ratioResolve` and `ratioBudget` are written to trust.
59
50
  *
60
51
  * [LAW:parse-dont-validate] The setter is the border, so the guarantee holds
61
52
  * for the object's whole lifetime and nothing downstream re-checks. `size` and
@@ -67,7 +58,7 @@ export class Layout {
67
58
  return this._ratio;
68
59
  }
69
60
  set ratio(value) {
70
- this._ratio = growthRatio(value);
61
+ this._ratio = shareWeight(value);
71
62
  }
72
63
  get size() {
73
64
  return this._size;
@@ -125,13 +116,23 @@ export class Layout {
125
116
  // would otherwise hand each of them the caller's raw number.
126
117
  const options = withBoundedWidth(rawOptions, this);
127
118
  if (this._isLeaf) {
128
- // Cropped rather than forwarded: a leaf hands its content the offer and
129
- // content is free to ignore it, and a pane wider than the region it was
130
- // given is the one thing a layout may never emit — in a row split it
131
- // overwrites the pane beside it. The row path already crops each share,
132
- // so this is the same rule at the one place that skipped it.
119
+ // Shaped to its region, as Rich's `render_lines` shapes a pane: every
120
+ // line cropped and padded to the width, and held to the region's rows.
121
+ // The leaf sets its content's region, so it shapes what comes back, as
122
+ // `paneLines` shapes a pane for the split that set the pane's.
123
+ // Cropped rather than forwarded because content is free to ignore its
124
+ // offer, and a pane wider than its region is the one thing a layout may
125
+ // never emit — in a row split it overwrites the pane beside it. Held to
126
+ // the rows because content keeps its own `end`: `RichText("status:
127
+ // ok\n")` is two lines, and a one-row region cuts the second. Every line
128
+ // ended, as the split paths below end theirs: a leaf stacked in a
129
+ // `Group` is still a region of lines.
133
130
  if (this._renderable) {
134
- yield* Segment.cropLines(this._renderable.render(options), options.maxWidth);
131
+ const lines = fitHeight(Segment.splitLines(this._renderable.render(options)), options.height);
132
+ for (const line of lines) {
133
+ yield* Segment.adjustLineLength(line, options.maxWidth);
134
+ yield Segment.line();
135
+ }
135
136
  }
136
137
  return;
137
138
  }
@@ -152,7 +153,7 @@ export class Layout {
152
153
  const region = regionRows(options.height);
153
154
  const shares = region === undefined
154
155
  ? children.map((child) => child.size)
155
- : this._distributeSpace(children, region);
156
+ : ratioResolve(region, children);
156
157
  for (let i = 0; i < children.length; i++) {
157
158
  for (const line of paneLines(children[i], options, shares[i])) {
158
159
  yield* line;
@@ -166,7 +167,7 @@ export class Layout {
166
167
  // width made every share NaN and the merge threw `Invalid array length`.
167
168
  // Every pane stands in the whole region, and with none the merge pads the
168
169
  // row to its tallest pane.
169
- const widths = this._distributeSpace(children, options.maxWidth);
170
+ const widths = ratioResolve(options.maxWidth, children);
170
171
  const region = regionRows(options.height);
171
172
  const cells = children.map((child, i) => ({
172
173
  width: widths[i],
@@ -174,55 +175,6 @@ export class Layout {
174
175
  }));
175
176
  yield* Segment.mergeHorizontal(cells);
176
177
  }
177
- _distributeSpace(children, totalSpace) {
178
- const sizes = new Array(children.length).fill(0);
179
- let remaining = totalSpace;
180
- // Children that do not grow are paid first: a declared `size`, and equally
181
- // a `ratio` of 0, which is the caller saying this pane stays at its
182
- // `minimumSize`. Left in the ratio pass below, a zero-ratio child divided
183
- // by its own ratio and drew its minimum out of a budget the shares had
184
- // already spent — three panes of `zzz`/`aaaaa`/`bbbbb` consumed
185
- // `3 + 2*floor(T/2)` cells out of `T`, which no `T` satisfies, and the row
186
- // only came out right at 16 because the `Math.min(remaining, ...)` below
187
- // happened to clip the last share to the right number.
188
- const growing = [];
189
- for (let i = 0; i < children.length; i++) {
190
- const child = children[i];
191
- if (child.size !== undefined) {
192
- sizes[i] = Math.min(child.size, remaining);
193
- }
194
- else if (child.ratio === 0) {
195
- sizes[i] = Math.min(child.minimumSize, remaining);
196
- }
197
- else {
198
- growing.push(i);
199
- continue;
200
- }
201
- remaining -= sizes[i];
202
- }
203
- // Distribute what is left by ratio. Each child's share comes off one
204
- // budget and is bounded by what that budget still holds: `minimumSize` is
205
- // what a child asks for, `remaining` is what there is to give, and the
206
- // second beats the first. Applied without that bound — as
207
- // `Math.max(minimumSize, share)` alone — every child claimed its whole
208
- // minimum out of a budget that could not cover one of them, so two
209
- // children of a Layout asked to fit one cell merged into a two-cell row.
210
- if (growing.length > 0 && remaining > 0) {
211
- const budget = remaining;
212
- // Every ratio in here is positive — `growthRatio` parses the rest to zero
213
- // at the constructor and zero is filtered out above — which is what makes
214
- // the division safe and `_rowBudgetFor`'s inverse of it finite.
215
- const totalRatio = growing.reduce((s, idx) => s + children[idx].ratio, 0);
216
- for (const idx of growing) {
217
- const child = children[idx];
218
- const share = Math.floor(budget * child.ratio / totalRatio);
219
- const allocated = Math.min(remaining, Math.max(child.minimumSize, share));
220
- sizes[idx] = allocated;
221
- remaining -= allocated;
222
- }
223
- }
224
- return sizes;
225
- }
226
178
  /**
227
179
  * The width this layout would take if nothing constrained it: its content's
228
180
  * for a leaf, its children's laid out the way the split lays them out.
@@ -237,19 +189,14 @@ export class Layout {
237
189
  if (this._isLeaf) {
238
190
  if (this._renderable === undefined)
239
191
  return 0;
240
- // A leaf whose content cannot measure itself has no width of its own to
241
- // report, so it reports the offer — unbounded included, which is where
242
- // `withBoundedWidth` says so rather than inventing a number.
243
- return isMeasurable(this._renderable)
244
- ? Measurement.get(options, this._renderable).maximum
245
- : options.maxWidth;
192
+ return Measurement.get(options, this._renderable).maximum;
246
193
  }
247
194
  const visible = this._children.filter((c) => c.visible);
248
195
  if (visible.length === 0)
249
196
  return 0;
250
197
  const widths = visible.map((c) => c._naturalWidth(options));
251
198
  if (this._splitDirection === "row")
252
- return this._rowBudgetFor(visible, widths);
199
+ return ratioBudget(visible, widths);
253
200
  // Accumulated, not spread: a column split holds as many children as a caller
254
201
  // made, and `Math.max(...widths)` passes one argument per child, so a
255
202
  // generated dashboard deep enough overruns the engine's argument limit and
@@ -259,42 +206,6 @@ export class Layout {
259
206
  widest = Math.max(widest, width);
260
207
  return widest;
261
208
  }
262
- /**
263
- * The budget at which `_distributeSpace` gives every child of a row at least
264
- * the width it wants — the inverse of the ratio rule above, and the reason
265
- * this is not the sum of those widths.
266
- *
267
- * [LAW:one-source-of-truth] A growing child receives `floor(budget * ratio /
268
- * totalRatio)`, so to receive `want` cells it needs the budget to reach
269
- * `want * totalRatio / ratio`, and one budget serves them all: the row needs
270
- * the largest demand, never their total. Summed instead, a 1:1 split of
271
- * "left" and "right" reported 9 and then rendered "right" into the 4 cells
272
- * that `floor(9/2)` actually hands it.
273
- *
274
- * The panes that do not grow are paid first here for the same reason they
275
- * are paid first there — and it is the reason this divides by no zero: a
276
- * `ratio` of 0 never reaches the division, because a pane that does not grow
277
- * demands nothing of a budget for growing.
278
- */
279
- _rowBudgetFor(children, widths) {
280
- const growing = children.filter((c) => c.size === undefined && c.ratio !== 0);
281
- const totalRatio = growing.reduce((sum, c) => sum + c.ratio, 0);
282
- let pinned = 0;
283
- let budget = 0;
284
- for (const [i, child] of children.entries()) {
285
- if (child.size !== undefined) {
286
- pinned += child.size;
287
- }
288
- else if (child.ratio === 0) {
289
- pinned += child.minimumSize;
290
- }
291
- else {
292
- const want = Math.max(child.minimumSize, widths[i]);
293
- budget = Math.max(budget, Math.ceil(want * totalRatio / child.ratio));
294
- }
295
- }
296
- return pinned + budget;
297
- }
298
209
  measure(rawOptions) {
299
210
  // `minimumSize` is what this layout asks for and the ceiling is what it was
300
211
  // offered; the ceiling wins. Unclamped, a layout measured into no width
@@ -1,40 +1,62 @@
1
1
  /**
2
2
  * Live — animates a portion of the terminal by continuously re-rendering.
3
3
  *
4
- * Two modes:
5
- * - **Inline** (default): clears and redraws N lines in the current scroll
6
- * region. Good for spinners/progress bars below other output.
4
+ * Two modes, each painting every frame over the last in place
5
+ * (`core/paint`):
6
+ * - **Inline** (default): the frame starts at the cursor's line and keeps its
7
+ * own height. Good for spinners/progress bars below other output.
7
8
  * - **Alt-screen** (`altScreen: true`): enters the alternate screen buffer
8
- * on start, cursor-homes on each refresh (no clear flicker), and restores
9
- * the original buffer on stop. The screen is the frame's region, so a
10
- * `Layout` fills it. Good for full-screen TUI apps.
9
+ * on start and restores the original buffer on stop. The screen is the
10
+ * frame's region, so a `Layout` fills it. Good for full-screen TUI apps.
11
+ *
12
+ * On a console that is not interactive — a file, a pipe, a CI log — neither
13
+ * mode paints: what is printed goes out plain, and the frame is written once,
14
+ * when the display stops, as Rich's `Live` does.
15
+ *
16
+ * Live is not built on `App`, though both take a terminal, paint a frame over
17
+ * the last and hand the terminal back. That much is the `Painter`'s, and both
18
+ * hold one. The rest differs in who owns what. An `App` owns its terminal: it
19
+ * starts and stops a `TerminalHost`, sets raw mode, reads keys and pointers,
20
+ * suspends, and builds its own `Console` from the host. A `Live` owns none of
21
+ * that. It draws into a `Console` its caller owns and goes on writing
22
+ * through — a sink that may be a file, whose every other write has to step
23
+ * around the frame (`Console.claimLiveRegion`) — and it reads no input. An
24
+ * `App` over that console would need a host that cannot start, stop, read or
25
+ * suspend, and a second owner of the console's writes.
11
26
  */
27
+ import { type Clock } from "../core/clock.js";
12
28
  import { Console } from "../core/console.js";
13
29
  import { type Renderable } from "../core/protocol.js";
14
30
  export interface LiveOptions {
31
+ /** Frames a second while auto-refreshing: positive and finite, fractional included. */
15
32
  refreshPerSecond?: number;
33
+ /** What the auto-refresh ticks on. The platform's, unless given. */
34
+ clock?: Clock;
16
35
  autoRefresh?: boolean;
17
36
  transient?: boolean;
18
37
  console?: Console;
19
38
  verticalOverflow?: "crop" | "ellipsis" | "visible";
20
39
  /** When true, Live enters the alternate screen buffer on start() and
21
- * restores the original on stop(). Refresh uses cursor-home instead of
22
- * cursor-up-and-clear, eliminating flicker for full-screen layouts. */
40
+ * restores the original on stop(). The screen is the frame's region, so
41
+ * a full-screen layout fills it. */
23
42
  altScreen?: boolean;
24
43
  }
25
44
  export declare class Live {
26
45
  private _renderable;
27
46
  private _console;
28
- private _refreshPerSecond;
47
+ private readonly _rate;
48
+ private readonly _clock;
29
49
  private _autoRefresh;
30
50
  private _transient;
31
51
  private _verticalOverflow;
32
- private _altScreen;
33
- private _timer;
34
- private _lastLineCount;
35
- private _started;
36
- private _firstRefresh;
52
+ private readonly _painter;
53
+ private _stopTicking;
54
+ private _held;
37
55
  constructor(renderable?: Renderable, options?: LiveOptions);
56
+ /**
57
+ * The console to print through while this Live runs: what it prints lands
58
+ * above an inline frame. The alternate screen is all frame, and paints over it.
59
+ */
38
60
  get console(): Console;
39
61
  get renderable(): Renderable | undefined;
40
62
  start(): void;
@@ -43,8 +65,10 @@ export declare class Live {
43
65
  refresh?: boolean;
44
66
  }): void;
45
67
  refresh(): void;
68
+ private _frame;
69
+ private _stepAround;
70
+ private _paint;
71
+ private _screen;
46
72
  private _overflow;
47
- private _erase;
48
73
  private _release;
49
- private _writeCursorControl;
50
74
  }
@@ -1,42 +1,71 @@
1
1
  /**
2
2
  * Live — animates a portion of the terminal by continuously re-rendering.
3
3
  *
4
- * Two modes:
5
- * - **Inline** (default): clears and redraws N lines in the current scroll
6
- * region. Good for spinners/progress bars below other output.
4
+ * Two modes, each painting every frame over the last in place
5
+ * (`core/paint`):
6
+ * - **Inline** (default): the frame starts at the cursor's line and keeps its
7
+ * own height. Good for spinners/progress bars below other output.
7
8
  * - **Alt-screen** (`altScreen: true`): enters the alternate screen buffer
8
- * on start, cursor-homes on each refresh (no clear flicker), and restores
9
- * the original buffer on stop. The screen is the frame's region, so a
10
- * `Layout` fills it. Good for full-screen TUI apps.
9
+ * on start and restores the original buffer on stop. The screen is the
10
+ * frame's region, so a `Layout` fills it. Good for full-screen TUI apps.
11
+ *
12
+ * On a console that is not interactive — a file, a pipe, a CI log — neither
13
+ * mode paints: what is printed goes out plain, and the frame is written once,
14
+ * when the display stops, as Rich's `Live` does.
15
+ *
16
+ * Live is not built on `App`, though both take a terminal, paint a frame over
17
+ * the last and hand the terminal back. That much is the `Painter`'s, and both
18
+ * hold one. The rest differs in who owns what. An `App` owns its terminal: it
19
+ * starts and stops a `TerminalHost`, sets raw mode, reads keys and pointers,
20
+ * suspends, and builds its own `Console` from the host. A `Live` owns none of
21
+ * that. It draws into a `Console` its caller owns and goes on writing
22
+ * through — a sink that may be a file, whose every other write has to step
23
+ * around the frame (`Console.claimLiveRegion`) — and it reads no input. An
24
+ * `App` over that console would need a host that cannot start, stop, read or
25
+ * suspend, and a second owner of the console's writes.
11
26
  */
27
+ import { frameRate, systemClock } from "../core/clock.js";
12
28
  import { Console } from "../core/console.js";
13
29
  import { Segment } from "../core/segment.js";
14
- import { segmentsToString } from "../core/render.js";
30
+ import { FinalFramePainter, SurfacePainter } from "../core/paint.js";
15
31
  import { fitHeight } from "../core/protocol.js";
16
32
  export class Live {
17
33
  _renderable;
18
34
  _console;
19
- _refreshPerSecond;
35
+ _rate;
36
+ _clock;
20
37
  _autoRefresh;
21
38
  _transient;
22
39
  _verticalOverflow;
23
- _altScreen;
24
- _timer;
25
- _lastLineCount;
26
- _started;
27
- _firstRefresh;
40
+ _painter;
41
+ _stopTicking;
42
+ // [LAW:one-source-of-truth] The console's live region, held from `start()`
43
+ // to the hand-back — which is what "started" means, so there is no separate
44
+ // flag to disagree with it.
45
+ _held;
28
46
  constructor(renderable, options) {
29
47
  this._renderable = renderable;
30
- this._console = options?.console ?? new Console({ forceTerminal: true });
31
- this._refreshPerSecond = options?.refreshPerSecond ?? 4;
48
+ this._console = options?.console ?? new Console();
49
+ // [LAW:parse-dont-validate] Parsed whether or not it auto-refreshes, so a
50
+ // rate that could never tick is refused where it is given.
51
+ this._rate = frameRate(options?.refreshPerSecond ?? 4);
52
+ this._clock = options?.clock ?? systemClock();
32
53
  this._autoRefresh = options?.autoRefresh !== false;
33
54
  this._transient = options?.transient ?? false;
34
55
  this._verticalOverflow = options?.verticalOverflow ?? "ellipsis";
35
- this._altScreen = options?.altScreen ?? false;
36
- this._lastLineCount = 0;
37
- this._started = false;
38
- this._firstRefresh = true;
56
+ // [LAW:dataflow-not-control-flow] What the console is decides which
57
+ // painter Live holds; every frame then takes the same path through it.
58
+ // The final frame is a print, as in Rich, so a recording or a capture
59
+ // holds it with everything else the console wrote.
60
+ this._painter = this._console.isInteractive
61
+ ? new SurfacePainter(options?.altScreen ? "alternate" : "inline", (bytes) => void this._console.file.write(bytes))
62
+ : new FinalFramePainter((frame) => this._console.print(linesOf(frame)));
63
+ this._held = null;
39
64
  }
65
+ /**
66
+ * The console to print through while this Live runs: what it prints lands
67
+ * above an inline frame. The alternate screen is all frame, and paints over it.
68
+ */
40
69
  get console() {
41
70
  return this._console;
42
71
  }
@@ -44,48 +73,45 @@ export class Live {
44
73
  return this._renderable;
45
74
  }
46
75
  start() {
47
- if (this._started)
76
+ if (this._held !== null)
48
77
  return;
49
- this._started = true;
50
- this._firstRefresh = true;
51
- const stream = this._console.file;
52
- if (this._altScreen) {
53
- stream.write("\x1b[?1049h"); // enter alt screen
54
- }
55
- this._writeCursorControl(false);
78
+ // Claimed before a byte is written, so a console another Live is running
79
+ // on refuses this one with the terminal untouched.
80
+ const held = this._console.claimLiveRegion({ around: (text) => this._stepAround(text) });
81
+ this._held = held;
82
+ this._painter.take();
56
83
  if (this._autoRefresh) {
57
- const interval = Math.floor(1000 / this._refreshPerSecond);
58
84
  // No caller is on the stack to run `stop()` for a frame that throws
59
85
  // here, and in Node the throw ends the process — so the terminal is
60
86
  // handed back first, and the error then goes on to wherever an uncaught
61
87
  // one goes.
62
- this._timer = setInterval(() => {
88
+ this._stopTicking = this._clock.every(this._rate, () => {
63
89
  try {
64
90
  this.refresh();
65
91
  }
66
92
  catch (error) {
67
- this._release();
93
+ this._release(held);
68
94
  throw error;
69
95
  }
70
- }, interval);
96
+ });
71
97
  }
72
98
  }
73
99
  stop() {
74
- if (!this._started)
100
+ const held = this._held;
101
+ if (held === null)
75
102
  return;
76
103
  // [LAW:no-silent-failure] A final frame that throws still propagates, and
77
104
  // the terminal is handed back on the way out.
78
105
  try {
79
106
  if (this._transient) {
80
- this._console.file.write(this._erase());
81
- this._lastLineCount = 0;
107
+ this._paint(() => []);
82
108
  }
83
109
  else {
84
110
  this.refresh();
85
111
  }
86
112
  }
87
113
  finally {
88
- this._release();
114
+ this._release(held);
89
115
  }
90
116
  }
91
117
  update(renderable, options) {
@@ -100,47 +126,39 @@ export class Live {
100
126
  // A frame is drawn only while this Live holds the terminal, as in Rich:
101
127
  // before `start()` or after the hand-back it would land on a screen that
102
128
  // belongs to someone else — the main buffer, under the user's own output.
103
- if (!this._started || !this._renderable)
129
+ if (this._held === null)
104
130
  return;
131
+ this._paint(() => this._frame());
132
+ }
133
+ _frame() {
134
+ if (!this._renderable)
135
+ return [];
105
136
  // [LAW:dataflow-not-control-flow] Both modes render under the terminal's
106
137
  // rows and differ only in what those rows are: the alternate screen is a
107
138
  // region the frame stands in, and an inline frame keeps its natural height
108
139
  // under them as a ceiling. Live set the budget, so Live shapes what comes
109
140
  // back — its overflow policy, then `fitHeight`, which pads only a region.
110
141
  const options = this._console.options;
111
- const height = { rows: options.height.rows, exact: this._altScreen };
142
+ const height = this._painter.height(options.height.rows);
112
143
  const lines = Segment.splitLines(this._renderable.render({ ...options, height }));
113
- const displayLines = fitHeight(this._overflow(lines, height.rows), height);
114
- // [LAW:single-enforcer] Per-line encoding routes through the same
115
- // tree-coalescer `Console._writeSegments` uses, so Live frames coalesce
116
- // adjacent same-style segments into shared SGR pairs on the wire and
117
- // encode for the console's own destination.
118
- // The alternate screen is not erased between frames — the cursor only goes
119
- // home — so each of its rows is erased before it is drawn, or a shorter
120
- // frame leaves the last one's rows and line tails showing. The erase comes
121
- // first because the cursor is then at the row's start; after a row that
122
- // fills the width it would take the last cell. An inline frame's rows are
123
- // erased by `_erase`.
124
- const lead = this._altScreen ? "\x1b[2K" : "";
125
- const destination = this._console.destination;
126
- const output = displayLines
127
- .map((line) => lead + segmentsToString(line, destination))
128
- .join("\n");
129
- // [LAW:effects-at-boundaries] The frame is fully rendered before a byte
130
- // reaches the terminal, and whatever takes the last frame away goes out in
131
- // the same write as the new one — so a render that throws leaves the last
132
- // good frame showing. The first alternate-screen frame clears the buffer
133
- // it walked into; after that the cursor only goes home.
134
- const replace = this._altScreen ? (this._firstRefresh ? "\x1b[2J\x1b[H" : "\x1b[H") : this._erase();
135
- // Inline, the newline leaves the cursor under the frame, where `_erase`
136
- // counts up from. On the alternate screen the next frame starts from home,
137
- // and a newline after a full-height frame's last row scrolls its first row
138
- // off the top.
139
- this._console.file.write(replace + (this._altScreen ? output : output + "\n"));
140
- this._firstRefresh = false;
141
- // What `_erase` erases: an inline frame's rows. An alternate-screen frame
142
- // is erased by leaving the buffer.
143
- this._lastLineCount = this._altScreen ? 0 : displayLines.length;
144
+ return fitHeight(this._overflow(lines, height.rows), height);
145
+ }
146
+ // What the console writes goes where the frame was, and the frame is painted
147
+ // again under it, as Rich's render hook does — in the bytes the console
148
+ // writes, so a render that throws writes nothing.
149
+ _stepAround(text) {
150
+ return this._painter.around(text, () => this._frame(), this._screen(), this._console.destination);
151
+ }
152
+ // [LAW:effects-at-boundaries] The frame is fully rendered before a byte
153
+ // reaches the terminal, and whatever takes the last frame away goes out in
154
+ // the same write as the new one — so a render that throws leaves the last
155
+ // good frame showing.
156
+ _paint(frame) {
157
+ this._painter.paint(frame, this._screen(), this._console.destination);
158
+ }
159
+ _screen() {
160
+ const { height, maxWidth } = this._console.options;
161
+ return { rows: height.rows, cols: maxWidth };
144
162
  }
145
163
  // Lines past `rows`: dropped, with the last kept row replaced by an ellipsis,
146
164
  // or left for the terminal to scroll.
@@ -153,23 +171,18 @@ export class Live {
153
171
  }
154
172
  return kept;
155
173
  }
156
- // Cursor-up and erase-line for each row of the last inline frame.
157
- _erase() {
158
- return "\x1b[1A\x1b[2K".repeat(this._lastLineCount);
159
- }
160
174
  // [LAW:single-enforcer] The one way the terminal is handed back — the timer
161
175
  // stopped, the cursor shown, the alternate screen left — whether `stop()`
162
176
  // ran or a frame threw with no caller to run it.
163
- _release() {
164
- this._started = false;
165
- clearInterval(this._timer);
166
- this._timer = undefined;
167
- this._writeCursorControl(true);
168
- if (this._altScreen) {
169
- this._console.file.write("\x1b[0m\x1b[?1049l"); // reset attrs + exit alt screen
170
- }
171
- }
172
- _writeCursorControl(show) {
173
- this._console.file.write(show ? "\x1b[?25h" : "\x1b[?25l");
177
+ _release(held) {
178
+ held();
179
+ this._held = null;
180
+ this._stopTicking?.();
181
+ this._stopTicking = undefined;
182
+ this._painter.handBack();
174
183
  }
175
184
  }
185
+ // A drawn frame as a renderable, each row a line, for the console to print.
186
+ function linesOf(frame) {
187
+ return { render: () => frame.flatMap((row) => [...row, Segment.line()]) };
188
+ }
@@ -1,20 +1,33 @@
1
1
  /**
2
2
  * Markdown — renders Markdown content to the terminal.
3
3
  * Uses built-in parsing (no external dependency).
4
+ *
5
+ * Block structure is read as CommonMark reads it: a blockquote and a list
6
+ * item are both containers, their lines tokenized as Markdown of their own
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.
4
10
  */
5
11
  import { Segment } from "../core/segment.js";
6
12
  import { Style } from "../core/style.js";
7
13
  import type { Renderable, Measurable, RenderOptions } from "../core/protocol.js";
8
14
  export interface MarkdownOptions {
9
- codeTheme?: string;
15
+ /** The style of `inline code`: a theme name or a style definition. Default `markdown.code`. */
10
16
  inlineCodeStyle?: string | Style;
17
+ /**
18
+ * Whether a link's text is the link. `false` writes the URL after the text
19
+ * in parentheses, for a reader who cannot click it. Default `true`.
20
+ */
11
21
  hyperlinks?: boolean;
12
- justify?: "left" | "center" | "right" | "full";
22
+ /** How body text — paragraphs, list items, quotes — is placed in its width. Default `"left"`; headings keep their own. */
23
+ justify?: Justify;
13
24
  }
25
+ type Justify = NonNullable<RenderOptions["justify"]>;
14
26
  export declare class Markdown implements Renderable, Measurable {
15
27
  readonly markdown: string;
16
28
  readonly inlineCodeStyle: string | Style;
17
29
  readonly hyperlinks: boolean;
30
+ readonly justify: Justify;
18
31
  constructor(markdown: string, options?: MarkdownOptions);
19
32
  render(rawOptions: RenderOptions): Iterable<Segment>;
20
33
  measure(options: RenderOptions): {
@@ -22,3 +35,4 @@ export declare class Markdown implements Renderable, Measurable {
22
35
  maximum: number;
23
36
  };
24
37
  }
38
+ export {};