@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/README.md CHANGED
@@ -342,7 +342,7 @@ console.print(new Greeting());
342
342
 
343
343
  ```typescript
344
344
  const console = new Console({
345
- colorSystem: "truecolor", // null | "auto" | "standard" | "256" | "truecolor" | "windows"
345
+ colorSystem: "truecolor", // null | "auto" | "none" | "ansi" | "256" | "truecolor"
346
346
  width: 120, // override terminal width
347
347
  stderr: true, // write to stderr
348
348
  record: true, // record output for export
@@ -354,14 +354,15 @@ const console = new Console({
354
354
  When `record: true`, export output after the fact:
355
355
 
356
356
  ```typescript
357
- import { saveHtml } from "@promptctl/rich-js/node/save";
357
+ import { SOLARIZED_LIGHT, SVG_EXPORT_THEME } from "@promptctl/rich-js";
358
+ import { saveSvg } from "@promptctl/rich-js/node/save";
358
359
 
359
- const text = console.exportText();
360
- const html = console.exportHtml();
361
- saveHtml(console, "output.html");
360
+ const text = console.exportText({ clear: false });
361
+ const html = console.exportHtml({ theme: SOLARIZED_LIGHT, clear: false });
362
+ saveSvg(console, "output.svg", { theme: SVG_EXPORT_THEME });
362
363
  ```
363
364
 
364
- `saveText` / `saveHtml` live on the `node/save` subpath because they import `node:fs`; the main barrel stays browser-safe.
365
+ `saveText`, `saveHtml` and `saveSvg` live on the `node/save` subpath because they import `node:fs`; the main barrel stays browser-safe. The [console guide](https://brandon-fryslie.github.io/rich-js/master/console#exporting) covers what each export draws, the themes it takes, and when it clears the recording.
365
366
 
366
367
  ## Demos
367
368
 
@@ -384,10 +385,12 @@ npm run demo
384
385
  | `npm run demo-inputs` | rich-config | The widgets — checkbox, toggle, slider, dropdown, text input, button — driving a theme and palette viewer. |
385
386
  | `npm run demo:dropdown` | dropdown-demo | Three `Dropdown` widgets: a plain one, one filtered as you type, and one whose options change every few seconds. |
386
387
  | `npm run template-bindings` | rich-template-bindings | Type a template on the left and watch it render on the right. |
387
- | `npm run themes-and-color-studio` | themes-and-color-studio | A printed tour of colours, palettes, bundled themes, and contrast. Prints once and exits; set `EXPORT_HTML=out.html` to save it as HTML too. |
388
+ | `npm run themes-and-color-studio` | themes-and-color-studio | A printed tour of colours, palettes, bundled themes, and contrast. Prints once and exits; set any of `EXPORT_HTML=out.html`, `EXPORT_SVG=out.svg` and `EXPORT_TEXT=out.txt` to also save it to each file named. |
388
389
  | `npm run strip` | rich-strip | Every built-in `Joiner`, printed side by side. Prints once and exits. |
390
+ | `npm run effects-feel` | effects-feel | Shimmer, a gentle pulse on chosen elements, sparkle, a hue wheel, fade-in and a dissolve-out that eddies back before it goes, on a powerline strip and a run of text, at a colour depth you choose, with keys to step the frame rate, the demo's pace and the theme while it runs. A demo for agreeing how the effects feel; `npm run effects-feel -- --help` lists the flags and keys. |
389
391
  | `npm run markup-plugins` | rich-markup-plugins | Custom markup tags registered through `MarkupRegistry`. Prints once and exits. |
390
- | `npm run viewport` | rich-viewport | A cursor walking a list taller than its window, kept in view by `Viewport.ensureVisible`. |
392
+ | `npm run viewport` | rich-viewport | A selection you move through a list taller than its window with the arrow keys, `j`/`k`, PgUp/PgDn and Home/End, kept in view by `Viewport.ensureVisible`; the mouse wheel scrolls the window, and `q` quits. |
393
+ | `npm run terminal-host` | terminal-host | Each key you press printed as the bytes the terminal sends for it, and each resize as the new size — a program written against `TerminalHost` alone. `examples/terminal-host/embed.ts` runs the same program on an xterm.js terminal in your own page. |
391
394
 
392
395
  `Ctrl-C` quits any demo that stays running.
393
396
 
@@ -117,7 +117,9 @@ export declare class Box {
117
117
  plainHeaded(): Box;
118
118
  /**
119
119
  * [LAW:dataflow-not-control-flow] Every full-width rule the box can draw is
120
- * this one loop; which rule it is arrives as four characters, not a branch.
120
+ * this one join; which rule it is arrives as four characters, not a branch.
121
+ * The rule is one segment, as the reference's `get_top` is one string, so
122
+ * its bytes are one styled run.
121
123
  */
122
124
  private getEdge;
123
125
  private getRowChars;
package/dist/core/box.js CHANGED
@@ -173,21 +173,14 @@ export class Box {
173
173
  }
174
174
  /**
175
175
  * [LAW:dataflow-not-control-flow] Every full-width rule the box can draw is
176
- * this one loop; which rule it is arrives as four characters, not a branch.
176
+ * this one join; which rule it is arrives as four characters, not a branch.
177
+ * The rule is one segment, as the reference's `get_top` is one string, so
178
+ * its bytes are one styled run.
177
179
  */
178
180
  getEdge(widths, chars, style, edge) {
179
- const segments = [];
180
- if (edge)
181
- segments.push(new Segment(chars.left, style));
182
- for (let i = 0; i < widths.length; i++) {
183
- if (i > 0)
184
- segments.push(new Segment(chars.cross, style));
185
- segments.push(new Segment(chars.horizontal.repeat(widths[i]), style));
186
- }
187
- if (edge)
188
- segments.push(new Segment(chars.right, style));
189
- segments.push(Segment.line());
190
- return segments;
181
+ const inner = widths.map((width) => chars.horizontal.repeat(width)).join(chars.cross);
182
+ const rule = edge ? chars.left + inner + chars.right : inner;
183
+ return [new Segment(rule, style), Segment.line()];
191
184
  }
192
185
  getRowChars(level) {
193
186
  switch (level) {
@@ -66,8 +66,19 @@ export declare function setCellSize(text: string, totalWidth: CellCol): string;
66
66
  * Splits text at a cell position. Returns [left, right].
67
67
  * When the position falls mid-wide-character, the left side is padded
68
68
  * to reach exactly `position` cells. The wide char remains in the right side.
69
+ *
70
+ * [LAW:one-source-of-truth] The left side is `cellFit`'s, so the cut falls
71
+ * between the grapheme clusters `cellLen` measures and never inside one.
69
72
  */
70
73
  export declare function splitText(text: string, position: CellCol): [string, string];
74
+ /**
75
+ * Cuts `text` into exactly `position` cells and the rest, as the reference's
76
+ * `Segment.split_cells` does: a wide glyph the cut goes through fits neither
77
+ * side, so it leaves a space in each of its cells, where `splitText` keeps it
78
+ * whole on the right. Every other character lands on exactly one side.
79
+ * `position` must fall inside `text`, between 0 and its width.
80
+ */
81
+ export declare function cutCells(text: string, position: CellCol): [string, string];
71
82
  /**
72
83
  * Wraps text into lines of at most `maxWidth` cells, preserving every code
73
84
  * point: `chopCells(t, w).join("") === t`. Unlike `splitText` this never pads,
@@ -79,6 +90,12 @@ export declare function splitText(text: string, position: CellCol): [string, str
79
90
  * or less is no budget at all, so the text comes back as one line.
80
91
  */
81
92
  export declare function chopCells(text: string, maxWidth: CellCol): string[];
93
+ /**
94
+ * `line` split at each of `cuts`, in one walk over it. Converting each cut to a
95
+ * code-unit offset on its own rescans the line from the start every time, which
96
+ * is quadratic in a long enough string.
97
+ */
98
+ export declare function splitAtCells(line: string, cuts: readonly CellCol[]): string[];
82
99
  /**
83
100
  * The grapheme clusters of `text`, in order: the unit `string-width` measures,
84
101
  * and so the unit `cellLen` of a whole string is the sum of. Summing `cellLen`
@@ -105,8 +122,8 @@ export declare function cellFitEnd(text: string, cap: CellCol): string;
105
122
  * offset without slicing the tail, avoiding O(N²) allocation when called
106
123
  * repeatedly across a long string.
107
124
  *
108
- * [LAW:types-are-the-program] returns CodePoint because for...of always
109
- * stops on a code-point boundary.
125
+ * [LAW:types-are-the-program] returns CodePoint because it stops between
126
+ * grapheme clusters, and every cluster boundary is a code-point boundary.
110
127
  */
111
128
  export declare function cellFitFrom(text: string, startCU: CodePoint, cap: CellCol): CodePoint;
112
129
  /**
@@ -123,37 +140,53 @@ export declare function cellFitFrom(text: string, startCU: CodePoint, cap: CellC
123
140
  */
124
141
  export declare function cellStepFrom(text: string, startCU: CodePoint, cap: CellCol): CodePoint;
125
142
  /**
126
- * Returns the largest code-unit offset into `content` whose prefix has
127
- * cell width ≤ `cellCol`. When `cellCol` falls mid-wide-character the
128
- * function stops before that character (never advances into it).
129
- * Clamps to `content.length` if `cellCol` exceeds the string's total
130
- * cell width.
131
- *
132
- * This is the inverse of `cellLen(content.slice(0, codeUnit))` — given a
133
- * visual column, return the corresponding string index.
134
- *
135
- * Returns `CodePoint` because `for...of` iteration always stops on a
136
- * code-point boundary.
143
+ * The code-unit offset a visual column falls at: the end of the largest
144
+ * prefix of `content` within `cellCol` cells, so a column inside a wide glyph
145
+ * lands before it. The inverse of `cellLen(content.slice(0, offset))`.
137
146
  */
138
147
  export declare function cellColToCodeUnitOffset(content: string, cellCol: CellCol): CodePoint;
139
148
  /**
140
- * Advance one full Unicode code point from `cu`, returning the code-unit
141
- * offset of the start of the *next* code point. Returns `s.length` when
142
- * already at or past the end.
143
- *
144
- * Handles surrogate pairs: when the code point at `cu` is a supplementary
145
- * character (U+10000…U+10FFFF) it occupies 2 UTF-16 code units, so the
146
- * returned offset advances by 2.
149
+ * The offset just past the grapheme cluster at `cu`: one step of a cursor, a
150
+ * Delete, a transposition, so `👍🏽` or `👨‍👩‍👧` moves and goes as the one
151
+ * glyph `cellLen` measures, never a code point at a time. `cu` is a cluster
152
+ * boundary in `s` (see `graphemeBoundary`); `s.length` gives `s.length`.
153
+ */
154
+ export declare function nextGrapheme(s: string, cu: CodePoint): CodePoint;
155
+ /** `nextGrapheme` backwards: the start of the cluster ending at `cu`; 0 at the start. */
156
+ export declare function prevGrapheme(s: string, cu: CodePoint): CodePoint;
157
+ /**
158
+ * Where an offset into `s` comes to rest as a cursor: itself when it is a
159
+ * cluster boundary, else the end of the cluster it falls inside, and never
160
+ * past `s.length`. The one parse from any number to the boundaries
161
+ * `nextGrapheme` and `prevGrapheme` step between.
162
+ */
163
+ export declare function graphemeBoundary(s: string, cu: CodeUnit): CodePoint;
164
+ declare const _tabSize: unique symbol;
165
+ /** Cells between tab stops: a positive integer, so every stop is a column. */
166
+ export type TabSize = number & {
167
+ readonly [_tabSize]: true;
168
+ };
169
+ /**
170
+ * `n` as a `TabSize`, or a `RangeError` naming it. Rich divides by the tab
171
+ * size and raises on 0; here the value is refused where it is given, not at
172
+ * the first tab it meets.
147
173
  */
148
- export declare function nextCodePoint(s: string, cu: CodeUnit): CodePoint;
174
+ export declare function parseTabSize(n: number): TabSize;
175
+ /** Text with its tabs expanded, and where each offset into the original went. */
176
+ export interface TabExpansion {
177
+ readonly text: string;
178
+ offsetOf(offset: number): number;
179
+ }
149
180
  /**
150
- * Step back one full Unicode code point from `cu`, returning the code-unit
151
- * offset of the start of the *previous* code point. Returns 0 when already
152
- * at the start.
181
+ * `text` with each tab widened to the next tab stop — a multiple of `tabSize`
182
+ * cells from the start of its line — as Rich's `Text.expand_tabs` widens it, and
183
+ * `offsetOf`, where each code-unit offset into `text` lands in the result.
153
184
  *
154
- * Handles surrogate pairs: when the code unit at `cu - 1` is a low surrogate
155
- * AND the code unit at `cu - 2` is a high surrogate, steps back 2 code units.
156
- * Unpaired surrogates are treated as 1-CU characters.
185
+ * A tab is a column position, not a run of spaces: one that already sits a
186
+ * cell short of a stop widens to a single space. `offsetOf` is there because a
187
+ * caller that annotates the text by offset — a `RichText`'s spans — has to move
188
+ * its offsets with the characters, or every annotation after a tab lands short
189
+ * of what it styled.
157
190
  */
158
- export declare function prevCodePoint(s: string, cu: CodeUnit): CodePoint;
191
+ export declare function expandTabs(text: string, tabSize: TabSize): TabExpansion;
159
192
  export {};
@@ -3,28 +3,17 @@
3
3
  * Handles ASCII, CJK (double-width), and emoji characters.
4
4
  */
5
5
  import stringWidth from "string-width";
6
+ import { Memo } from "./memo.js";
6
7
  // [LAW:one-source-of-truth] string-width is the single authority for cell width
7
- const cellLenCache = new Map();
8
- const CACHE_MAX = 4096;
8
+ const cellLenMemo = new Memo();
9
9
  /**
10
10
  * Returns the terminal cell width of a string.
11
11
  */
12
12
  export function cellLen(text) {
13
13
  if (text.length === 0)
14
14
  return 0;
15
- // Fast path for pure ASCII
16
- if (text.length <= 64) {
17
- const cached = cellLenCache.get(text);
18
- if (cached !== undefined)
19
- return cached;
20
- }
21
- const width = stringWidth(text);
22
- if (text.length <= 64) {
23
- if (cellLenCache.size >= CACHE_MAX)
24
- cellLenCache.clear();
25
- cellLenCache.set(text, width);
26
- }
27
- return width;
15
+ // Short strings recur (words, cells, labels); a long one is rarely measured twice.
16
+ return text.length <= 64 ? cellLenMemo.get(text, stringWidth) : stringWidth(text);
28
17
  }
29
18
  /** Brand a raw number as a CellCol. Use only at trust boundaries. */
30
19
  export function asCellCol(n) { return n; }
@@ -74,36 +63,41 @@ export function setCellSize(text, totalWidth) {
74
63
  if (currentWidth < totalWidth) {
75
64
  return text + " ".repeat(totalWidth - currentWidth);
76
65
  }
77
- return cropToWidth(text, totalWidth);
66
+ return splitText(text, totalWidth)[0];
78
67
  }
79
68
  /**
80
69
  * Splits text at a cell position. Returns [left, right].
81
70
  * When the position falls mid-wide-character, the left side is padded
82
71
  * to reach exactly `position` cells. The wide char remains in the right side.
72
+ *
73
+ * [LAW:one-source-of-truth] The left side is `cellFit`'s, so the cut falls
74
+ * between the grapheme clusters `cellLen` measures and never inside one.
83
75
  */
84
76
  export function splitText(text, position) {
85
77
  if (position <= 0)
86
78
  return ["", text];
87
- const totalWidth = cellLen(text);
88
- if (position >= totalWidth)
79
+ if (position >= cellLen(text))
89
80
  return [text, ""];
90
- // Walk characters tracking cell width and source char index
91
- let width = 0;
92
- let charIndex = 0;
93
- for (const char of text) {
94
- const charWidth = cellLen(char);
95
- if (width + charWidth > position)
96
- break;
97
- width += charWidth;
98
- charIndex += char.length;
99
- }
100
- const left = text.slice(0, charIndex);
101
- const right = text.slice(charIndex);
102
- // If we stopped short of position (mid-wide-char), pad left with spaces
103
- if (width < position) {
104
- return [left + " ".repeat(position - width), right];
105
- }
106
- return [left, right];
81
+ const left = cellFit(text, position);
82
+ return [left + " ".repeat(position - cellLen(left)), text.slice(left.length)];
83
+ }
84
+ /**
85
+ * Cuts `text` into exactly `position` cells and the rest, as the reference's
86
+ * `Segment.split_cells` does: a wide glyph the cut goes through fits neither
87
+ * side, so it leaves a space in each of its cells, where `splitText` keeps it
88
+ * whole on the right. Every other character lands on exactly one side.
89
+ * `position` must fall inside `text`, between 0 and its width.
90
+ */
91
+ export function cutCells(text, position) {
92
+ const head = cellFit(text, position);
93
+ // Cells of the glyph the cut goes through that lie left of it; 0 when the
94
+ // cut falls between glyphs, and then there is no such glyph.
95
+ const straddle = position - cellLen(head);
96
+ const [glyph = ""] = straddle > 0 ? clustersFrom(text, asCodePoint(head.length)) : [];
97
+ return [
98
+ head + " ".repeat(straddle),
99
+ " ".repeat(cellLen(glyph) - straddle) + text.slice(head.length + glyph.length),
100
+ ];
107
101
  }
108
102
  /**
109
103
  * Wraps text into lines of at most `maxWidth` cells, preserving every code
@@ -127,8 +121,39 @@ export function chopCells(text, maxWidth) {
127
121
  }
128
122
  return lines;
129
123
  }
130
- // [LAW:no-shared-mutable-globals] A private memo, written only by `graphemes`.
124
+ /**
125
+ * `line` split at each of `cuts`, in one walk over it. Converting each cut to a
126
+ * code-unit offset on its own rescans the line from the start every time, which
127
+ * is quadratic in a long enough string.
128
+ */
129
+ export function splitAtCells(line, cuts) {
130
+ const pieces = [];
131
+ let start = 0;
132
+ let offset = 0;
133
+ let cells = 0;
134
+ for (const ch of graphemes(line)) {
135
+ if (pieces.length < cuts.length && cells >= cuts[pieces.length]) {
136
+ pieces.push(line.slice(start, offset));
137
+ start = offset;
138
+ }
139
+ cells += cellLen(ch);
140
+ offset += ch.length;
141
+ }
142
+ return [...pieces, line.slice(start)];
143
+ }
144
+ // [LAW:no-shared-mutable-globals] Private memos, written only by `segmentsOf`:
145
+ // the segmenter, and the segmentation of the last string walked, so a caller
146
+ // stepping along one string does not re-segment the whole of it every step.
131
147
  let segmenter;
148
+ let lastSegmented;
149
+ /** The grapheme segmentation of `text`, reusing the last one when it is the same string. */
150
+ function segmentsOf(text) {
151
+ if (lastSegmented?.text !== text) {
152
+ segmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
153
+ lastSegmented = { text, segments: segmenter.segment(text) };
154
+ }
155
+ return lastSegmented.segments;
156
+ }
132
157
  /**
133
158
  * The grapheme clusters of `text`, in order: the unit `string-width` measures,
134
159
  * and so the unit `cellLen` of a whole string is the sum of. Summing `cellLen`
@@ -137,8 +162,19 @@ let segmenter;
137
162
  * between two of its code points leaves half a glyph.
138
163
  */
139
164
  export function graphemes(text) {
140
- segmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
141
- return Array.from(segmenter.segment(text), (s) => s.segment);
165
+ return Array.from(clustersFrom(text, asCodePoint(0)));
166
+ }
167
+ /**
168
+ * The clusters of `text` from code unit `start` on, lazily, so a walk that
169
+ * stops early pays only for the clusters it reads.
170
+ */
171
+ function* clustersFrom(text, start) {
172
+ const segments = segmentsOf(text);
173
+ for (let at = start, s = segments.containing(at); s; s = segments.containing(at)) {
174
+ const end = s.index + s.segment.length;
175
+ yield text.slice(at, end);
176
+ at = asCodePoint(end);
177
+ }
142
178
  }
143
179
  /**
144
180
  * Returns the largest prefix of `text` whose cell width fits within `cap` cells,
@@ -150,13 +186,14 @@ export function graphemes(text) {
150
186
  * must decide whether to force-take the glyph or skip it).
151
187
  */
152
188
  export function cellFit(text, cap) {
153
- return fitClusters(graphemes(text), cap).join("");
189
+ return text.slice(0, fitLength(clustersFrom(text, asCodePoint(0)), cap));
154
190
  }
155
191
  /** `cellFit` from the other end: the largest suffix of `text` within `cap` cells. */
156
192
  export function cellFitEnd(text, cap) {
157
- return fitClusters(graphemes(text).reverse(), cap).reverse().join("");
193
+ return text.slice(text.length - fitLength(graphemes(text).reverse(), cap));
158
194
  }
159
- function fitClusters(clusters, cap) {
195
+ /** The code units of the leading `clusters` that fit within `cap` cells. */
196
+ function fitLength(clusters, cap) {
160
197
  let w = 0;
161
198
  let n = 0;
162
199
  for (const cluster of clusters) {
@@ -164,9 +201,9 @@ function fitClusters(clusters, cap) {
164
201
  if (w + cw > cap)
165
202
  break;
166
203
  w += cw;
167
- n++;
204
+ n += cluster.length;
168
205
  }
169
- return clusters.slice(0, n);
206
+ return n;
170
207
  }
171
208
  /**
172
209
  * Returns the largest code-unit end offset starting from `startCU` whose
@@ -174,22 +211,11 @@ function fitClusters(clusters, cap) {
174
211
  * offset without slicing the tail, avoiding O(N²) allocation when called
175
212
  * repeatedly across a long string.
176
213
  *
177
- * [LAW:types-are-the-program] returns CodePoint because for...of always
178
- * stops on a code-point boundary.
214
+ * [LAW:types-are-the-program] returns CodePoint because it stops between
215
+ * grapheme clusters, and every cluster boundary is a code-point boundary.
179
216
  */
180
217
  export function cellFitFrom(text, startCU, cap) {
181
- let w = 0;
182
- let i = startCU;
183
- while (i < text.length) {
184
- const cp = text.codePointAt(i);
185
- const ch = String.fromCodePoint(cp);
186
- const cw = cellLen(ch);
187
- if (w + cw > cap)
188
- break;
189
- w += cw;
190
- i = asCodePoint(i + ch.length);
191
- }
192
- return i;
218
+ return asCodePoint(startCU + fitLength(clustersFrom(text, startCU), cap));
193
219
  }
194
220
  /**
195
221
  * Like `cellFitFrom`, but for any `startCU` inside `text` the result is
@@ -204,84 +230,105 @@ export function cellFitFrom(text, startCU, cap) {
204
230
  * `chopCells` did neither and hung.
205
231
  */
206
232
  export function cellStepFrom(text, startCU, cap) {
207
- return asCodePoint(Math.max(cellFitFrom(text, startCU, cap), nextCodePoint(text, startCU)));
233
+ const [first = ""] = clustersFrom(text, startCU);
234
+ return asCodePoint(Math.max(cellFitFrom(text, startCU, cap), startCU + first.length));
208
235
  }
209
236
  /**
210
- * Returns the largest code-unit offset into `content` whose prefix has
211
- * cell width ≤ `cellCol`. When `cellCol` falls mid-wide-character the
212
- * function stops before that character (never advances into it).
213
- * Clamps to `content.length` if `cellCol` exceeds the string's total
214
- * cell width.
215
- *
216
- * This is the inverse of `cellLen(content.slice(0, codeUnit))` — given a
217
- * visual column, return the corresponding string index.
218
- *
219
- * Returns `CodePoint` because `for...of` iteration always stops on a
220
- * code-point boundary.
237
+ * The code-unit offset a visual column falls at: the end of the largest
238
+ * prefix of `content` within `cellCol` cells, so a column inside a wide glyph
239
+ * lands before it. The inverse of `cellLen(content.slice(0, offset))`.
221
240
  */
222
241
  export function cellColToCodeUnitOffset(content, cellCol) {
223
- let w = 0;
224
- let i = 0;
225
- for (const ch of content) {
226
- if (w >= cellCol)
227
- break;
228
- const cw = cellLen(ch);
229
- if (w + cw > cellCol)
230
- break;
231
- w += cw;
232
- i += ch.length;
233
- }
234
- return asCodePoint(i);
242
+ return cellFitFrom(content, asCodePoint(0), cellCol);
235
243
  }
236
244
  /**
237
- * Advance one full Unicode code point from `cu`, returning the code-unit
238
- * offset of the start of the *next* code point. Returns `s.length` when
239
- * already at or past the end.
240
- *
241
- * Handles surrogate pairs: when the code point at `cu` is a supplementary
242
- * character (U+10000…U+10FFFF) it occupies 2 UTF-16 code units, so the
243
- * returned offset advances by 2.
245
+ * The offset just past the grapheme cluster at `cu`: one step of a cursor, a
246
+ * Delete, a transposition, so `👍🏽` or `👨‍👩‍👧` moves and goes as the one
247
+ * glyph `cellLen` measures, never a code point at a time. `cu` is a cluster
248
+ * boundary in `s` (see `graphemeBoundary`); `s.length` gives `s.length`.
244
249
  */
245
- export function nextCodePoint(s, cu) {
246
- if (cu >= s.length)
247
- return asCodePoint(s.length);
248
- const cp = s.codePointAt(cu);
249
- return asCodePoint(cu + (cp > 0xFFFF ? 2 : 1));
250
+ export function nextGrapheme(s, cu) {
251
+ const [cluster = ""] = clustersFrom(s, cu);
252
+ return asCodePoint(cu + cluster.length);
250
253
  }
251
- /**
252
- * Step back one full Unicode code point from `cu`, returning the code-unit
253
- * offset of the start of the *previous* code point. Returns 0 when already
254
- * at the start.
255
- *
256
- * Handles surrogate pairs: when the code unit at `cu - 1` is a low surrogate
257
- * AND the code unit at `cu - 2` is a high surrogate, steps back 2 code units.
258
- * Unpaired surrogates are treated as 1-CU characters.
259
- */
260
- export function prevCodePoint(s, cu) {
254
+ /** `nextGrapheme` backwards: the start of the cluster ending at `cu`; 0 at the start. */
255
+ export function prevGrapheme(s, cu) {
261
256
  if (cu <= 0)
262
257
  return asCodePoint(0);
263
- const low = s.charCodeAt(cu - 1);
264
- if (low >= 0xDC00 && low <= 0xDFFF && cu >= 2) {
265
- const high = s.charCodeAt(cu - 2);
266
- if (high >= 0xD800 && high <= 0xDBFF)
267
- return asCodePoint(cu - 2);
258
+ return asCodePoint(segmentsOf(s).containing(cu - 1).index);
259
+ }
260
+ /**
261
+ * Where an offset into `s` comes to rest as a cursor: itself when it is a
262
+ * cluster boundary, else the end of the cluster it falls inside, and never
263
+ * past `s.length`. The one parse from any number to the boundaries
264
+ * `nextGrapheme` and `prevGrapheme` step between.
265
+ */
266
+ export function graphemeBoundary(s, cu) {
267
+ const at = Math.min(cu, s.length);
268
+ const cluster = segmentsOf(s).containing(at);
269
+ return asCodePoint(cluster === undefined || cluster.index === at ? at : cluster.index + cluster.segment.length);
270
+ }
271
+ /**
272
+ * `n` as a `TabSize`, or a `RangeError` naming it. Rich divides by the tab
273
+ * size and raises on 0; here the value is refused where it is given, not at
274
+ * the first tab it meets.
275
+ */
276
+ export function parseTabSize(n) {
277
+ if (!Number.isInteger(n) || n < 1) {
278
+ throw new RangeError(`tabSize must be a positive integer, got ${n}`);
268
279
  }
269
- return asCodePoint(cu - 1);
280
+ return n;
270
281
  }
271
- // --- internal ---
272
- function cropToWidth(text, targetWidth) {
273
- let width = 0;
274
- let i = 0;
275
- // Use the string's code point iterator to handle surrogate pairs
276
- for (const char of text) {
277
- const charWidth = cellLen(char);
278
- if (width + charWidth > targetWidth)
279
- break;
280
- width += charWidth;
281
- i += char.length;
282
+ /**
283
+ * `text` with each tab widened to the next tab stop — a multiple of `tabSize`
284
+ * cells from the start of its line — as Rich's `Text.expand_tabs` widens it, and
285
+ * `offsetOf`, where each code-unit offset into `text` lands in the result.
286
+ *
287
+ * A tab is a column position, not a run of spaces: one that already sits a
288
+ * cell short of a stop widens to a single space. `offsetOf` is there because a
289
+ * caller that annotates the text by offset — a `RichText`'s spans — has to move
290
+ * its offsets with the characters, or every annotation after a tab lands short
291
+ * of what it styled.
292
+ */
293
+ export function expandTabs(text, tabSize) {
294
+ const [head, ...rest] = text.split("\t");
295
+ const tabAt = []; // offset of each tab in `text`
296
+ const grownBy = []; // code units added by this tab and every one before it
297
+ const columnAfter = (piece, column) => {
298
+ const newline = piece.lastIndexOf("\n");
299
+ return (newline < 0 ? column : 0) + cellLen(piece.slice(newline + 1));
300
+ };
301
+ // A piece's column is read only when a tab follows it, so text with no tab
302
+ // costs one `split` and no measuring.
303
+ let expanded = head;
304
+ let before = head; // the piece the next tab follows
305
+ let column = 0; // the column `before` starts at
306
+ let offset = head.length;
307
+ let grown = 0;
308
+ for (const piece of rest) {
309
+ column = columnAfter(before, column);
310
+ const spaces = tabSize - (column % tabSize);
311
+ grown += spaces - 1;
312
+ tabAt.push(offset);
313
+ grownBy.push(grown);
314
+ expanded += " ".repeat(spaces) + piece;
315
+ column += spaces;
316
+ before = piece;
317
+ offset += 1 + piece.length;
282
318
  }
283
- const cropped = text.slice(0, i);
284
- // Pad if we couldn't hit the exact width (wide char boundary)
285
- const diff = targetWidth - width;
286
- return diff > 0 ? cropped + " ".repeat(diff) : cropped;
319
+ // An offset moves by what every tab before it grew: the count of tabs at
320
+ // offsets below it, found by bisection since `tabAt` ascends.
321
+ const offsetOf = (at) => {
322
+ let low = 0;
323
+ let high = tabAt.length;
324
+ while (low < high) {
325
+ const mid = (low + high) >> 1;
326
+ if (tabAt[mid] < at)
327
+ low = mid + 1;
328
+ else
329
+ high = mid;
330
+ }
331
+ return at + (low === 0 ? 0 : grownBy[low - 1]);
332
+ };
333
+ return { text: expanded, offsetOf };
287
334
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Clock — the time source and timer whoever draws frames is handed, and the
3
+ * frame rate it ticks at.
4
+ *
5
+ * [LAW:no-ambient-temporal-coupling] Frame rate belongs to whoever draws the
6
+ * frames — an `App`, a `Live` — never to what is drawn, and each ticks on the
7
+ * `Clock` it was given. An `App` reads the frame's time through that clock and
8
+ * hands it to its view as data, so nothing rendered reads a clock, and an
9
+ * effect sampled thirty times a second or once every two seconds runs the
10
+ * same code.
11
+ *
12
+ * [LAW:effects-at-boundaries] The clock is a capability passed in, like the
13
+ * `TerminalHost`: a test hands over a clock whose time it moves by hand and
14
+ * counts frames with no real timer, and nothing it starts can outlive the
15
+ * thing that started it. `systemClock` is the one place the library reads the
16
+ * platform's clock and timers, and it reads them only when called.
17
+ */
18
+ import type { Unsubscribe } from "./subscription.js";
19
+ declare const parsed: unique symbol;
20
+ /**
21
+ * How often frames are drawn: a positive, finite number of frames a second,
22
+ * fractional included — 0.5 is one frame every two seconds.
23
+ *
24
+ * [LAW:parse-dont-validate] Only `frameRate` makes one, so holding a
25
+ * `FrameRate` is the proof the number was checked, and nothing that takes one
26
+ * checks it again.
27
+ */
28
+ export interface FrameRate {
29
+ readonly perSecond: number;
30
+ /** Seconds from one frame to the next. */
31
+ readonly interval: number;
32
+ readonly [parsed]: true;
33
+ }
34
+ /**
35
+ * `perSecond` as a `FrameRate`; anything but a positive, finite number throws,
36
+ * and so does a rate whose frames are further apart than a timer can wait —
37
+ * about 24.8 days.
38
+ */
39
+ export declare function frameRate(perSecond: number): FrameRate;
40
+ export interface Clock {
41
+ /** The time now, in seconds, on this clock's own timeline, which never runs backwards. */
42
+ now(): number;
43
+ /** Call `tick` once a frame at `rate` until the returned function is called. */
44
+ every(rate: FrameRate, tick: () => void): Unsubscribe;
45
+ }
46
+ /** The platform's monotonic clock and its interval timer. */
47
+ export declare function systemClock(): Clock;
48
+ export {};