@promptctl/rich-js 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.md +11 -8
  2. package/dist/core/box.d.ts +3 -1
  3. package/dist/core/box.js +6 -13
  4. package/dist/core/cells.d.ts +61 -28
  5. package/dist/core/cells.js +171 -124
  6. package/dist/core/clock.d.ts +48 -0
  7. package/dist/core/clock.js +42 -0
  8. package/dist/core/color.d.ts +99 -18
  9. package/dist/core/color.js +334 -146
  10. package/dist/core/console.d.ts +38 -7
  11. package/dist/core/console.js +159 -90
  12. package/dist/core/easing.d.ts +86 -0
  13. package/dist/core/easing.js +171 -0
  14. package/dist/core/env.d.ts +16 -0
  15. package/dist/core/env.js +16 -0
  16. package/dist/core/export-html.js +1 -4
  17. package/dist/core/export-lines.d.ts +22 -13
  18. package/dist/core/export-lines.js +37 -19
  19. package/dist/core/export-svg.d.ts +49 -0
  20. package/dist/core/export-svg.js +213 -0
  21. package/dist/core/fnv1a.d.ts +10 -0
  22. package/dist/core/fnv1a.js +17 -0
  23. package/dist/core/highlighter.d.ts +2 -2
  24. package/dist/core/highlighter.js +113 -47
  25. package/dist/core/json.js +1 -1
  26. package/dist/core/markup.d.ts +28 -7
  27. package/dist/core/markup.js +235 -312
  28. package/dist/core/measure.d.ts +3 -3
  29. package/dist/core/measure.js +12 -7
  30. package/dist/core/memo.d.ts +19 -0
  31. package/dist/core/memo.js +34 -0
  32. package/dist/core/noise.d.ts +31 -0
  33. package/dist/core/noise.js +94 -0
  34. package/dist/core/osc8.d.ts +6 -6
  35. package/dist/core/osc8.js +9 -21
  36. package/dist/core/paint.d.ts +107 -0
  37. package/dist/core/paint.js +161 -0
  38. package/dist/core/place.d.ts +1 -1
  39. package/dist/core/place.js +4 -6
  40. package/dist/core/pretty.d.ts +126 -2
  41. package/dist/core/pretty.js +571 -105
  42. package/dist/core/protocol.d.ts +43 -3
  43. package/dist/core/protocol.js +23 -2
  44. package/dist/core/render.d.ts +33 -30
  45. package/dist/core/render.js +43 -83
  46. package/dist/core/segment.d.ts +6 -5
  47. package/dist/core/segment.js +11 -15
  48. package/dist/core/selection.d.ts +12 -0
  49. package/dist/core/selection.js +14 -0
  50. package/dist/core/strip.d.ts +116 -74
  51. package/dist/core/strip.js +266 -217
  52. package/dist/core/style.d.ts +27 -7
  53. package/dist/core/style.js +106 -41
  54. package/dist/core/text.d.ts +43 -16
  55. package/dist/core/text.js +180 -59
  56. package/dist/core/wrap.d.ts +5 -0
  57. package/dist/core/wrap.js +16 -3
  58. package/dist/host/app.d.ts +95 -17
  59. package/dist/host/app.js +201 -61
  60. package/dist/host/index.d.ts +4 -1
  61. package/dist/host/index.js +1 -0
  62. package/dist/host/input.d.ts +84 -0
  63. package/dist/host/input.js +498 -0
  64. package/dist/host/terminal-host.d.ts +15 -2
  65. package/dist/host/terminal-host.js +2 -0
  66. package/dist/index.d.ts +26 -14
  67. package/dist/index.js +17 -7
  68. package/dist/node/prompt.d.ts +13 -0
  69. package/dist/node/prompt.js +30 -9
  70. package/dist/node/save.d.ts +4 -3
  71. package/dist/node/save.js +6 -3
  72. package/dist/node/terminal-host.d.ts +10 -2
  73. package/dist/node/terminal-host.js +7 -0
  74. package/dist/node/traceback.d.ts +2 -1
  75. package/dist/node/traceback.js +3 -39
  76. package/dist/renderables/align.js +2 -9
  77. package/dist/renderables/columns.d.ts +45 -29
  78. package/dist/renderables/columns.js +142 -103
  79. package/dist/renderables/constrain.js +6 -12
  80. package/dist/renderables/effect.d.ts +113 -0
  81. package/dist/renderables/effect.js +182 -0
  82. package/dist/renderables/effects.d.ts +252 -0
  83. package/dist/renderables/effects.js +565 -0
  84. package/dist/renderables/embed.d.ts +94 -31
  85. package/dist/renderables/embed.js +141 -45
  86. package/dist/renderables/group.d.ts +6 -3
  87. package/dist/renderables/group.js +5 -0
  88. package/dist/renderables/layout.d.ts +6 -23
  89. package/dist/renderables/layout.js +24 -113
  90. package/dist/renderables/list-view.d.ts +42 -0
  91. package/dist/renderables/list-view.js +78 -0
  92. package/dist/renderables/live.d.ts +40 -16
  93. package/dist/renderables/live.js +96 -83
  94. package/dist/renderables/markdown.d.ts +16 -2
  95. package/dist/renderables/markdown.js +521 -118
  96. package/dist/renderables/padding.d.ts +22 -1
  97. package/dist/renderables/padding.js +30 -24
  98. package/dist/renderables/panel.d.ts +31 -33
  99. package/dist/renderables/panel.js +137 -141
  100. package/dist/renderables/progress.d.ts +110 -16
  101. package/dist/renderables/progress.js +212 -81
  102. package/dist/renderables/progressBar.d.ts +24 -4
  103. package/dist/renderables/progressBar.js +78 -23
  104. package/dist/renderables/prompt.d.ts +37 -15
  105. package/dist/renderables/prompt.js +125 -85
  106. package/dist/renderables/ratio.d.ts +123 -0
  107. package/dist/renderables/ratio.js +213 -0
  108. package/dist/renderables/rule.d.ts +3 -2
  109. package/dist/renderables/rule.js +50 -52
  110. package/dist/renderables/spinner.d.ts +28 -2
  111. package/dist/renderables/spinner.js +52 -19
  112. package/dist/renderables/status.d.ts +13 -7
  113. package/dist/renderables/status.js +15 -29
  114. package/dist/renderables/syntax.d.ts +37 -7
  115. package/dist/renderables/syntax.js +134 -83
  116. package/dist/renderables/table.d.ts +58 -20
  117. package/dist/renderables/table.js +373 -197
  118. package/dist/renderables/traceback.d.ts +5 -4
  119. package/dist/renderables/traceback.js +239 -68
  120. package/dist/renderables/tree.d.ts +10 -2
  121. package/dist/renderables/tree.js +61 -34
  122. package/dist/renderables/viewport.d.ts +30 -0
  123. package/dist/renderables/viewport.js +61 -7
  124. package/dist/template-bindings/color-funcs.d.ts +31 -9
  125. package/dist/template-bindings/color-funcs.js +40 -14
  126. package/dist/template-bindings/helpers.d.ts +4 -2
  127. package/dist/template-bindings/helpers.js +5 -20
  128. package/dist/template-bindings/index.d.ts +29 -17
  129. package/dist/template-bindings/index.js +36 -20
  130. package/dist/template-bindings/palette-funcs.d.ts +3 -1
  131. package/dist/template-bindings/palette-funcs.js +11 -7
  132. package/dist/template-bindings/style-funcs.js +4 -2
  133. package/dist/themes/buildPalette.d.ts +24 -3
  134. package/dist/themes/buildPalette.js +52 -8
  135. package/dist/themes/colorMath.d.ts +29 -17
  136. package/dist/themes/colorMath.js +74 -48
  137. package/dist/themes/colorRef.d.ts +2 -17
  138. package/dist/themes/colorRef.js +13 -31
  139. package/dist/themes/palette.d.ts +24 -0
  140. package/dist/themes/palette.js +46 -3
  141. package/dist/themes/ramp.d.ts +21 -34
  142. package/dist/themes/ramp.js +23 -42
  143. package/dist/themes/registry.js +39 -28
  144. package/dist/widgets/button.d.ts +2 -6
  145. package/dist/widgets/button.js +9 -40
  146. package/dist/widgets/checkbox.d.ts +2 -5
  147. package/dist/widgets/checkbox.js +9 -31
  148. package/dist/widgets/dropdown.d.ts +2 -5
  149. package/dist/widgets/dropdown.js +17 -51
  150. package/dist/widgets/event-router.d.ts +31 -40
  151. package/dist/widgets/event-router.js +52 -483
  152. package/dist/widgets/index.d.ts +2 -1
  153. package/dist/widgets/ink.d.ts +22 -0
  154. package/dist/widgets/ink.js +31 -0
  155. package/dist/widgets/slider.d.ts +2 -5
  156. package/dist/widgets/slider.js +11 -34
  157. package/dist/widgets/text-input.d.ts +19 -10
  158. package/dist/widgets/text-input.js +156 -147
  159. package/dist/widgets/themed-widget.d.ts +20 -0
  160. package/dist/widgets/themed-widget.js +89 -0
  161. package/dist/widgets/toggle.d.ts +2 -5
  162. package/dist/widgets/toggle.js +8 -31
  163. package/dist/widgets/types.d.ts +8 -25
  164. package/dist/widgets/types.js +8 -0
  165. package/dist/widgets/widget-app.d.ts +24 -7
  166. package/dist/widgets/widget-app.js +35 -18
  167. package/dist/widgets/widget-base.d.ts +2 -0
  168. package/dist/widgets/widget-base.js +9 -2
  169. package/package.json +31 -17
package/dist/core/text.js CHANGED
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * RichText — styled text with spans. The primary text type for the library.
3
3
  */
4
- import { cellLen, cellCount, cellFit, cellFitEnd, asCellCol } from "./cells.js";
5
- import { divideLine } from "./wrap.js";
4
+ import { cellLen, cellCount, cellFit, cellFitEnd, asCellCol, expandTabs, parseTabSize } from "./cells.js";
5
+ import { divideLine, wordWidths } from "./wrap.js";
6
6
  import { Segment } from "./segment.js";
7
7
  import { Style, NULL_STYLE, StyleSyntaxError } from "./style.js";
8
8
  import { stripOscTerminators } from "./osc8.js";
9
9
  import { drawable, getStyle, withBoundedWidth } from "./protocol.js";
10
- // Strip control characters except \t and \n
10
+ // Every C0 control and DEL except \t and \n. Rich's `Text` strips only
11
+ // `STRIP_CONTROL_CODES` (BEL, BS, VT, FF, CR); the rest of the set is the
12
+ // port's extension, so no C0 byte of content can start an escape sequence or
13
+ // move the cursor on the wire. C1 controls (U+0080–U+009F) pass, as in Rich.
11
14
  // [LAW:single-enforcer] Single place where control chars are sanitized
12
- const CONTROL_CHARS_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
15
+ const CONTROL_CHARS_RE = /[\x00-\x08\x0B-\x1F\x7F]/g;
13
16
  export function stripControlChars(text) {
14
17
  return text.replace(CONTROL_CHARS_RE, "");
15
18
  }
@@ -64,8 +67,8 @@ function sanitizeStyleLink(style) {
64
67
  * A style as a RichText keeps it: as given. A name stays a name because what
65
68
  * it stands for depends on the theme of the render that draws it, which no
66
69
  * RichText knows when the name arrives — the reference stores span styles the
67
- * same way. A string holds no link until it is parsed, so only a `Style` has
68
- * one to sanitize here.
70
+ * same way. A string holds no link until it is parsed, so only a `Style` —
71
+ * alone or laid over a name — has one to sanitize here.
69
72
  *
70
73
  * [LAW:single-enforcer] Nor does a RichText keep an anchor (`./anchor.ts`). It
71
74
  * lays its text out anew, so a stamp naming where a cell sat in the output it
@@ -73,8 +76,13 @@ function sanitizeStyleLink(style) {
73
76
  * owner that renders the RichText is the one that stamps its cells.
74
77
  */
75
78
  function admitStyle(style) {
76
- if (!(style instanceof Style))
79
+ if (typeof style === "string")
77
80
  return style;
81
+ if (style instanceof Style)
82
+ return admitDefined(style);
83
+ return { under: style.under, over: admitDefined(style.over) };
84
+ }
85
+ function admitDefined(style) {
78
86
  return sanitizeStyleLink(style.anchor ? style.withAnchor(undefined) : style);
79
87
  }
80
88
  /** A style that adds nothing: the empty definition, or a null `Style`. */
@@ -82,13 +90,16 @@ function isEmptyStyle(style) {
82
90
  return style instanceof Style ? style.isNull : style === "";
83
91
  }
84
92
  /**
85
- * The style a stored `string | Style` stands for in this render.
93
+ * The style a stored `TextStyle` stands for in this render.
86
94
  *
87
95
  * A parsed string can carry a link, so the result is sanitized on the way out
88
96
  * as well.
89
97
  */
90
- function resolveStyle(options, style) {
91
- return sanitizeStyleLink(style instanceof Style ? style : resolveDefinition(options, style));
98
+ export function resolveStyle(options, style) {
99
+ const resolved = style instanceof Style ? style
100
+ : typeof style === "string" ? resolveDefinition(options, style)
101
+ : resolveDefinition(options, style.under).add(style.over);
102
+ return sanitizeStyleLink(resolved);
92
103
  }
93
104
  /**
94
105
  * [LAW:single-enforcer] Styling is non-critical — an unrecognized style name
@@ -163,7 +174,7 @@ export class RichText {
163
174
  this._justify = options?.justify;
164
175
  this._overflow = options?.overflow;
165
176
  this._end = options?.end ?? "\n";
166
- this._tabSize = options?.tabSize ?? 8;
177
+ this._tabSize = parseTabSize(options?.tabSize ?? 8);
167
178
  this._noWrap = options?.noWrap ?? false;
168
179
  }
169
180
  // --- Properties ---
@@ -182,13 +193,21 @@ export class RichText {
182
193
  get length() {
183
194
  return this._text.length;
184
195
  }
196
+ /**
197
+ * The cells of the plain text with each tab widened to its stop counted from
198
+ * this text's own column 0, as `render` widens it — so it is the drawn width
199
+ * of a one-line text set at a stop. A deliberate departure from Rich, whose
200
+ * `Text.cell_len` counts a tab as no cells, so everything sized by it — a
201
+ * truncation, an alignment, a header's remaining width — decided on a width
202
+ * no render draws.
203
+ */
185
204
  get cellLength() {
186
- return cellLen(this._text);
205
+ return cellLen(expandTabs(this._text, this._tabSize).text);
187
206
  }
188
207
  get hasContent() {
189
208
  return this._text.length > 0;
190
209
  }
191
- /** The base style every span layers over: a `Style`, or a name resolved at render. */
210
+ /** The base style every span layers over, resolved at render unless it is a `Style`. */
192
211
  get style() {
193
212
  return this._style;
194
213
  }
@@ -458,6 +477,17 @@ export class RichText {
458
477
  this._text = padding + this._text + padding;
459
478
  return this;
460
479
  }
480
+ /**
481
+ * Each tab widened to the next tab stop, in place, as Rich's
482
+ * `Text.expand_tabs` widens it: spans move with the characters they style, so
483
+ * the text measures what it will draw.
484
+ */
485
+ expandTabs() {
486
+ const expanded = expandTabs(this._text, this._tabSize);
487
+ this._spans = this._spans.map((s) => new Span(expanded.offsetOf(s.start), expanded.offsetOf(s.end), s.style));
488
+ this._text = expanded.text;
489
+ return this;
490
+ }
461
491
  padLeft(count, char) {
462
492
  const c = char ?? " ";
463
493
  this._spans = this._spans.map((s) => s.move(count));
@@ -506,6 +536,10 @@ export class RichText {
506
536
  * styling; the marker (if any) is inserted as plain text with no span.
507
537
  * Use `stylize(...)` on the result to color the marker if needed.
508
538
  *
539
+ * Text that fits comes back untouched. Text that is cut has its tabs widened
540
+ * to their stops first, as `expandTabs` widens them, so the width cut to is
541
+ * the width drawn.
542
+ *
509
543
  * [LAW:dataflow-not-control-flow] mode/marker/width all flow as values;
510
544
  * the walk is the same shape regardless. No "if truncated then rebuild"
511
545
  * branch \u2014 the unchanged path just early-returns when content fits.
@@ -513,6 +547,9 @@ export class RichText {
513
547
  truncate(width, options) {
514
548
  if (this.cellLength <= width)
515
549
  return this;
550
+ // [LAW:one-source-of-truth] Cut what is drawn: once a tab is spaces, a cell
551
+ // of the plain text is a cell of the output, so the cut lands where it looks.
552
+ this.expandTabs();
516
553
  const mode = options?.mode ?? "right";
517
554
  // [LAW:dataflow-not-control-flow] the marker is fitted to the width as a
518
555
  // value, so width 0 and a marker wider than the width run the same walk
@@ -592,6 +629,9 @@ export class RichText {
592
629
  const currentWidth = this.cellLength;
593
630
  if (currentWidth >= width)
594
631
  return this;
632
+ // Padded to `width`, the text claims that width only while its tab stops
633
+ // hold: widened now, padding on either side cannot move them.
634
+ this.expandTabs();
595
635
  const gap = width - currentWidth;
596
636
  switch (justify) {
597
637
  case "left":
@@ -670,9 +710,17 @@ export class RichText {
670
710
  }
671
711
  // --- Renderable ---
672
712
  *render(options) {
673
- const text = this._expandTabs(this._text);
713
+ const expanded = expandTabs(this._text, this._tabSize);
674
714
  const base = this.resolvedStyle(options);
675
- const allSegments = this._buildSegments(text, base, options);
715
+ const { segments: allSegments, covered } = this._buildSegments(expanded, base, options);
716
+ const spanFree = spanFreeLines(allSegments, covered);
717
+ // Rich wraps the lines of `split(allow_blank=True)`: n line breaks make
718
+ // n + 1 lines, the last one blank when the text ends in a break, and that
719
+ // blank line is justified like any other. `Segment.splitLines` is Rich's
720
+ // `Segment.split_lines`, which drops a trailing empty line, so the text is
721
+ // handed to it closed by one more break — the dropped line is then always
722
+ // that one, and every line the text has comes back.
723
+ allSegments.push(Segment.line());
676
724
  const logicalLines = Segment.splitLines(allSegments);
677
725
  // [LAW:single-enforcer] The one crossing for this renderable's width, and
678
726
  // the call every other renderable already makes. A bare `cellCount` stood
@@ -703,16 +751,19 @@ export class RichText {
703
751
  const justify = overflow === "ignore" ? undefined : this._justify ?? options.justify;
704
752
  // One cell either way, so the marker's stand-in leaves the cut where it was.
705
753
  const ellipsis = drawable(options, "\u2026", ".");
706
- const endsWithNewline = text.endsWith("\n");
707
754
  for (let index = 0; index < logicalLines.length; index += 1) {
708
755
  const line = logicalLines[index];
709
- const terminateLine = index < logicalLines.length - 1 || endsWithNewline;
756
+ const free = spanFree[index];
757
+ const terminateLine = index < logicalLines.length - 1;
710
758
  // Wrap first, overflow last — the reference's order, and the reason a
711
759
  // long sentence grows a table row while an unbreakable word in the same
712
760
  // column still ellipsizes.
713
761
  const cuts = divideLine(plainOf(line), wrapAt, { fold: overflow === "fold" });
714
762
  const wrapped = Segment.divide(line, cuts);
715
- const placed = this._justifyLines(wrapped.map((piece) => [...this._fitLine(piece, edge, overflow, ellipsis)]), maxWidth, base, justify);
763
+ const placed = this._justifyLines(wrapped.map((piece, at) => ({
764
+ segments: [...this._fitLine(piece, edge, overflow, ellipsis)],
765
+ free: (cell) => free(at === 0 ? cell : cuts[at - 1] + cell),
766
+ })), maxWidth, base, justify);
716
767
  for (let piece = 0; piece < placed.length; piece += 1) {
717
768
  yield* placed[piece];
718
769
  if (piece < placed.length - 1 || terminateLine) {
@@ -724,29 +775,23 @@ export class RichText {
724
775
  // non-empty text alike — the pre-fix code checked it once for empty text
725
776
  // (truthy) and again for non-empty text (truthy and not the default
726
777
  // "\n"), which gave `end` two meanings depending on whether the text was
727
- // empty (rich-text-5ai). Empty text takes zero iterations of the loop
728
- // above, so this is reached either way with nothing yielded but this.
778
+ // empty (rich-text-5ai).
729
779
  if (this._end) {
730
780
  yield new Segment(this._end);
731
781
  }
732
782
  }
733
783
  // --- Measurable ---
734
784
  measure(options) {
735
- const text = this._expandTabs(this._text);
736
- const lines = text.split("\n");
737
- let maxLineWidth = 0;
738
- let maxWordWidth = 0;
739
- for (const line of lines) {
740
- const lineWidth = cellLen(line);
741
- maxLineWidth = Math.max(maxLineWidth, lineWidth);
742
- // Minimum is the longest word
743
- const words = line.split(/\s+/);
744
- for (const word of words) {
745
- if (word.length > 0) {
746
- maxWordWidth = Math.max(maxWordWidth, cellLen(word));
747
- }
748
- }
749
- }
785
+ const lines = expandTabs(this._text, this._tabSize).text.split("\n");
786
+ const maxLineWidth = lines.reduce((widest, line) => Math.max(widest, cellLen(line)), 0);
787
+ // [LAW:one-source-of-truth] The minimum is the widest piece the wrapper
788
+ // never breaks, asked of the wrapper — so a line's indent, which wraps
789
+ // with its first word, counts toward it. Rich splits on whitespace here
790
+ // and drops the indent, then wraps with it kept: a container sized by
791
+ // that minimum cuts the word it was meant to hold whole. Text with no word
792
+ // at all is all whitespace, and as in Rich its whole width is the minimum.
793
+ const widths = lines.flatMap(wordWidths);
794
+ const maxWordWidth = widths.length > 0 ? widths.reduce((widest, w) => Math.max(widest, w), 0) : maxLineWidth;
750
795
  // Parsed for the same reason `render` parses it: an unparsed NaN ceiling
751
796
  // makes both `Math.min` calls NaN, and a range of NaN..NaN is one no parent
752
797
  // layout can divide.
@@ -757,11 +802,6 @@ export class RichText {
757
802
  };
758
803
  }
759
804
  // --- Internal ---
760
- _expandTabs(text) {
761
- if (!text.includes("\t"))
762
- return text;
763
- return text.replace(/\t/g, " ".repeat(this._tabSize));
764
- }
765
805
  /**
766
806
  * The rendered text cut at every span edge, each piece carrying the base
767
807
  * style plus every span covering it.
@@ -787,27 +827,41 @@ export class RichText {
787
827
  * always did, and then folds into no piece at all: its range comes out empty
788
828
  * and no case handles it.
789
829
  */
790
- _buildSegments(text, base, options) {
791
- const clamp = (offset) => Math.max(0, Math.min(offset, text.length));
792
- const positions = new Set([0, text.length]);
830
+ _buildSegments({ text, offsetOf }, base, options) {
831
+ // A span is written against the text as given; `offsetOf` moves its edges
832
+ // past every tab that widened before them.
833
+ const clamp = (offset) => Math.max(0, Math.min(offsetOf(offset), text.length));
834
+ // The text's own start and end are always the outer boundaries, even when
835
+ // they are one offset: empty text is one empty piece, as Rich's
836
+ // `Text.render` yields `Segment("")` for it. That piece writes no bytes,
837
+ // and it is what makes empty text one line to everything that splits a
838
+ // render into lines — a panel's body, a table cell — where Rich draws one.
839
+ const inner = new Set();
793
840
  for (const span of this._spans) {
794
- positions.add(clamp(span.start));
795
- positions.add(clamp(span.end));
841
+ inner.add(clamp(span.start));
842
+ inner.add(clamp(span.end));
796
843
  }
797
- const boundaries = [...positions].sort((a, b) => a - b);
844
+ inner.delete(0);
845
+ inner.delete(text.length);
846
+ const boundaries = [0, ...[...inner].sort((a, b) => a - b), text.length];
798
847
  // Every span edge is a boundary, so where a span's range opens is a lookup
799
- // rather than a search.
800
- const pieceAt = new Map(boundaries.map((position, piece) => [position, piece]));
848
+ // rather than a search. Where two boundaries share an offset — empty
849
+ // text's start and end — the first wins, so the offset names the piece
850
+ // that opens there rather than the one past the last.
851
+ const pieceAt = new Map(boundaries.map((position, piece) => [position, piece]).reverse());
801
852
  const styles = boundaries.slice(0, -1).map(() => base);
853
+ const covered = styles.map(() => false);
802
854
  for (const span of this._spans) {
803
855
  const end = clamp(span.end);
804
856
  const style = resolveStyle(options, span.style);
805
857
  const opensAt = pieceAt.get(clamp(span.start));
806
858
  for (let piece = opensAt; boundaries[piece] < end; piece++) {
807
859
  styles[piece] = styles[piece].add(style);
860
+ covered[piece] = true;
808
861
  }
809
862
  }
810
- return styles.map((style, piece) => new Segment(text.slice(boundaries[piece], boundaries[piece + 1]), style.isNull ? undefined : style));
863
+ const segments = styles.map((style, piece) => new Segment(text.slice(boundaries[piece], boundaries[piece + 1]), style.isNull ? undefined : style));
864
+ return { segments, covered };
811
865
  }
812
866
  /**
813
867
  * The pieces one logical line wrapped into, each placed in a canvas
@@ -823,9 +877,9 @@ export class RichText {
823
877
  */
824
878
  _justifyLines(lines, maxWidth, base, justify) {
825
879
  if (justify !== "full") {
826
- return lines.map((line) => [...this._justifyLine(line, maxWidth, justify)]);
880
+ return lines.map((line) => [...this._justifyLine(line, maxWidth, base, justify)]);
827
881
  }
828
- return lines.map((line, index) => index === lines.length - 1 ? line : this._fillLine(line, maxWidth, base));
882
+ return lines.map(({ segments }, index) => index === lines.length - 1 ? segments : this._fillLine(segments, maxWidth, base));
829
883
  }
830
884
  /**
831
885
  * One line placed in a canvas `maxWidth` wide.
@@ -840,29 +894,28 @@ export class RichText {
840
894
  * `undefined` is not `"left"`: it is Rich's `"default"`, which places the
841
895
  * line without padding it at all. That distinction is what lets a soft-wrapped
842
896
  * `Console.print` leave its lines at their natural width.
897
+ *
898
+ * The padding is drawn in the text's base style, as the reference pads
899
+ * inside the `Text` it justifies: a title "on red" fills its row in red.
843
900
  */
844
- *_justifyLine(line, maxWidth,
901
+ *_justifyLine({ segments: line, free }, maxWidth, base,
845
902
  // [LAW:types-are-the-program] `full` is absent rather than ignored: it
846
903
  // needs the lines either side of this one, so the type refuses it here
847
904
  // instead of a branch quietly rendering it as `left`, which is the bug
848
905
  // this signature replaces (rich-justify-0cr.1).
849
906
  justify) {
907
+ const padStyle = base.isNull ? undefined : base;
850
908
  switch (justify) {
851
909
  case "center":
852
910
  case "right": {
853
911
  const body = Segment.adjustLineLength(line, Segment.getLineLength(line) - hangingWhitespace(line), undefined, false);
854
912
  const gap = Math.max(maxWidth - Segment.getLineLength(body), 0);
855
913
  const leftPad = justify === "center" ? Math.floor(gap / 2) : gap;
856
- if (leftPad > 0)
857
- yield new Segment(" ".repeat(leftPad));
858
- yield* body;
859
- const rightPad = gap - leftPad;
860
- if (rightPad > 0)
861
- yield new Segment(" ".repeat(rightPad));
914
+ yield* padded(body, free, leftPad, gap - leftPad, padStyle);
862
915
  break;
863
916
  }
864
917
  case "left":
865
- yield* Segment.adjustLineLength(line, Math.max(maxWidth, Segment.getLineLength(line)));
918
+ yield* padded(line, free, 0, Math.max(maxWidth - Segment.getLineLength(line), 0), padStyle);
866
919
  break;
867
920
  default:
868
921
  yield* line;
@@ -974,3 +1027,71 @@ export class RichText {
974
1027
  yield* Segment.adjustLineLength(line, maxWidth, undefined, false);
975
1028
  }
976
1029
  }
1030
+ /**
1031
+ * For each logical line of a render, whether the cell at an offset is covered
1032
+ * by no span. `covered` is `_buildSegments`'s answer per piece; a piece split
1033
+ * by a line break answers for both halves.
1034
+ */
1035
+ function spanFreeLines(segments, covered) {
1036
+ // Per line, the cell each run ends at and whether that run is span-free.
1037
+ const lines = [[]];
1038
+ segments.forEach((segment, piece) => {
1039
+ segment.text.split("\n").forEach((part, at) => {
1040
+ if (at > 0)
1041
+ lines.push([]);
1042
+ const runs = lines.at(-1);
1043
+ runs.push({ end: (runs.at(-1)?.end ?? 0) + cellLen(part), free: !covered[piece] });
1044
+ });
1045
+ });
1046
+ return lines.map((runs) => (cell) => {
1047
+ let lo = 0;
1048
+ let hi = runs.length;
1049
+ while (lo < hi) {
1050
+ const mid = (lo + hi) >> 1;
1051
+ if (runs[mid].end <= cell)
1052
+ lo = mid + 1;
1053
+ else
1054
+ hi = mid;
1055
+ }
1056
+ return runs[lo].free;
1057
+ });
1058
+ }
1059
+ /**
1060
+ * A line with `left` and `right` cells of padding in `style` either side.
1061
+ *
1062
+ * The reference pads inside the `Text` it justifies and cuts its output at
1063
+ * span edges, so a pad and the run of the line beside it that no span covers
1064
+ * are one piece, and Rich emits them as one segment; a run a span covers stays
1065
+ * its own segment even when its style draws the same as the pad
1066
+ * (rich-table-qj6i). A span-free run is drawn in `style` alone, which is what
1067
+ * makes the joined segment's style exact.
1068
+ */
1069
+ function* padded(drawn, free, left, right, style) {
1070
+ if (left + right === 0)
1071
+ return yield* drawn;
1072
+ // An empty piece writes no bytes, and at an edge it would take the join that
1073
+ // belongs to the run behind it.
1074
+ const line = drawn.filter((segment) => segment.text !== "");
1075
+ const pad = (cells) => " ".repeat(cells);
1076
+ const width = Segment.getLineLength(line);
1077
+ // A line with no cells has no run to join; the pads alone are its one piece.
1078
+ if (width === 0)
1079
+ return yield new Segment(pad(left + right), style);
1080
+ const last = line.length - 1;
1081
+ // A zero-width segment has no cell of its own to ask about, so it never
1082
+ // joins: merged on its neighbour's answer it would lose its own style.
1083
+ const head = left > 0 && line[0].cellLength > 0 && free(0);
1084
+ const tail = right > 0 && line[last].cellLength > 0 && free(width - 1);
1085
+ const from = head ? 1 : 0;
1086
+ const to = tail ? last : last + 1;
1087
+ // One run joined on both sides: the pads and it are one piece.
1088
+ if (from > to) {
1089
+ yield new Segment(pad(left) + line[0].text + pad(right), style);
1090
+ return;
1091
+ }
1092
+ if (left > 0)
1093
+ yield new Segment(pad(left) + (head ? line[0].text : ""), style);
1094
+ yield* line.slice(from, to);
1095
+ if (right > 0)
1096
+ yield new Segment((tail ? line[last].text : "") + pad(right), style);
1097
+ }
@@ -20,6 +20,11 @@
20
20
  * same cell still ellipsizes.
21
21
  */
22
22
  import { type CellCol } from "./cells.js";
23
+ /**
24
+ * The cells each word of `text` needs, in order — the widest is the narrowest
25
+ * width `divideLine` can wrap it to without cutting a word.
26
+ */
27
+ export declare function wordWidths(text: string): number[];
23
28
  /**
24
29
  * The cell offsets `text` should be cut at to wrap it into lines of at most
25
30
  * `width` cells, for `Segment.divide` to apply to the styled line.
package/dist/core/wrap.js CHANGED
@@ -39,6 +39,21 @@ function* words(text) {
39
39
  yield match[0];
40
40
  }
41
41
  }
42
+ /**
43
+ * The cells a word needs on a line: all of it but its trailing whitespace,
44
+ * which may hang past the edge for the caller to crop. Its leading whitespace
45
+ * counts — that is a line's indent, and it is not a place to break.
46
+ */
47
+ function widthOf(word) {
48
+ return cellLen(word.trimEnd());
49
+ }
50
+ /**
51
+ * The cells each word of `text` needs, in order — the widest is the narrowest
52
+ * width `divideLine` can wrap it to without cutting a word.
53
+ */
54
+ export function wordWidths(text) {
55
+ return Array.from(words(text), widthOf);
56
+ }
42
57
  /**
43
58
  * A word chopped into pieces of at most `width` cells, as the reference chops
44
59
  * it.
@@ -92,9 +107,7 @@ export function divideLine(text, width, options) {
92
107
  let used = 0; // cells already committed to the line being filled
93
108
  let wordStart = 0; // absolute cell offset where the current word begins
94
109
  for (const word of words(text)) {
95
- // Measured without its trailing whitespace: a word is allowed to end the
96
- // line with its spaces hanging past the edge, and the caller crops them.
97
- const wordWidth = cellLen(word.trimEnd());
110
+ const wordWidth = widthOf(word);
98
111
  if (width - used >= wordWidth) {
99
112
  used += cellLen(word);
100
113
  }
@@ -23,56 +23,100 @@
23
23
  * frame's encoding. What differs per host — whether the program can end
24
24
  * under us, how it is suspended — is the `TerminalHost`'s, so the same app
25
25
  * runs in node and in a browser with no path of its own for either.
26
+ *
27
+ * [LAW:single-enforcer] Input has one reader: the app subscribes to its
28
+ * host's bytes once and parses them (`host/input`), so a program hears keys
29
+ * and the pointer as events and never decodes a byte. The quit keys are
30
+ * checked here, before any handler hears the key, so nothing a program binds
31
+ * — a focused field in a `WidgetApp` included — can swallow them.
26
32
  */
33
+ import { type Clock, type FrameRate } from "../core/clock.js";
34
+ import { type ConsoleOptions } from "../core/console.js";
27
35
  import { Segment } from "../core/segment.js";
36
+ import { type Surface } from "../core/paint.js";
28
37
  import { type Renderable } from "../core/protocol.js";
29
38
  import type { Unsubscribe } from "../core/subscription.js";
39
+ import { type KeyPress, type ScreenMouseEvent } from "./input.js";
30
40
  import type { TerminalHost } from "./terminal-host.js";
31
- /**
32
- * Where the frame is painted. `alternate` is the whole terminal, in the
33
- * alternate screen buffer, so the rows the program printed before it are
34
- * there again when it stops; the terminal reports the pointer on it. `inline`
35
- * starts at the cursor's line, as tall as the frame, and stays on the
36
- * terminal when the app stops. It gets no pointer events: the terminal
37
- * reports a pointer by its row on the screen, and the app does not know
38
- * which row of the screen its frame starts on.
39
- */
40
- export type Surface = "alternate" | "inline";
41
41
  /**
42
42
  * Where an app is in its life. `idle` has not run; `running` holds the
43
43
  * terminal and paints; `suspended` has handed it back until the host resumes
44
44
  * the program; `stopped` has handed it back for good.
45
45
  */
46
46
  export type AppPhase = "idle" | "running" | "suspended" | "stopped";
47
- export interface AppOptions {
47
+ declare const DRAW_OPTIONS: readonly ["asciiOnly", "theme", "onStyleError", "markup", "highlight", "highlighter"];
48
+ type DrawOption = (typeof DRAW_OPTIONS)[number];
49
+ type DrawOptions = Readonly<Pick<ConsoleOptions, DrawOption>>;
50
+ export interface AppOptions extends DrawOptions {
48
51
  /** The terminal the app runs on. The app starts and stops it. */
49
52
  readonly host: TerminalHost;
50
53
  readonly surface: Surface;
51
54
  /**
52
55
  * The app's root renderable, asked for once per frame, so a view of the
53
- * app's state is drawn from the state as it is at that frame.
56
+ * app's state is drawn from the state as it is at that frame. `t` is the
57
+ * frame's time in seconds on the app's `clock`, which is what an effect is
58
+ * sampled at.
54
59
  */
55
- readonly view: () => Renderable;
60
+ readonly view: (t: number) => Renderable;
61
+ /** What the app reads the time from and ticks on. The platform's, unless given. */
62
+ readonly clock?: Clock;
63
+ /** How often the app paints while something is animating. 30 frames a second, unless given. */
64
+ readonly rate?: FrameRate;
56
65
  /**
57
- * The terminal can draw only ASCII, so every frame draws its boxes, guides
58
- * and widget marks with ASCII characters. Default false.
66
+ * The keys that stop the app, by `keyName`, heard before any `onKey`
67
+ * handler. `["ctrl+c"]` unless given; `[]` leaves every key to the program.
68
+ * A name no key goes by throws here.
59
69
  */
60
- readonly asciiOnly?: boolean;
70
+ readonly quitKeys?: readonly string[];
71
+ }
72
+ /**
73
+ * A turn of the wheel, where the pointer was when it turned: `lines` is
74
+ * positive towards the end of the content, a line a notch.
75
+ */
76
+ export interface Wheel {
77
+ readonly x: number;
78
+ readonly y: number;
79
+ readonly lines: number;
61
80
  }
81
+ declare const WHEEL_LINES: {
82
+ readonly scroll_up: -1;
83
+ readonly scroll_down: 1;
84
+ };
85
+ type WheelTurn = Extract<ScreenMouseEvent, {
86
+ type: keyof typeof WHEEL_LINES;
87
+ }>;
88
+ /** A pointer event `onMouse` hears: every one but the vertical wheel, which is `onWheel`'s. */
89
+ export type AppMouseEvent = Exclude<ScreenMouseEvent, WheelTurn>;
62
90
  export declare class App {
63
91
  private readonly host;
64
92
  private readonly surface;
65
93
  private readonly view;
94
+ private readonly clock;
95
+ private _rate;
96
+ private readonly animations;
97
+ private ticking;
66
98
  private readonly console;
67
99
  private _phase;
68
100
  private _frame;
69
- private rows;
101
+ private readonly painter;
70
102
  private refreshQueued;
71
103
  private subscriptions;
72
104
  private readonly paintHandlers;
105
+ private readonly keyHandlers;
106
+ private readonly mouseHandlers;
107
+ private readonly wheelHandlers;
108
+ private readonly quitKeys;
109
+ private readonly input;
73
110
  private settle;
74
111
  constructor(options: AppOptions);
75
112
  get phase(): AppPhase;
113
+ /** How often the app paints while something is animating. */
114
+ get rate(): FrameRate;
115
+ /**
116
+ * Set how often the app paints: an animation under way goes on at the new
117
+ * rate from its next frame, without a frame lost or one painted twice.
118
+ */
119
+ set rate(rate: FrameRate);
76
120
  /** The rows on screen now, as painted: what a pointer event lands on. */
77
121
  get frame(): readonly (readonly Segment[])[];
78
122
  /**
@@ -93,6 +137,15 @@ export declare class App {
93
137
  * for as long as it ran.
94
138
  */
95
139
  refresh(): void;
140
+ /**
141
+ * Paint at the app's `rate` until the returned function is called: what an
142
+ * effect sampled at the frame's time asks for while it moves. The app ticks
143
+ * while anything is animating and it holds the terminal, and stops when
144
+ * nothing is, so a still app paints only when asked. The animation's first
145
+ * frame does not wait out an interval: at one frame every two seconds, that
146
+ * would hold its start off screen for two.
147
+ */
148
+ animate(): Unsubscribe;
96
149
  /**
97
150
  * Hand the terminal to whatever launched the program, and take it back and
98
151
  * repaint when that resumes it. What sends the app here is the app's own —
@@ -104,6 +157,26 @@ export declare class App {
104
157
  * handler that throws fails the app as a frame that throws does.
105
158
  */
106
159
  onPaint(handler: (frame: readonly (readonly Segment[])[]) => void): Unsubscribe;
160
+ /**
161
+ * Hear every key the app does not quit on, by the `KeyPress` the terminal
162
+ * reported; `keyName` names it. A handler that throws fails the app.
163
+ */
164
+ onKey(handler: (press: KeyPress) => void): Unsubscribe;
165
+ /**
166
+ * Hear every pointer event but the vertical wheel, which is `onWheel`'s,
167
+ * in terminal cells. Only the alternate surface reports the pointer. A
168
+ * handler that throws fails the app.
169
+ */
170
+ onMouse(handler: (event: AppMouseEvent) => void): Unsubscribe;
171
+ /**
172
+ * Offer every wheel turn to `handler`, which returns whether it moved
173
+ * something. A turn no handler moves anything with is passed outward to the
174
+ * host (`TerminalHost.passWheel`), so the page or terminal around the app
175
+ * scrolls instead. Handlers are offered a turn in the order they subscribed,
176
+ * and the first that moves something takes it. A handler that throws fails
177
+ * the app.
178
+ */
179
+ onWheel(handler: (wheel: Wheel) => boolean): Unsubscribe;
107
180
  /** Hand the terminal back for good; `run` resolves. */
108
181
  stop(): void;
109
182
  /**
@@ -116,6 +189,11 @@ export declare class App {
116
189
  private enter;
117
190
  private leave;
118
191
  private end;
192
+ private key;
193
+ private mouse;
194
+ private hear;
195
+ private tick;
119
196
  private paint;
120
197
  private draw;
121
198
  }
199
+ export {};