@promptctl/rich-js 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +9 -7
  2. package/dist/core/box.d.ts +3 -1
  3. package/dist/core/box.js +6 -13
  4. package/dist/core/cells.d.ts +61 -28
  5. package/dist/core/cells.js +171 -124
  6. package/dist/core/clock.d.ts +48 -0
  7. package/dist/core/clock.js +42 -0
  8. package/dist/core/color.d.ts +98 -18
  9. package/dist/core/color.js +331 -144
  10. package/dist/core/console.d.ts +38 -7
  11. package/dist/core/console.js +159 -90
  12. package/dist/core/easing.d.ts +86 -0
  13. package/dist/core/easing.js +171 -0
  14. package/dist/core/env.d.ts +16 -0
  15. package/dist/core/env.js +16 -0
  16. package/dist/core/export-html.js +1 -4
  17. package/dist/core/export-lines.d.ts +22 -13
  18. package/dist/core/export-lines.js +37 -19
  19. package/dist/core/export-svg.d.ts +49 -0
  20. package/dist/core/export-svg.js +213 -0
  21. package/dist/core/fnv1a.d.ts +10 -0
  22. package/dist/core/fnv1a.js +17 -0
  23. package/dist/core/highlighter.d.ts +2 -2
  24. package/dist/core/highlighter.js +113 -47
  25. package/dist/core/json.js +1 -1
  26. package/dist/core/markup.d.ts +28 -7
  27. package/dist/core/markup.js +235 -312
  28. package/dist/core/measure.d.ts +3 -3
  29. package/dist/core/measure.js +12 -7
  30. package/dist/core/memo.d.ts +19 -0
  31. package/dist/core/memo.js +34 -0
  32. package/dist/core/osc8.d.ts +6 -6
  33. package/dist/core/osc8.js +9 -21
  34. package/dist/core/paint.d.ts +107 -0
  35. package/dist/core/paint.js +150 -0
  36. package/dist/core/place.d.ts +1 -1
  37. package/dist/core/place.js +4 -6
  38. package/dist/core/pretty.d.ts +105 -2
  39. package/dist/core/pretty.js +527 -97
  40. package/dist/core/protocol.d.ts +41 -3
  41. package/dist/core/protocol.js +20 -0
  42. package/dist/core/render.d.ts +33 -30
  43. package/dist/core/render.js +43 -83
  44. package/dist/core/segment.d.ts +6 -5
  45. package/dist/core/segment.js +11 -15
  46. package/dist/core/strip.d.ts +13 -2
  47. package/dist/core/strip.js +44 -23
  48. package/dist/core/style.d.ts +27 -7
  49. package/dist/core/style.js +97 -34
  50. package/dist/core/text.d.ts +43 -16
  51. package/dist/core/text.js +180 -59
  52. package/dist/core/wrap.d.ts +5 -0
  53. package/dist/core/wrap.js +16 -3
  54. package/dist/host/app.d.ts +31 -19
  55. package/dist/host/app.js +90 -60
  56. package/dist/host/index.d.ts +2 -1
  57. package/dist/host/terminal-host.d.ts +3 -2
  58. package/dist/index.d.ts +17 -10
  59. package/dist/index.js +11 -4
  60. package/dist/node/prompt.d.ts +13 -0
  61. package/dist/node/prompt.js +30 -9
  62. package/dist/node/save.d.ts +4 -3
  63. package/dist/node/save.js +6 -3
  64. package/dist/node/terminal-host.d.ts +3 -2
  65. package/dist/node/traceback.d.ts +2 -1
  66. package/dist/node/traceback.js +3 -39
  67. package/dist/renderables/align.js +2 -9
  68. package/dist/renderables/columns.d.ts +45 -29
  69. package/dist/renderables/columns.js +142 -103
  70. package/dist/renderables/constrain.js +6 -12
  71. package/dist/renderables/effect.d.ts +98 -0
  72. package/dist/renderables/effect.js +160 -0
  73. package/dist/renderables/embed.d.ts +87 -30
  74. package/dist/renderables/embed.js +134 -45
  75. package/dist/renderables/group.d.ts +6 -3
  76. package/dist/renderables/group.js +5 -0
  77. package/dist/renderables/layout.d.ts +6 -23
  78. package/dist/renderables/layout.js +24 -113
  79. package/dist/renderables/live.d.ts +40 -16
  80. package/dist/renderables/live.js +96 -83
  81. package/dist/renderables/markdown.d.ts +16 -2
  82. package/dist/renderables/markdown.js +521 -118
  83. package/dist/renderables/padding.d.ts +22 -1
  84. package/dist/renderables/padding.js +30 -24
  85. package/dist/renderables/panel.d.ts +31 -33
  86. package/dist/renderables/panel.js +137 -141
  87. package/dist/renderables/progress.d.ts +104 -16
  88. package/dist/renderables/progress.js +205 -81
  89. package/dist/renderables/progressBar.d.ts +2 -2
  90. package/dist/renderables/progressBar.js +30 -19
  91. package/dist/renderables/prompt.d.ts +37 -15
  92. package/dist/renderables/prompt.js +125 -85
  93. package/dist/renderables/ratio.d.ts +123 -0
  94. package/dist/renderables/ratio.js +213 -0
  95. package/dist/renderables/rule.d.ts +1 -0
  96. package/dist/renderables/rule.js +44 -48
  97. package/dist/renderables/spinner.d.ts +28 -2
  98. package/dist/renderables/spinner.js +52 -19
  99. package/dist/renderables/status.d.ts +13 -7
  100. package/dist/renderables/status.js +15 -29
  101. package/dist/renderables/syntax.d.ts +37 -7
  102. package/dist/renderables/syntax.js +134 -83
  103. package/dist/renderables/table.d.ts +58 -20
  104. package/dist/renderables/table.js +370 -192
  105. package/dist/renderables/traceback.d.ts +5 -4
  106. package/dist/renderables/traceback.js +239 -68
  107. package/dist/renderables/tree.d.ts +10 -2
  108. package/dist/renderables/tree.js +61 -34
  109. package/dist/renderables/viewport.js +2 -4
  110. package/dist/template-bindings/color-funcs.d.ts +31 -9
  111. package/dist/template-bindings/color-funcs.js +40 -14
  112. package/dist/template-bindings/helpers.d.ts +4 -2
  113. package/dist/template-bindings/helpers.js +5 -20
  114. package/dist/template-bindings/index.d.ts +29 -17
  115. package/dist/template-bindings/index.js +36 -20
  116. package/dist/template-bindings/palette-funcs.d.ts +3 -1
  117. package/dist/template-bindings/palette-funcs.js +11 -7
  118. package/dist/template-bindings/style-funcs.js +4 -2
  119. package/dist/themes/buildPalette.d.ts +24 -3
  120. package/dist/themes/buildPalette.js +52 -8
  121. package/dist/themes/colorMath.d.ts +29 -17
  122. package/dist/themes/colorMath.js +74 -48
  123. package/dist/themes/colorRef.d.ts +2 -17
  124. package/dist/themes/colorRef.js +13 -31
  125. package/dist/themes/palette.d.ts +24 -0
  126. package/dist/themes/palette.js +46 -3
  127. package/dist/themes/ramp.d.ts +21 -34
  128. package/dist/themes/ramp.js +23 -42
  129. package/dist/themes/registry.js +39 -28
  130. package/dist/widgets/button.d.ts +2 -6
  131. package/dist/widgets/button.js +9 -40
  132. package/dist/widgets/checkbox.d.ts +2 -5
  133. package/dist/widgets/checkbox.js +9 -31
  134. package/dist/widgets/dropdown.d.ts +2 -5
  135. package/dist/widgets/dropdown.js +14 -48
  136. package/dist/widgets/ink.d.ts +22 -0
  137. package/dist/widgets/ink.js +31 -0
  138. package/dist/widgets/slider.d.ts +2 -5
  139. package/dist/widgets/slider.js +11 -34
  140. package/dist/widgets/text-input.d.ts +19 -10
  141. package/dist/widgets/text-input.js +156 -147
  142. package/dist/widgets/themed-widget.d.ts +20 -0
  143. package/dist/widgets/themed-widget.js +89 -0
  144. package/dist/widgets/toggle.d.ts +2 -5
  145. package/dist/widgets/toggle.js +8 -31
  146. package/dist/widgets/widget-app.d.ts +2 -0
  147. package/dist/widgets/widget-app.js +13 -6
  148. package/dist/widgets/widget-base.d.ts +2 -0
  149. package/dist/widgets/widget-base.js +9 -2
  150. package/package.json +11 -1
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * Progress — displays continuously updated progress bars.
3
3
  */
4
- import { Segment } from "../core/segment.js";
4
+ import type { Segment } from "../core/segment.js";
5
+ import { RichText } from "../core/text.js";
5
6
  import { Console } from "../core/console.js";
7
+ import type { Style } from "../core/style.js";
8
+ import { ProgressBar } from "./progressBar.js";
9
+ import { type Clock } from "../core/clock.js";
10
+ import { type ColumnOptions } from "./table.js";
6
11
  import type { Renderable, RenderOptions } from "../core/protocol.js";
7
12
  export interface TaskOptions {
8
13
  total?: number;
@@ -16,45 +21,122 @@ export interface TaskUpdateOptions {
16
21
  visible?: boolean;
17
22
  refresh?: boolean;
18
23
  }
19
- interface Task {
24
+ export interface Task {
20
25
  id: number;
21
26
  description: string;
22
27
  total: number | undefined;
23
28
  completed: number;
24
- started: boolean;
25
29
  visible: boolean;
26
- startTime: number;
27
- elapsed: number;
30
+ /** When the task started, in seconds on its `Progress`'s clock; undefined until it has. */
31
+ startTime: number | undefined;
32
+ /**
33
+ * Rich's `Task.elapsed`: seconds since the task started, as of the frame
34
+ * being drawn — `Progress` reads its clock once a frame and hands columns
35
+ * the result, so no column reads a clock. Undefined until the task starts.
36
+ */
37
+ elapsed: number | undefined;
38
+ /**
39
+ * Rich's `Task.finished_time`: the elapsed time the first time an update
40
+ * finds a started task at its total, kept from then on, so a finished task
41
+ * stays finished however its count moves after.
42
+ */
43
+ finishedTime: number | undefined;
44
+ /**
45
+ * Rich's `Task.speed`: steps a second over the updates that moved the task
46
+ * in the `SPEED_ESTIMATE_PERIOD` before its latest update — measured back from
47
+ * that update, not from now, so a task that stops being updated keeps the
48
+ * speed it had. Undefined until the task has started and two such updates lie
49
+ * apart in time.
50
+ */
51
+ speed: number | undefined;
28
52
  }
29
- export interface ProgressColumn extends Renderable {
30
- render(options: RenderOptions, task?: Task): Iterable<Segment>;
53
+ /**
54
+ * One cell of a `Progress` row, made for the task on that row. Not a
55
+ * `Renderable`: a column has nothing to draw without a task, as the
56
+ * reference's `ProgressColumn` is a callable of one. What it returns is the
57
+ * cell's content, which the progress grid lays out like any table cell's —
58
+ * width, wrapping and justify are decided once, in the cell, as Rich's
59
+ * `make_tasks_table` decides them. [LAW:single-enforcer]
60
+ */
61
+ export interface ProgressColumn {
62
+ /**
63
+ * The grid column this one is laid out in, Rich's `get_table_column()`:
64
+ * whether its cell may wrap, how it justifies, its share of the width.
65
+ */
66
+ readonly tableColumn: ColumnOptions;
67
+ render(task: Task): Renderable;
31
68
  }
32
69
  export declare class TextColumn implements ProgressColumn {
33
70
  readonly format: string;
71
+ /** Text is cut, never wrapped, as Rich's `TextColumn` defaults to `Column(no_wrap=True)`. */
72
+ readonly tableColumn: ColumnOptions;
34
73
  constructor(format?: string);
35
- render(options: RenderOptions, task?: Task): Iterable<Segment>;
74
+ render(task: Task): RichText;
36
75
  }
37
76
  export declare class BarColumn implements ProgressColumn {
38
77
  readonly barWidth: number;
78
+ readonly tableColumn: ColumnOptions;
39
79
  constructor(barWidth?: number);
40
- render(_options: RenderOptions, task?: Task): Iterable<Segment>;
80
+ render(task: Task): ProgressBar;
41
81
  }
82
+ /**
83
+ * Rich's `{task.percentage:>3.0f}%`: the percentage right-aligned in three
84
+ * cells, so the column is four wide from 0% to 100% and the row does not
85
+ * shift as it counts. A task with no total shows nothing, Rich's
86
+ * `text_format_no_percentage`.
87
+ */
42
88
  export declare class TaskProgressColumn implements ProgressColumn {
43
- render(options: RenderOptions, task?: Task): Iterable<Segment>;
89
+ /** A `TextColumn` in Rich, and laid out as one. */
90
+ readonly tableColumn: ColumnOptions;
91
+ render(task: Task): RichText;
92
+ }
93
+ export interface TimeRemainingColumnOptions {
94
+ /**
95
+ * Once the task is finished, draw the time it took, styled
96
+ * `progress.elapsed`, in place of the zero left to go: Rich's
97
+ * `elapsed_when_finished`. Defaults to false.
98
+ */
99
+ elapsedWhenFinished?: boolean;
44
100
  }
45
101
  export declare class TimeRemainingColumn implements ProgressColumn {
46
- render(options: RenderOptions, task?: Task): Iterable<Segment>;
102
+ readonly tableColumn: ColumnOptions;
103
+ readonly elapsedWhenFinished: boolean;
104
+ constructor(options?: TimeRemainingColumnOptions);
105
+ render(task: Task): RichText;
47
106
  }
48
107
  export declare class TimeElapsedColumn implements ProgressColumn {
49
- render(options: RenderOptions, task?: Task): Iterable<Segment>;
108
+ readonly tableColumn: ColumnOptions;
109
+ render(task: Task): RichText;
110
+ }
111
+ export interface SpinnerColumnOptions {
112
+ /** The frame's style. Defaults to `progress.spinner`, a name its console's theme resolves. */
113
+ style?: string | Style;
114
+ /** Multiplies the spinner's frame rate. Defaults to 1. */
115
+ speed?: number;
116
+ /** Drawn in place of the frame once the task reaches its total; a string is markup. Defaults to `" "`. */
117
+ finishedText?: string | RichText;
50
118
  }
51
119
  export declare class SpinnerColumn implements ProgressColumn {
120
+ readonly tableColumn: ColumnOptions;
52
121
  private _spinner;
53
- constructor(spinnerName?: string);
54
- render(options: RenderOptions, _task?: Task): Iterable<Segment>;
122
+ private _finishedText;
123
+ constructor(spinnerName?: string, options?: SpinnerColumnOptions);
124
+ render(task: Task): Renderable;
125
+ }
126
+ export interface MofNCompleteColumnOptions {
127
+ /** Drawn between the two counts: Rich's `separator`. Defaults to `"/"`. */
128
+ separator?: string;
55
129
  }
130
+ /**
131
+ * Rich's `MofNCompleteColumn`: both counts as whole numbers, the completed one
132
+ * padded to the total's width so the column holds still as it counts up to its
133
+ * total, styled `progress.download`.
134
+ */
56
135
  export declare class MofNCompleteColumn implements ProgressColumn {
57
- render(_options: RenderOptions, task?: Task): Iterable<Segment>;
136
+ readonly tableColumn: ColumnOptions;
137
+ readonly separator: string;
138
+ constructor(options?: MofNCompleteColumnOptions);
139
+ render(task: Task): RichText;
58
140
  }
59
141
  export interface ProgressOptions {
60
142
  refreshPerSecond?: number;
@@ -62,6 +144,11 @@ export interface ProgressOptions {
62
144
  transient?: boolean;
63
145
  expand?: boolean;
64
146
  console?: Console;
147
+ /**
148
+ * What tasks are timed by and frames are drawn on, Rich's `get_time`;
149
+ * defaults to the platform's monotonic clock. Handed on to the `Live`.
150
+ */
151
+ clock?: Clock;
65
152
  }
66
153
  export declare class Progress implements Renderable {
67
154
  private _columns;
@@ -69,9 +156,11 @@ export declare class Progress implements Renderable {
69
156
  private _nextId;
70
157
  private _live;
71
158
  private _console;
159
+ private readonly _clock;
72
160
  readonly expand: boolean;
73
161
  constructor(...columns: (ProgressColumn | ProgressOptions)[]);
74
162
  get console(): Console;
163
+ /** Rich's `Progress.finished`: every task finished, and so true with none. */
75
164
  get finished(): boolean;
76
165
  static getDefaultColumns(): ProgressColumn[];
77
166
  addTask(description: string, options?: TaskOptions): number;
@@ -87,4 +176,3 @@ export declare function track<T>(iterable: Iterable<T>, options?: {
87
176
  total?: number;
88
177
  console?: Console;
89
178
  }): Iterable<T>;
90
- export {};
@@ -1,100 +1,215 @@
1
1
  /**
2
2
  * Progress — displays continuously updated progress bars.
3
3
  */
4
- import { Segment } from "../core/segment.js";
5
4
  import { RichText } from "../core/text.js";
6
5
  import { Console } from "../core/console.js";
7
- import { escape as escapeMarkup } from "../core/markup.js";
8
- import { embeddedText } from "./embed.js";
6
+ import { escape as escapeMarkup, readStr, renderStr } from "../core/markup.js";
9
7
  import { ProgressBar } from "./progressBar.js";
10
8
  import { Spinner } from "./spinner.js";
11
9
  import { Live } from "./live.js";
10
+ import { systemClock } from "../core/clock.js";
12
11
  import { Table } from "./table.js";
13
- import { getStyle } from "../core/protocol.js";
12
+ /**
13
+ * Rich's defaults for `speed_estimate_period` and its sample deque's
14
+ * `maxlen`: an update forgets samples older than the period, and the newest
15
+ * thousand are all a task keeps.
16
+ */
17
+ const SPEED_ESTIMATE_PERIOD = 30;
18
+ const MAX_SAMPLES = 1000;
19
+ /**
20
+ * Rich's `Progress.update` bookkeeping on its sample deque, in place: drop the
21
+ * samples older than the period as of `now`, then add this update's if it
22
+ * moved the task forward, keeping the newest `MAX_SAMPLES`. The samples are in
23
+ * time order, so the stale ones are a prefix.
24
+ */
25
+ function recordSample(samples, now, moved) {
26
+ const firstFresh = samples.findIndex((sample) => sample.timestamp >= now - SPEED_ESTIMATE_PERIOD);
27
+ samples.splice(0, firstFresh === -1 ? samples.length : firstFresh);
28
+ if (moved > 0)
29
+ samples.push({ timestamp: now, completed: moved });
30
+ samples.splice(0, samples.length - MAX_SAMPLES);
31
+ }
32
+ /**
33
+ * Rich's `Task.speed` over a started task's samples: everything moved after
34
+ * the first sample, over the time from the first to the last. No samples, or
35
+ * none apart in time, is no speed.
36
+ */
37
+ function sampleSpeed(samples) {
38
+ const span = (samples.at(-1)?.timestamp ?? 0) - (samples[0]?.timestamp ?? 0);
39
+ if (span === 0)
40
+ return undefined;
41
+ return samples.slice(1).reduce((sum, sample) => sum + sample.completed, 0) / span;
42
+ }
43
+ /**
44
+ * A count as a task takes it in. [LAW:single-enforcer] `addTask` and
45
+ * `updateTask` are where counts enter, so a NaN or an Infinity is refused there,
46
+ * with the caller's stack, rather than drawn as `NaN%` by one column and thrown
47
+ * on a refresh tick by another.
48
+ */
49
+ function finiteCount(field, value) {
50
+ if (!Number.isFinite(value))
51
+ throw new RangeError(`progress task ${field} must be a finite number, got ${value}`);
52
+ return value;
53
+ }
54
+ /** Rich's `Task.finished`. [LAW:one-source-of-truth] The one predicate every reader of "finished" asks. */
55
+ function finished(task) {
56
+ return task.finishedTime !== undefined;
57
+ }
14
58
  export class TextColumn {
15
59
  format;
60
+ /** Text is cut, never wrapped, as Rich's `TextColumn` defaults to `Column(no_wrap=True)`. */
61
+ tableColumn = { noWrap: true };
16
62
  constructor(format) {
17
63
  this.format = format ?? "{task.description}";
18
64
  }
19
- *render(options, task) {
65
+ render(task) {
20
66
  // [LAW:single-enforcer] Format strings flow through the markup parser so
21
67
  // tags like `[progress.description]` become Style spans, not literal text.
22
68
  // Task descriptions are escaped first to prevent injection of stray tags.
23
- const description = escapeMarkup(task?.description ?? "");
69
+ const description = escapeMarkup(task.description);
24
70
  // Use a callback so `$&`/`$1`/`$$` in the task description aren't
25
71
  // reinterpreted by String.replace as replacement patterns.
26
72
  const formatted = this.format.replace(/\{task\.description\}/g, () => description);
27
- // [LAW:single-enforcer] A column is a row fragment, the same as any
28
- // embedded text.
29
- yield* embeddedText(formatted).render(options);
73
+ // Markup whatever the console says, and never highlighted: Rich's
74
+ // `TextColumn` reads its format as markup on its own `markup=True` and
75
+ // highlights only with a highlighter it was given, not the console's.
76
+ return renderStr(formatted, { markup: true });
30
77
  }
31
78
  }
32
79
  export class BarColumn {
33
80
  barWidth;
81
+ tableColumn = {};
34
82
  constructor(barWidth) {
35
83
  this.barWidth = barWidth ?? 40;
36
84
  }
37
- *render(_options, task) {
38
- const bar = new ProgressBar({
39
- total: task?.total ?? 100,
40
- completed: task?.completed ?? 0,
85
+ render(task) {
86
+ return new ProgressBar({
87
+ total: task.total ?? 100,
88
+ completed: task.completed,
41
89
  width: this.barWidth,
42
90
  });
43
- yield* bar.render(_options);
44
91
  }
45
92
  }
93
+ /**
94
+ * Rich's `{task.percentage:>3.0f}%`: the percentage right-aligned in three
95
+ * cells, so the column is four wide from 0% to 100% and the row does not
96
+ * shift as it counts. A task with no total shows nothing, Rich's
97
+ * `text_format_no_percentage`.
98
+ */
46
99
  export class TaskProgressColumn {
47
- *render(options, task) {
48
- const percent = task && task.total
49
- ? Math.min(100, Math.round((task.completed / task.total) * 100))
50
- : 0;
51
- yield new Segment(`${percent}%`, getStyle(options, "progress.percentage"));
100
+ /** A `TextColumn` in Rich, and laid out as one. */
101
+ tableColumn = { noWrap: true };
102
+ render(task) {
103
+ const text = task.total === undefined ? "" : `${String(roundHalfEven(percentage(task))).padStart(3)}%`;
104
+ return new RichText(text, { style: "progress.percentage" });
52
105
  }
53
106
  }
107
+ /** Rich's `Task.percentage`: 0 without a total, and clamped to [0, 100]. */
108
+ function percentage(task) {
109
+ return task.total ? Math.min(100, Math.max(0, (task.completed / task.total) * 100)) : 0;
110
+ }
111
+ /**
112
+ * Python's `format(x, ".0f")`: the nearest integer to the double's exact
113
+ * value, a tie going to the even one. `Math.round` is exact too but sends a
114
+ * tie up, so only a tie is corrected.
115
+ */
116
+ function roundHalfEven(x) {
117
+ const up = Math.round(x);
118
+ return up - x === 0.5 && up % 2 !== 0 ? up - 1 : up;
119
+ }
54
120
  export class TimeRemainingColumn {
55
- *render(options, task) {
56
- const style = getStyle(options, "progress.remaining");
57
- if (!task || !task.total || !task.started || task.completed <= 0) {
58
- yield new Segment("-:--:--", style);
59
- return;
60
- }
61
- const elapsed = (Date.now() - task.startTime) / 1000;
62
- const rate = task.completed / elapsed;
63
- const remaining = (task.total - task.completed) / rate;
64
- yield new Segment(formatTime(remaining), style);
121
+ tableColumn = {};
122
+ elapsedWhenFinished;
123
+ constructor(options) {
124
+ this.elapsedWhenFinished = options?.elapsedWhenFinished ?? false;
125
+ }
126
+ render(task) {
127
+ const took = this.elapsedWhenFinished && finished(task) ? task.finishedTime : undefined;
128
+ const time = took ?? timeRemaining(task);
129
+ const style = took === undefined ? "progress.remaining" : "progress.elapsed";
130
+ // A task with no total has no time to estimate, and Rich draws its cell empty.
131
+ return new RichText(task.total === undefined ? "" : clockFace(time), { style });
65
132
  }
66
133
  }
134
+ /**
135
+ * Rich's `Task.time_remaining`: nothing left once the task is finished,
136
+ * however long ago that was, unknown until it has a speed against a total,
137
+ * and otherwise whole seconds, rounded up.
138
+ */
139
+ function timeRemaining(task) {
140
+ if (finished(task))
141
+ return 0;
142
+ if (!task.speed || task.total === undefined)
143
+ return undefined;
144
+ return Math.ceil((task.total - task.completed) / task.speed);
145
+ }
67
146
  export class TimeElapsedColumn {
68
- *render(options, task) {
69
- const style = getStyle(options, "progress.elapsed");
70
- if (!task || !task.started) {
71
- yield new Segment("0:00:00", style);
72
- return;
73
- }
74
- const elapsed = (Date.now() - task.startTime) / 1000;
75
- yield new Segment(formatTime(elapsed), style);
147
+ tableColumn = {};
148
+ render(task) {
149
+ // Rich's `finished_time if finished else elapsed`: a finished task's clock
150
+ // holds where it stopped, and one never started has no time to show.
151
+ const time = finished(task) ? task.finishedTime : task.elapsed;
152
+ return new RichText(clockFace(time), { style: "progress.elapsed" });
76
153
  }
77
154
  }
155
+ /** A time as the time columns draw it, `-:--:--` when there is none to draw. */
156
+ function clockFace(seconds) {
157
+ return seconds === undefined ? "-:--:--" : formatTime(seconds);
158
+ }
78
159
  export class SpinnerColumn {
160
+ tableColumn = {};
79
161
  _spinner;
80
- constructor(spinnerName) {
81
- this._spinner = new Spinner(spinnerName);
162
+ _finishedText;
163
+ constructor(spinnerName, options) {
164
+ this._spinner = new Spinner(spinnerName, "", {
165
+ style: options?.style ?? "progress.spinner",
166
+ speed: options?.speed,
167
+ });
168
+ // Markup whatever the console says, as Rich's `Text.from_markup`. A
169
+ // `RichText` is copied, since the caller still holds it.
170
+ const finishedText = options?.finishedText ?? " ";
171
+ this._finishedText = typeof finishedText === "string" ? readStr(finishedText, true) : finishedText.copy();
82
172
  }
83
- *render(options, _task) {
84
- yield* this._spinner.render(options);
173
+ render(task) {
174
+ return finished(task) ? this._finishedText : this._spinner;
85
175
  }
86
176
  }
177
+ /**
178
+ * Rich's `MofNCompleteColumn`: both counts as whole numbers, the completed one
179
+ * padded to the total's width so the column holds still as it counts up to its
180
+ * total, styled `progress.download`.
181
+ */
87
182
  export class MofNCompleteColumn {
88
- *render(_options, task) {
89
- const completed = task?.completed ?? 0;
90
- const total = task?.total ?? "?";
91
- yield new Segment(`${completed}/${total}`);
183
+ tableColumn = {};
184
+ separator;
185
+ constructor(options) {
186
+ this.separator = options?.separator ?? "/";
187
+ }
188
+ render(task) {
189
+ const total = task.total === undefined ? "?" : wholeNumber(task.total);
190
+ const completed = wholeNumber(task.completed).padStart(total.length);
191
+ return new RichText(`${completed}${this.separator}${total}`, { style: "progress.download" });
92
192
  }
93
193
  }
194
+ /**
195
+ * Python's `str(int(x))`: truncated toward zero and written out in full,
196
+ * never in exponent form. `Progress` admits only finite counts, so `BigInt`
197
+ * has nothing here to refuse.
198
+ */
199
+ function wholeNumber(x) {
200
+ return BigInt(Math.trunc(x)).toString();
201
+ }
202
+ /**
203
+ * Rich's clock face: `int()` the seconds, then Python's floor `divmod` by 60
204
+ * twice, so a negative estimate — a task past its total — reads as Rich's
205
+ * does, `-3` as `-1:59:57`.
206
+ */
94
207
  function formatTime(seconds) {
95
- const h = Math.floor(seconds / 3600);
96
- const m = Math.floor((seconds % 3600) / 60);
97
- const s = Math.floor(seconds % 60);
208
+ const whole = Math.trunc(seconds);
209
+ const minutes = Math.floor(whole / 60);
210
+ const h = Math.floor(minutes / 60);
211
+ const m = minutes - h * 60;
212
+ const s = whole - minutes * 60;
98
213
  return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
99
214
  }
100
215
  export class Progress {
@@ -103,6 +218,7 @@ export class Progress {
103
218
  _nextId;
104
219
  _live;
105
220
  _console;
221
+ _clock;
106
222
  expand;
107
223
  constructor(...columns) {
108
224
  // Last arg might be options
@@ -122,10 +238,12 @@ export class Progress {
122
238
  this._columns = cols;
123
239
  this._tasks = new Map();
124
240
  this._nextId = 1;
125
- this._console = opts.console ?? new Console({ forceTerminal: true });
241
+ this._console = opts.console ?? new Console();
126
242
  this.expand = opts.expand ?? false;
243
+ this._clock = opts.clock ?? systemClock();
127
244
  this._live = new Live(this, {
128
245
  console: this._console,
246
+ clock: this._clock,
129
247
  refreshPerSecond: opts.refreshPerSecond ?? 10,
130
248
  autoRefresh: opts.autoRefresh,
131
249
  transient: opts.transient,
@@ -134,13 +252,9 @@ export class Progress {
134
252
  get console() {
135
253
  return this._console;
136
254
  }
255
+ /** Rich's `Progress.finished`: every task finished, and so true with none. */
137
256
  get finished() {
138
- for (const task of this._tasks.values()) {
139
- if (!task.started || (task.total !== undefined && task.completed < task.total)) {
140
- return false;
141
- }
142
- }
143
- return this._tasks.size > 0;
257
+ return [...this._tasks.values()].every(finished);
144
258
  }
145
259
  static getDefaultColumns() {
146
260
  return [
@@ -155,12 +269,12 @@ export class Progress {
155
269
  const task = {
156
270
  id,
157
271
  description,
158
- total: options?.total,
272
+ total: options?.total === undefined ? undefined : finiteCount("total", options.total),
159
273
  completed: 0,
160
- started: options?.start !== false,
161
274
  visible: options?.visible !== false,
162
- startTime: Date.now(),
163
- elapsed: 0,
275
+ startTime: options?.start === false ? undefined : this._clock.now(),
276
+ finishedTime: undefined,
277
+ samples: [],
164
278
  };
165
279
  this._tasks.set(id, task);
166
280
  return id;
@@ -169,24 +283,32 @@ export class Progress {
169
283
  const task = this._tasks.get(taskId);
170
284
  if (!task)
171
285
  return;
172
- if (options.completed !== undefined)
173
- task.completed = options.completed;
174
- if (options.advance !== undefined)
175
- task.completed += options.advance;
286
+ // Rich's order, `completed` then `advance`, checked whole before any field
287
+ // is written, so a refused update leaves the task as it was.
288
+ const advance = finiteCount("advance", options.advance ?? 0);
289
+ const completed = finiteCount("completed", (options.completed ?? task.completed) + advance);
290
+ const now = this._clock.now();
291
+ recordSample(task.samples, now, completed - task.completed);
292
+ task.completed = completed;
176
293
  if (options.description !== undefined)
177
294
  task.description = options.description;
178
295
  if (options.visible !== undefined)
179
296
  task.visible = options.visible;
297
+ if (!finished(task) &&
298
+ task.startTime !== undefined &&
299
+ task.total !== undefined &&
300
+ task.completed >= task.total) {
301
+ task.finishedTime = now - task.startTime;
302
+ }
180
303
  if (options.refresh) {
181
304
  this._live.update(this, { refresh: true });
182
305
  }
183
306
  }
184
307
  startTask(taskId) {
185
308
  const task = this._tasks.get(taskId);
186
- if (task) {
187
- task.started = true;
188
- task.startTime = Date.now();
189
- }
309
+ // Rich's `start_task`: a task already running keeps the time it started.
310
+ if (task)
311
+ task.startTime ??= this._clock.now();
190
312
  }
191
313
  start() {
192
314
  this._live.start();
@@ -198,23 +320,25 @@ export class Progress {
198
320
  this._live.refresh();
199
321
  }
200
322
  *render(options) {
323
+ // Rich's `make_tasks_table`: each column laid out as it says.
201
324
  const table = Table.grid({ expand: this.expand });
202
- for (const _col of this._columns) {
203
- table.addColumn();
325
+ for (const col of this._columns) {
326
+ table.addColumn(undefined, col.tableColumn);
204
327
  }
205
- for (const task of this._tasks.values()) {
206
- if (!task.visible)
328
+ // [LAW:effects-at-boundaries] One read of the clock a frame, handed to
329
+ // every column as the task's `elapsed`.
330
+ const now = this._clock.now();
331
+ for (const { samples, ...state } of this._tasks.values()) {
332
+ if (!state.visible)
207
333
  continue;
208
- const cells = this._columns.map((col) => {
209
- const segs = [...col.render(options, task)];
210
- // Build a RichText that preserves segment styles as spans
211
- const text = new RichText("", { end: "" });
212
- for (const seg of segs) {
213
- text.append(seg.text, seg.style);
214
- }
215
- return text;
216
- });
217
- table.addRow(...cells);
334
+ const { startTime } = state;
335
+ const started = startTime !== undefined;
336
+ const task = {
337
+ ...state,
338
+ elapsed: started ? now - startTime : undefined,
339
+ speed: started ? sampleSpeed(samples) : undefined,
340
+ };
341
+ table.addRow(...this._columns.map((col) => col.render(task)));
218
342
  }
219
343
  yield* table.render(options);
220
344
  }
@@ -22,9 +22,9 @@ export declare class ProgressBar implements Renderable, Measurable {
22
22
  readonly completeStyle: string | Style;
23
23
  readonly finishedStyle: string | Style;
24
24
  constructor(options?: ProgressBarOptions);
25
- get percentComplete(): number;
26
25
  render(options: RenderOptions): Iterable<Segment>;
27
- measure(_options: RenderOptions): {
26
+ /** A width given is the bar's exact width, as Rich measures it; one left open fills what it is offered, down to 4. */
27
+ measure(options: RenderOptions): {
28
28
  minimum: number;
29
29
  maximum: number;
30
30
  };
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * ProgressBar — a visual progress bar rendered with block characters.
3
3
  */
4
+ import { cellCount } from "../core/cells.js";
4
5
  import { Segment } from "../core/segment.js";
5
6
  import { NULL_STYLE } from "../core/style.js";
6
7
  import { drawable, getStyle } from "../core/protocol.js";
7
8
  const BAR = "━";
8
9
  const ASCII_BAR = "-";
10
+ const HALF_BAR_RIGHT = "╸";
11
+ const HALF_BAR_LEFT = "╺";
9
12
  export class ProgressBar {
10
13
  total;
11
14
  completed;
@@ -23,31 +26,39 @@ export class ProgressBar {
23
26
  this.completeStyle = options?.completeStyle ?? "bar.complete";
24
27
  this.finishedStyle = options?.finishedStyle ?? "bar.finished";
25
28
  }
26
- get percentComplete() {
27
- if (this.total <= 0)
28
- return 0;
29
- return Math.min(1, Math.max(0, this.completed / this.total));
30
- }
31
29
  *render(options) {
32
- const barWidth = this.width ?? Math.min(40, options.maxWidth);
33
- const percent = this.percentComplete;
34
- const isFinished = percent >= 1;
35
- const filledWidth = Math.round(barWidth * percent);
36
- const emptyWidth = barWidth - filledWidth;
30
+ // Never wider than offered, as Rich's `min(self.width or max_width, max_width)`.
31
+ // [LAW:parse-dont-validate] parsed as a cell count, so a negative width draws nothing, as Python's `"━" * -2` is "".
32
+ const width = cellCount(Math.min(this.width || options.maxWidth, options.maxWidth));
33
+ // Rich 9d8f9a3 `__rich_console__`: the fill is counted in half cells, and
34
+ // a total of zero is a bar already full.
35
+ const completed = Math.min(this.total, Math.max(0, this.completed));
36
+ const completeHalves = this.total ? Math.trunc((width * 2 * completed) / this.total) : width * 2;
37
+ const barCount = Math.floor(completeHalves / 2);
38
+ const halfBarCount = completeHalves % 2;
39
+ const isFinished = this.completed >= this.total;
37
40
  const fill = getStyle(options, isFinished ? this.finishedStyle : this.completeStyle);
38
41
  const back = getStyle(options, this.style);
39
42
  const fillStyle = fill.isNull ? undefined : fill;
40
- const bgStyle = back.isNull ? undefined : back;
43
+ const backStyle = back.isNull ? undefined : back;
41
44
  const bar = drawable(options, BAR, ASCII_BAR);
42
- if (filledWidth > 0) {
43
- yield new Segment(bar.repeat(filledWidth), fillStyle);
44
- }
45
- if (emptyWidth > 0) {
46
- yield new Segment(bar.repeat(emptyWidth), bgStyle);
45
+ if (barCount)
46
+ yield new Segment(bar.repeat(barCount), fillStyle);
47
+ if (halfBarCount)
48
+ yield new Segment(drawable(options, HALF_BAR_RIGHT, " "), fillStyle);
49
+ // The back is drawn in colour alone, so an output with none leaves it blank.
50
+ if (options.colorSystem === null)
51
+ return;
52
+ let remaining = width - barCount - halfBarCount;
53
+ if (remaining && !halfBarCount && barCount) {
54
+ yield new Segment(drawable(options, HALF_BAR_LEFT, " "), backStyle);
55
+ remaining -= 1;
47
56
  }
57
+ if (remaining)
58
+ yield new Segment(bar.repeat(remaining), backStyle);
48
59
  }
49
- measure(_options) {
50
- const w = this.width ?? 40;
51
- return { minimum: 4, maximum: w };
60
+ /** A width given is the bar's exact width, as Rich measures it; one left open fills what it is offered, down to 4. */
61
+ measure(options) {
62
+ return this.width === undefined ? { minimum: 4, maximum: options.maxWidth } : { minimum: this.width, maximum: this.width };
52
63
  }
53
64
  }