@promptctl/rich-js 0.19.0 → 0.21.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 (58) hide show
  1. package/README.md +3 -2
  2. package/dist/core/anchor.d.ts +5 -0
  3. package/dist/core/anchor.js +10 -0
  4. package/dist/core/color.d.ts +1 -0
  5. package/dist/core/color.js +14 -3
  6. package/dist/core/noise.d.ts +31 -0
  7. package/dist/core/noise.js +94 -0
  8. package/dist/core/paint.js +13 -2
  9. package/dist/core/pretty.d.ts +23 -2
  10. package/dist/core/pretty.js +54 -18
  11. package/dist/core/protocol.d.ts +2 -0
  12. package/dist/core/protocol.js +3 -2
  13. package/dist/core/selection.d.ts +12 -0
  14. package/dist/core/selection.js +14 -0
  15. package/dist/core/strip.d.ts +159 -76
  16. package/dist/core/strip.js +367 -215
  17. package/dist/core/style.js +10 -7
  18. package/dist/host/app.d.ts +68 -2
  19. package/dist/host/app.js +125 -15
  20. package/dist/host/index.d.ts +3 -1
  21. package/dist/host/index.js +1 -0
  22. package/dist/host/input.d.ts +87 -0
  23. package/dist/host/input.js +517 -0
  24. package/dist/host/terminal-host.d.ts +12 -0
  25. package/dist/host/terminal-host.js +2 -0
  26. package/dist/index.d.ts +11 -6
  27. package/dist/index.js +7 -4
  28. package/dist/node/terminal-host.d.ts +7 -0
  29. package/dist/node/terminal-host.js +7 -0
  30. package/dist/renderables/effect.d.ts +89 -16
  31. package/dist/renderables/effect.js +115 -42
  32. package/dist/renderables/effects.d.ts +260 -0
  33. package/dist/renderables/effects.js +596 -0
  34. package/dist/renderables/embed.d.ts +11 -5
  35. package/dist/renderables/embed.js +13 -6
  36. package/dist/renderables/flexStrip.d.ts +1 -1
  37. package/dist/renderables/flexStrip.js +10 -9
  38. package/dist/renderables/list-view.d.ts +42 -0
  39. package/dist/renderables/list-view.js +78 -0
  40. package/dist/renderables/progress.d.ts +6 -0
  41. package/dist/renderables/progress.js +11 -4
  42. package/dist/renderables/progressBar.d.ts +22 -2
  43. package/dist/renderables/progressBar.js +52 -7
  44. package/dist/renderables/rule.d.ts +2 -2
  45. package/dist/renderables/rule.js +12 -10
  46. package/dist/renderables/table.js +5 -7
  47. package/dist/renderables/viewport.d.ts +30 -0
  48. package/dist/renderables/viewport.js +59 -3
  49. package/dist/widgets/dropdown.js +3 -3
  50. package/dist/widgets/event-router.d.ts +31 -40
  51. package/dist/widgets/event-router.js +52 -483
  52. package/dist/widgets/hit.js +2 -4
  53. package/dist/widgets/index.d.ts +2 -1
  54. package/dist/widgets/types.d.ts +8 -25
  55. package/dist/widgets/types.js +8 -0
  56. package/dist/widgets/widget-app.d.ts +22 -7
  57. package/dist/widgets/widget-app.js +22 -12
  58. package/package.json +26 -18
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { Segment } from "../core/segment.js";
19
19
  import type { Renderable, Measurable, RenderOptions } from "../core/protocol.js";
20
- import type { Joiner, StyledRenderable } from "../core/strip.js";
20
+ import { type Joiner, type StyledRenderable } from "../core/strip.js";
21
21
  export type FlexAlign = "left" | "center" | "right" | "justify";
22
22
  export interface FlexStripOptions<T extends StyledRenderable> {
23
23
  /** Joiner used between items within a line, and at line endpoints. */
@@ -16,6 +16,7 @@
16
16
  * FlexStrip reuses it verbatim. There is no second concept of "join."
17
17
  */
18
18
  import { Segment } from "../core/segment.js";
19
+ import { drawItem, drawJoin } from "../core/strip.js";
19
20
  function renderToBlock(r, options) {
20
21
  const segments = [...r.render(options)];
21
22
  let width = 0;
@@ -63,10 +64,10 @@ export class FlexStrip {
63
64
  const joiner = this.joiner;
64
65
  // Pre-render every item and every joiner form we might need. Doing this
65
66
  // up-front turns packing into a pure data computation over widths.
66
- const itemBlocks = items.map((it) => renderToBlock(it, options));
67
- const startCap = (i) => joiner ? renderToBlock(joiner.join(null, items[i]), options) : EMPTY_BLOCK;
68
- const endCap = (i) => joiner ? renderToBlock(joiner.join(items[i], null), options) : EMPTY_BLOCK;
69
- const midJoin = (i, j) => joiner ? renderToBlock(joiner.join(items[i], items[j]), options) : EMPTY_BLOCK;
67
+ const itemBlocks = items.map((it) => renderToBlock(drawItem(it), options));
68
+ const startCap = (i) => joiner ? renderToBlock(drawJoin(joiner, null, items[i], { index: i }), options) : EMPTY_BLOCK;
69
+ const endCap = (i) => joiner ? renderToBlock(drawJoin(joiner, items[i], null, { index: i + 1 }), options) : EMPTY_BLOCK;
70
+ const midJoin = (i, j) => joiner ? renderToBlock(drawJoin(joiner, items[i], items[j], { index: j }), options) : EMPTY_BLOCK;
70
71
  const lines = [];
71
72
  let line = null;
72
73
  const gapBlock = gap > 0
@@ -177,23 +178,23 @@ export class FlexStrip {
177
178
  for (let i = 0; i < items.length; i++) {
178
179
  const itemWidth = renderToBlock(items[i], options).width;
179
180
  const sc = this.joiner
180
- ? renderToBlock(this.joiner.join(null, items[i]), options).width
181
+ ? renderToBlock(drawJoin(this.joiner, null, items[i], { index: i }), options).width
181
182
  : 0;
182
183
  const ec = this.joiner
183
- ? renderToBlock(this.joiner.join(items[i], null), options).width
184
+ ? renderToBlock(drawJoin(this.joiner, items[i], null, { index: i + 1 }), options).width
184
185
  : 0;
185
186
  minimum = Math.max(minimum, itemWidth + sc + ec);
186
187
  maximum += itemWidth;
187
188
  if (i > 0) {
188
189
  maximum += this.gap * 2;
189
190
  if (this.joiner) {
190
- maximum += renderToBlock(this.joiner.join(items[i - 1], items[i]), options).width;
191
+ maximum += renderToBlock(drawJoin(this.joiner, items[i - 1], items[i], { index: i }), options).width;
191
192
  }
192
193
  }
193
194
  }
194
195
  if (this.joiner) {
195
- maximum += renderToBlock(this.joiner.join(null, items[0]), options).width;
196
- maximum += renderToBlock(this.joiner.join(items[items.length - 1], null), options).width;
196
+ maximum += renderToBlock(drawJoin(this.joiner, null, items[0], { index: 0 }), options).width;
197
+ maximum += renderToBlock(drawJoin(this.joiner, items[items.length - 1], null, { index: items.length }), options).width;
197
198
  }
198
199
  return { minimum, maximum };
199
200
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * ListView — items stacked down a `Viewport`, scrolled to keep the selected
3
+ * one in view. `moveSelection` (core/selection) is the cursor that picks it.
4
+ *
5
+ * An item can wrap, so the lines the selected one covers exist only at the
6
+ * width it is drawn at, which a render is handed and whoever builds the frame
7
+ * is not. So the items are drawn here, at the viewport's content width and
8
+ * with no height — the lines the viewport scrolls — and the viewport is handed
9
+ * those lines and the selected item's range.
10
+ * [LAW:single-enforcer] Where the window sits is the viewport's call; this
11
+ * only says which lines the selection is.
12
+ *
13
+ * The viewport is handed in rather than made here: the scroll position lives
14
+ * in it, and a frame rebuilt on every key would otherwise start each one at
15
+ * the top. The pieces stay apart — a cursor, a viewport, a list drawn through
16
+ * it — so a consumer can take one without the others.
17
+ */
18
+ import { Segment } from "../core/segment.js";
19
+ import type { Measurable, Renderable, RenderOptions } from "../core/protocol.js";
20
+ import type { Viewport } from "./viewport.js";
21
+ export interface ListViewOptions {
22
+ /** Blank lines between one item and the next. Absent, none. */
23
+ gap?: number;
24
+ /**
25
+ * Lines kept in view above and below the selected item, where the list has
26
+ * them: the neighbour a cursor is about to move onto. Absent, none.
27
+ */
28
+ context?: number;
29
+ }
30
+ export declare class ListView implements Renderable, Measurable {
31
+ readonly viewport: Viewport;
32
+ readonly items: readonly Renderable[];
33
+ readonly selected: number;
34
+ readonly gap: number;
35
+ readonly context: number;
36
+ constructor(viewport: Viewport, items: readonly Renderable[], selected: number, options?: ListViewOptions);
37
+ render(rawOptions: RenderOptions): Iterable<Segment>;
38
+ measure(rawOptions: RenderOptions): {
39
+ minimum: number;
40
+ maximum: number;
41
+ };
42
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * ListView — items stacked down a `Viewport`, scrolled to keep the selected
3
+ * one in view. `moveSelection` (core/selection) is the cursor that picks it.
4
+ *
5
+ * An item can wrap, so the lines the selected one covers exist only at the
6
+ * width it is drawn at, which a render is handed and whoever builds the frame
7
+ * is not. So the items are drawn here, at the viewport's content width and
8
+ * with no height — the lines the viewport scrolls — and the viewport is handed
9
+ * those lines and the selected item's range.
10
+ * [LAW:single-enforcer] Where the window sits is the viewport's call; this
11
+ * only says which lines the selection is.
12
+ *
13
+ * The viewport is handed in rather than made here: the scroll position lives
14
+ * in it, and a frame rebuilt on every key would otherwise start each one at
15
+ * the top. The pieces stay apart — a cursor, a viewport, a list drawn through
16
+ * it — so a consumer can take one without the others.
17
+ */
18
+ import { Segment } from "../core/segment.js";
19
+ import { Measurement, measureRenderables } from "../core/measure.js";
20
+ import { withBoundedWidth, withCellWidth } from "../core/protocol.js";
21
+ function lineCount(name, value) {
22
+ if (!Number.isInteger(value) || value < 0)
23
+ throw new RangeError(`ListView: ${name} ${value} is not a whole number of lines`);
24
+ return value;
25
+ }
26
+ export class ListView {
27
+ viewport;
28
+ items;
29
+ selected;
30
+ gap;
31
+ context;
32
+ constructor(viewport, items, selected, options = {}) {
33
+ this.viewport = viewport;
34
+ this.items = items;
35
+ this.selected = selected;
36
+ this.gap = lineCount("gap", options.gap ?? 0);
37
+ this.context = lineCount("context", options.context ?? 0);
38
+ // An empty list's selection is 0: there is nothing for it to name.
39
+ if (!Number.isInteger(selected) || selected < 0 || selected >= Math.max(1, items.length)) {
40
+ throw new RangeError(`ListView: selected ${selected} is not an index of ${items.length} items`);
41
+ }
42
+ }
43
+ *render(rawOptions) {
44
+ const bounded = withBoundedWidth(rawOptions, this);
45
+ const { height: _height, ...rest } = bounded;
46
+ const options = { ...rest, maxWidth: this.viewport.contentWidth(rest.maxWidth) };
47
+ const gap = Array.from({ length: this.gap }, () => []);
48
+ const drawn = this.items.map((item) => Segment.splitLines(item.render(options)));
49
+ // The gap sits between items, so every item before the selected one
50
+ // brings its lines and one gap.
51
+ const start = drawn.slice(0, this.selected).reduce((total, item) => total + item.length + this.gap, 0);
52
+ const end = start + (drawn[this.selected]?.length ?? 0);
53
+ const lines = drawn.flatMap((item, i) => (i === 0 ? item : [...gap, ...item]));
54
+ this.viewport.content = {
55
+ *render() {
56
+ for (const line of lines) {
57
+ yield* line;
58
+ yield Segment.line();
59
+ }
60
+ },
61
+ };
62
+ // A range reaching past either end is clamped by the viewport, which holds
63
+ // its offset to the lines that exist. Followed, not ensured: the wheel can
64
+ // scroll away from the selection until the selection moves.
65
+ this.viewport.follow(start - this.context, end + this.context);
66
+ yield* this.viewport.render(bounded);
67
+ }
68
+ measure(rawOptions) {
69
+ // Stacked, the items are as wide as the widest of them, plus whatever
70
+ // gutter the viewport's scrollbar takes. `withCellWidth` and not
71
+ // `withBoundedWidth`: the bounded parse asks this very method.
72
+ const options = withCellWidth(rawOptions);
73
+ const contentWidth = this.viewport.contentWidth(options.maxWidth);
74
+ const gutter = this.viewport.gutter;
75
+ const items = measureRenderables({ ...options, maxWidth: contentWidth }, this.items);
76
+ return new Measurement(items.minimum + gutter, items.maximum + gutter).withMaximum(options.maxWidth);
77
+ }
78
+ }
@@ -29,6 +29,11 @@ export interface Task {
29
29
  visible: boolean;
30
30
  /** When the task started, in seconds on its `Progress`'s clock; undefined until it has. */
31
31
  startTime: number | undefined;
32
+ /**
33
+ * Rich's `Task.get_time()`: the moment the frame being drawn is drawn at, in
34
+ * seconds on its `Progress`'s clock, so a column animates without reading one.
35
+ */
36
+ time: number;
32
37
  /**
33
38
  * Rich's `Task.elapsed`: seconds since the task started, as of the frame
34
39
  * being drawn — `Progress` reads its clock once a frame and hands columns
@@ -77,6 +82,7 @@ export declare class BarColumn implements ProgressColumn {
77
82
  readonly barWidth: number;
78
83
  readonly tableColumn: ColumnOptions;
79
84
  constructor(barWidth?: number);
85
+ /** Rich's `BarColumn`: a task not yet started, or with no total to fill toward, pulses. */
80
86
  render(task: Task): ProgressBar;
81
87
  }
82
88
  /**
@@ -82,11 +82,14 @@ export class BarColumn {
82
82
  constructor(barWidth) {
83
83
  this.barWidth = barWidth ?? 40;
84
84
  }
85
+ /** Rich's `BarColumn`: a task not yet started, or with no total to fill toward, pulses. */
85
86
  render(task) {
87
+ const pulsing = task.startTime === undefined || task.total === undefined;
86
88
  return new ProgressBar({
87
89
  total: task.total ?? 100,
88
90
  completed: task.completed,
89
91
  width: this.barWidth,
92
+ pulse: pulsing ? { t: task.time } : undefined,
90
93
  });
91
94
  }
92
95
  }
@@ -283,10 +286,13 @@ export class Progress {
283
286
  const task = this._tasks.get(taskId);
284
287
  if (!task)
285
288
  return;
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.
289
+ // Rich's order: `advance` is added, then a given `completed` overwrites it,
290
+ // so `completed` wins when both come. Checked whole before any field is
291
+ // written, so a refused update leaves the task as it was.
288
292
  const advance = finiteCount("advance", options.advance ?? 0);
289
- const completed = finiteCount("completed", (options.completed ?? task.completed) + advance);
293
+ const completed = options.completed === undefined
294
+ ? finiteCount("completed + advance", task.completed + advance)
295
+ : finiteCount("completed", options.completed);
290
296
  const now = this._clock.now();
291
297
  recordSample(task.samples, now, completed - task.completed);
292
298
  task.completed = completed;
@@ -326,7 +332,7 @@ export class Progress {
326
332
  table.addColumn(undefined, col.tableColumn);
327
333
  }
328
334
  // [LAW:effects-at-boundaries] One read of the clock a frame, handed to
329
- // every column as the task's `elapsed`.
335
+ // every column as the task's `time` and `elapsed`.
330
336
  const now = this._clock.now();
331
337
  for (const { samples, ...state } of this._tasks.values()) {
332
338
  if (!state.visible)
@@ -335,6 +341,7 @@ export class Progress {
335
341
  const started = startTime !== undefined;
336
342
  const task = {
337
343
  ...state,
344
+ time: now,
338
345
  elapsed: started ? now - startTime : undefined,
339
346
  speed: started ? sampleSpeed(samples) : undefined,
340
347
  };
@@ -8,7 +8,15 @@ export interface ProgressBarOptions {
8
8
  total?: number;
9
9
  completed?: number;
10
10
  width?: number;
11
- pulse?: boolean;
11
+ /**
12
+ * Rich's pulse, for work whose end is not known, drawn as it is `t` seconds
13
+ * into whoever draws the frames' clock: the whole bar in its back style,
14
+ * with the library's `shimmer` carrying `bar.pulse` light along it. Without
15
+ * it, the bar shows `completed` of `total`.
16
+ */
17
+ pulse?: {
18
+ readonly t: number;
19
+ };
12
20
  style?: string | Style;
13
21
  completeStyle?: string | Style;
14
22
  finishedStyle?: string | Style;
@@ -17,12 +25,24 @@ export declare class ProgressBar implements Renderable, Measurable {
17
25
  total: number;
18
26
  completed: number;
19
27
  readonly width: number | undefined;
20
- readonly pulse: boolean;
28
+ readonly pulse: {
29
+ readonly t: number;
30
+ } | undefined;
21
31
  readonly style: string | Style;
22
32
  readonly completeStyle: string | Style;
23
33
  readonly finishedStyle: string | Style;
24
34
  constructor(options?: ProgressBarOptions);
25
35
  render(options: RenderOptions): Iterable<Segment>;
36
+ /**
37
+ * The shimmer over this bar's back colour as `Effected` sees it drawn at
38
+ * `depth`, toward `bar.pulse`. A bar has no words to keep legible, so its
39
+ * back takes the whole of the light.
40
+ */
41
+ private pulseEffect;
42
+ /** Rich 9d8f9a3 `__rich_console__`: the fill is counted in half cells, and a total of zero is a bar already full. */
43
+ private completeHalves;
44
+ /** The bar `width` cells wide with `completeHalves` half cells filled, the rest in `back`. */
45
+ private track;
26
46
  /** 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
47
  measure(options: RenderOptions): {
28
48
  minimum: number;
@@ -2,13 +2,25 @@
2
2
  * ProgressBar — a visual progress bar rendered with block characters.
3
3
  */
4
4
  import { cellCount } from "../core/cells.js";
5
+ import { ColorDepth, ColorRgba, ColorSpec, resolveTerminal } from "../core/color.js";
5
6
  import { Segment } from "../core/segment.js";
6
- import { NULL_STYLE } from "../core/style.js";
7
+ import { Style } from "../core/style.js";
7
8
  import { drawable, getStyle } from "../core/protocol.js";
9
+ import { Effected } from "./effect.js";
10
+ import { EFFECT_CURVES, atShares, shimmer } from "./effects.js";
8
11
  const BAR = "━";
9
12
  const ASCII_BAR = "-";
10
13
  const HALF_BAR_RIGHT = "╸";
11
14
  const HALF_BAR_LEFT = "╺";
15
+ /**
16
+ * The pulse's shimmer: the signed-off ease and swing, at a period that carries
17
+ * the light along a bar at about Rich's pace, and a band reaching half Rich's
18
+ * `PULSE_SIZE` of 20 cells either side of its centre.
19
+ */
20
+ const PULSE_CURVE = { ...EFFECT_CURVES.shimmer, seconds: 5 };
21
+ const PULSE_REACH = 10;
22
+ /** Rich's pulse back where the back style names no colour. */
23
+ const PULSE_BACK = ColorSpec.fromRgba(new ColorRgba(0, 0, 0));
12
24
  export class ProgressBar {
13
25
  total;
14
26
  completed;
@@ -21,8 +33,8 @@ export class ProgressBar {
21
33
  this.total = options?.total ?? 100;
22
34
  this.completed = options?.completed ?? 0;
23
35
  this.width = options?.width;
24
- this.pulse = options?.pulse ?? false;
25
- this.style = options?.style ?? NULL_STYLE;
36
+ this.pulse = options?.pulse;
37
+ this.style = options?.style ?? "bar.back";
26
38
  this.completeStyle = options?.completeStyle ?? "bar.complete";
27
39
  this.finishedStyle = options?.finishedStyle ?? "bar.finished";
28
40
  }
@@ -30,15 +42,48 @@ export class ProgressBar {
30
42
  // Never wider than offered, as Rich's `min(self.width or max_width, max_width)`.
31
43
  // [LAW:parse-dont-validate] parsed as a cell count, so a negative width draws nothing, as Python's `"━" * -2` is "".
32
44
  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.
45
+ if (this.pulse === undefined) {
46
+ yield* this.track(options, width, this.completeHalves(width), getStyle(options, this.style));
47
+ return;
48
+ }
49
+ const depth = options.colorSystem ?? ColorDepth.TRUECOLOR;
50
+ // Rich's pulse resolves its colours on the terminal a depth assumes, as
51
+ // `get_truecolor` does with no theme; so does this.
52
+ const theme = resolveTerminal(undefined, depth);
53
+ // Rich writes the pulse's back as a colour of its own, black where the
54
+ // back style names none, so the light always has the bar's colour to fall
55
+ // on: one the terminal supplies takes no light.
56
+ const style = getStyle(options, this.style);
57
+ const ink = style.color?.isDefault === false ? style.color : ColorSpec.fromRgba((style.color ?? PULSE_BACK).getTruecolor(theme, true));
58
+ const back = style.add(Style.fromColor(ink));
59
+ // [LAW:no-ambient-temporal-coupling] The moment is the caller's; the bar
60
+ // only says what it looks like then. An empty track, lit by the shimmer.
61
+ const track = { render: (o) => this.track(o, width, 0, back) };
62
+ yield* new Effected(track, this.pulseEffect(options, back, width, depth, theme), { t: this.pulse.t, key: "progress-bar", theme }).render(options);
63
+ }
64
+ /**
65
+ * The shimmer over this bar's back colour as `Effected` sees it drawn at
66
+ * `depth`, toward `bar.pulse`. A bar has no words to keep legible, so its
67
+ * back takes the whole of the light.
68
+ */
69
+ pulseEffect(options, back, width, depth, theme) {
70
+ const ink = back.drawnColors(depth).color ?? ColorSpec.default();
71
+ const glow = getStyle(options, "bar.pulse").drawnColors(depth).color ?? ColorSpec.default();
72
+ const loop = shimmer(PULSE_CURVE, width, PULSE_REACH, glow.getTruecolor(theme, true), 0);
73
+ const whole = new Map([[ink.getTruecolor(theme, true).hex, 1]]);
74
+ return atShares(loop, () => whole);
75
+ }
76
+ /** Rich 9d8f9a3 `__rich_console__`: the fill is counted in half cells, and a total of zero is a bar already full. */
77
+ completeHalves(width) {
35
78
  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;
79
+ return this.total ? Math.trunc((width * 2 * completed) / this.total) : width * 2;
80
+ }
81
+ /** The bar `width` cells wide with `completeHalves` half cells filled, the rest in `back`. */
82
+ *track(options, width, completeHalves, back) {
37
83
  const barCount = Math.floor(completeHalves / 2);
38
84
  const halfBarCount = completeHalves % 2;
39
85
  const isFinished = this.completed >= this.total;
40
86
  const fill = getStyle(options, isFinished ? this.finishedStyle : this.completeStyle);
41
- const back = getStyle(options, this.style);
42
87
  const fillStyle = fill.isNull ? undefined : fill;
43
88
  const backStyle = back.isNull ? undefined : back;
44
89
  const bar = drawable(options, BAR, ASCII_BAR);
@@ -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 {
@@ -3,8 +3,8 @@
3
3
  */
4
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";
6
+ import { resolveStyle } from "../core/text.js";
7
+ import { drawable } from "../core/protocol.js";
8
8
  import { cutLabel, drawLabel, inlineLabel } from "./embed.js";
9
9
  const ASCII_RULE_CHAR = "-";
10
10
  const DEFAULT_RULE_CHAR = "\u2500"; // ─
@@ -29,12 +29,14 @@ export class Rule {
29
29
  this._label = inlineLabel(title);
30
30
  this.characters = chars;
31
31
  this.align = align ?? "center";
32
- this.style = options?.style ?? NULL_STYLE;
32
+ this.style = options?.style ?? "rule.line";
33
33
  }
34
34
  *render(options) {
35
35
  const maxWidth = options.maxWidth;
36
36
  const ruleChar = drawable(options, this.characters, ASCII_RULE_CHAR);
37
- 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);
38
40
  const ruleStyle = style.isNull ? undefined : style;
39
41
  const line = (width) => new Segment(repeatToWidth(ruleChar, width), ruleStyle);
40
42
  // Rich's `required_space`: a centred title keeps a rule cell and a space
@@ -46,20 +48,20 @@ export class Rule {
46
48
  yield Segment.line();
47
49
  return;
48
50
  }
49
- const text = this._label.text(options);
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");
50
54
  cutLabel(text, room, drawable(options, "\u2026", "."));
51
- const title = drawLabel(text, options, ruleStyle);
55
+ const title = drawLabel(text, options, undefined);
52
56
  const titleWidth = Segment.getLineLength(title);
53
57
  // As Rich lays the line out: the gaps of a centred title are the rule's,
54
58
  // styled with it; an aligned title's one gap is unstyled.
55
59
  switch (this.align) {
56
60
  case "center": {
57
61
  const left = Math.floor((maxWidth - titleWidth) / 2) - 1;
58
- yield line(left);
59
- yield new Segment(" ", ruleStyle);
62
+ yield new Segment(`${repeatToWidth(ruleChar, left)} `, ruleStyle);
60
63
  yield* title;
61
- yield new Segment(" ", ruleStyle);
62
- yield line(maxWidth - left - titleWidth - 2);
64
+ yield new Segment(` ${repeatToWidth(ruleChar, maxWidth - left - titleWidth - 2)}`, ruleStyle);
63
65
  break;
64
66
  }
65
67
  case "left":
@@ -924,12 +924,11 @@ export class Table {
924
924
  }
925
925
  *_renderTitle(options, text, tableWidth, ownStyle, justify) {
926
926
  // The table owns the canvas; the caller's text still says how it meets the
927
- // edge. A `RichText`'s own `justify` and `overflow` outrank the options
928
- // `render` is handed, so `titleJustify` would lose to a property the caller
929
- // may not know it set, and an `"ignore"` title would have no edge and run
930
- // straight through the frame. Every other method, and `noWrap`, cuts within
931
- // the bound, so those stay theirs. `text` hands back a copy to clear them
932
- // on. [LAW:one-source-of-truth]
927
+ // edge. A `RichText`'s own `justify` outranks `titleJustify`, as a `Text`'s
928
+ // does in the reference (rich-table-qj6i.kpp). Its own `overflow` does too,
929
+ // but for `"ignore"`: that title would have no edge and run straight
930
+ // through the frame. Every other method, and `noWrap`, cuts within the
931
+ // bound, so those stay theirs. `text` hands back a copy to clear it on.
933
932
  //
934
933
  // Never highlighted, and markup as the console says: Rich draws a title
935
934
  // and caption through `render_str(highlight=False)`.
@@ -941,7 +940,6 @@ export class Table {
941
940
  // the reference renders a `Text` title without the title style
942
941
  // (rich-table-qj6i).
943
942
  const source = text.text({ ...options, highlight: false }, getStyle(options, ownStyle));
944
- source.justify = undefined;
945
943
  if (source.overflow === "ignore")
946
944
  source.overflow = undefined;
947
945
  //
@@ -71,6 +71,7 @@ export declare class Viewport implements Renderable, Measurable, Scrollable {
71
71
  private _offset;
72
72
  private _extent;
73
73
  private _moves;
74
+ private _followed;
74
75
  constructor(content: Renderable, options?: ViewportOptions);
75
76
  /**
76
77
  * The first content line the last render showed. A move requested since is
@@ -94,11 +95,24 @@ export declare class Viewport implements Renderable, Measurable, Scrollable {
94
95
  * the viewport shows its first line at the top.
95
96
  */
96
97
  ensureVisible(start: number, end: number): void;
98
+ /**
99
+ * `ensureVisible(start, end)`, when the range differs from the one the last
100
+ * `follow` named. A view rebuilt every frame calls this in every frame with
101
+ * its selection's lines, and the selection is brought into view when it
102
+ * moves — not on every frame, which would undo on its very render each
103
+ * scroll the mouse wheel queues. A followed range the last render showed
104
+ * whole stays shown when the row count changes: the same lines can fall off
105
+ * the bottom of a viewport that got shorter. One the wheel had scrolled
106
+ * away from stays away.
107
+ */
108
+ follow(start: number, end: number): void;
97
109
  /**
98
110
  * The width its content renders at, out of `width`: what the scrollbar's
99
111
  * gutter leaves. Lines handed to `ensureVisible` are lines at this width.
100
112
  */
101
113
  contentWidth(width: number): number;
114
+ /** The cells the scrollbar's gutter takes beside the content. */
115
+ get gutter(): number;
102
116
  render(rawOptions: RenderOptions): Iterable<Segment>;
103
117
  /**
104
118
  * [LAW:one-source-of-truth] Where the queued moves leave the offset against
@@ -112,3 +126,19 @@ export declare class Viewport implements Renderable, Measurable, Scrollable {
112
126
  maximum: number;
113
127
  };
114
128
  }
129
+ /**
130
+ * One viewport per thing a pane shows, named by a key. The same viewport comes
131
+ * back while the key holds, so its scroll position does too; a new key gets a
132
+ * fresh one, so a new subject opens at its top rather than wherever the last
133
+ * one was left. A frame is rebuilt on every key press and a scroll position is
134
+ * a viewport's, so this is what outlives the frame for a pane whose subject
135
+ * changes under it.
136
+ */
137
+ export declare class ViewportPerKey {
138
+ private readonly options;
139
+ private held;
140
+ constructor(options?: ViewportOptions);
141
+ /** The viewport for `key`: the held one while the key holds, a fresh one otherwise. */
142
+ of(key: string): Viewport;
143
+ private fresh;
144
+ }
@@ -58,6 +58,8 @@ export class Viewport {
58
58
  // Nothing rendered, nothing shown: no move can take until the first render.
59
59
  _extent = { rows: 0, lines: 0 };
60
60
  _moves = [];
61
+ // No range followed yet: NaN equals nothing, so the first `follow` moves.
62
+ _followed = { start: NaN, end: NaN, rows: NaN };
61
63
  constructor(content, options = {}) {
62
64
  this.content = content;
63
65
  this.rows = options.rows;
@@ -96,20 +98,47 @@ export class Viewport {
96
98
  ensureVisible(start, end) {
97
99
  this._moves.push((offset, { rows }) => Math.min(start, Math.max(offset, end - rows)));
98
100
  }
101
+ /**
102
+ * `ensureVisible(start, end)`, when the range differs from the one the last
103
+ * `follow` named. A view rebuilt every frame calls this in every frame with
104
+ * its selection's lines, and the selection is brought into view when it
105
+ * moves — not on every frame, which would undo on its very render each
106
+ * scroll the mouse wheel queues. A followed range the last render showed
107
+ * whole stays shown when the row count changes: the same lines can fall off
108
+ * the bottom of a viewport that got shorter. One the wheel had scrolled
109
+ * away from stays away.
110
+ */
111
+ follow(start, end) {
112
+ if (start === this._followed.start && end === this._followed.end)
113
+ return;
114
+ this._followed = { start, end, rows: NaN };
115
+ this.ensureVisible(start, end);
116
+ }
99
117
  /**
100
118
  * The width its content renders at, out of `width`: what the scrollbar's
101
119
  * gutter leaves. Lines handed to `ensureVisible` are lines at this width.
102
120
  */
103
121
  contentWidth(width) {
104
- return cellCount(width - gutterWidth(this.scrollbar));
122
+ return cellCount(width - this.gutter);
123
+ }
124
+ /** The cells the scrollbar's gutter takes beside the content. */
125
+ get gutter() {
126
+ return gutterWidth(this.scrollbar);
105
127
  }
106
128
  *render(rawOptions) {
107
129
  const { height, ...options } = withBoundedWidth(rawOptions, this);
108
- const gutter = gutterWidth(this.scrollbar);
130
+ const gutter = this.gutter;
109
131
  const contentWidth = this.contentWidth(options.maxWidth);
110
132
  const lines = Segment.splitLines(this.content.render({ ...options, maxWidth: contentWidth }));
111
133
  const extent = { rows: viewRows(height, this.rows, lines.length), lines: lines.length };
134
+ const followed = this._followed;
135
+ const wasShown = followed.start >= this._offset && followed.end <= this._offset + this._extent.rows;
112
136
  this._extent = extent;
137
+ if (!Number.isNaN(followed.rows) && followed.rows !== extent.rows && wasShown) {
138
+ this.ensureVisible(followed.start, followed.end);
139
+ }
140
+ if (!Number.isNaN(followed.start))
141
+ this._followed = { ...followed, rows: extent.rows };
113
142
  this._offset = this.resolve(extent);
114
143
  this._moves = [];
115
144
  const shown = fitHeight(lines.slice(this._offset, this._offset + extent.rows), { rows: extent.rows, exact: true });
@@ -153,12 +182,39 @@ export class Viewport {
153
182
  // `withBoundedWidth`: the bounded parse asks this very method for the
154
183
  // natural width.
155
184
  const options = withCellWidth(rawOptions);
156
- const gutter = gutterWidth(this.scrollbar);
185
+ const gutter = this.gutter;
157
186
  const inner = { ...options, maxWidth: this.contentWidth(options.maxWidth) };
158
187
  const content = Measurement.get(inner, this.content);
159
188
  return new Measurement(content.minimum + gutter, content.maximum + gutter).withMaximum(options.maxWidth);
160
189
  }
161
190
  }
191
+ /**
192
+ * One viewport per thing a pane shows, named by a key. The same viewport comes
193
+ * back while the key holds, so its scroll position does too; a new key gets a
194
+ * fresh one, so a new subject opens at its top rather than wherever the last
195
+ * one was left. A frame is rebuilt on every key press and a scroll position is
196
+ * a viewport's, so this is what outlives the frame for a pane whose subject
197
+ * changes under it.
198
+ */
199
+ export class ViewportPerKey {
200
+ options;
201
+ held;
202
+ constructor(options = {}) {
203
+ this.options = options;
204
+ this.held = { key: "", viewport: this.fresh() };
205
+ }
206
+ /** The viewport for `key`: the held one while the key holds, a fresh one otherwise. */
207
+ of(key) {
208
+ if (key !== this.held.key)
209
+ this.held = { key, viewport: this.fresh() };
210
+ return this.held.viewport;
211
+ }
212
+ fresh() {
213
+ // Empty until a view hands it content: the content is built where the frame is.
214
+ return new Viewport(EMPTY, this.options);
215
+ }
216
+ }
217
+ const EMPTY = { *render() { } };
162
218
  const scrollbarGlyphs = ({ thumb, track }) => thumb.glyph + track.glyph;
163
219
  /** `scrollbar` in its own styles, drawn in ASCII. */
164
220
  function asciiScrollbar({ thumb, track }) {