@promptctl/rich-js 0.26.0 → 0.28.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 (132) hide show
  1. package/README.md +1 -0
  2. package/THIRD-PARTY-NOTICES +42 -0
  3. package/dist/core/animation.d.ts +52 -15
  4. package/dist/core/animation.js +82 -37
  5. package/dist/core/ansi.d.ts +15 -8
  6. package/dist/core/ansi.js +106 -28
  7. package/dist/core/bidi-data.d.ts +14 -0
  8. package/dist/core/bidi-data.js +385 -0
  9. package/dist/core/bidi.d.ts +89 -0
  10. package/dist/core/bidi.js +615 -0
  11. package/dist/core/box.d.ts +9 -1
  12. package/dist/core/box.js +11 -0
  13. package/dist/core/cell-widths.d.ts +3 -2
  14. package/dist/core/cell-widths.js +93 -93
  15. package/dist/core/cells.d.ts +20 -0
  16. package/dist/core/cells.js +93 -5
  17. package/dist/core/color.d.ts +26 -18
  18. package/dist/core/color.js +37 -34
  19. package/dist/core/console.d.ts +26 -1
  20. package/dist/core/console.js +26 -6
  21. package/dist/core/control-codes.d.ts +61 -0
  22. package/dist/core/control-codes.js +157 -0
  23. package/dist/core/easing.js +6 -1
  24. package/dist/core/env.d.ts +6 -0
  25. package/dist/core/env.js +9 -1
  26. package/dist/core/export-html.js +4 -3
  27. package/dist/core/export-lines.d.ts +2 -0
  28. package/dist/core/export-lines.js +7 -3
  29. package/dist/core/export-svg.js +6 -5
  30. package/dist/core/json.js +9 -2
  31. package/dist/core/markup.js +4 -3
  32. package/dist/core/oklch.d.ts +2 -0
  33. package/dist/core/oklch.js +3 -3
  34. package/dist/core/orient.d.ts +68 -0
  35. package/dist/core/orient.js +224 -0
  36. package/dist/core/osc8.d.ts +12 -5
  37. package/dist/core/osc8.js +14 -7
  38. package/dist/core/pretty.js +22 -18
  39. package/dist/core/protocol.d.ts +53 -22
  40. package/dist/core/protocol.js +14 -6
  41. package/dist/core/render.d.ts +8 -3
  42. package/dist/core/render.js +19 -5
  43. package/dist/core/seal.d.ts +26 -0
  44. package/dist/core/seal.js +75 -0
  45. package/dist/core/segment.d.ts +9 -1
  46. package/dist/core/segment.js +11 -1
  47. package/dist/core/style.d.ts +29 -1
  48. package/dist/core/style.js +41 -5
  49. package/dist/core/text-order.d.ts +51 -0
  50. package/dist/core/text-order.js +50 -0
  51. package/dist/core/text.d.ts +10 -0
  52. package/dist/core/text.js +133 -87
  53. package/dist/core/timeline.js +6 -6
  54. package/dist/core/view.d.ts +23 -9
  55. package/dist/core/view.js +37 -12
  56. package/dist/core/wrap.d.ts +24 -6
  57. package/dist/core/wrap.js +82 -16
  58. package/dist/host/app.d.ts +48 -7
  59. package/dist/host/app.js +116 -11
  60. package/dist/host/index.d.ts +3 -2
  61. package/dist/host/index.js +1 -0
  62. package/dist/host/input.d.ts +35 -2
  63. package/dist/host/input.js +88 -4
  64. package/dist/host/terminal-colors.d.ts +18 -0
  65. package/dist/host/terminal-colors.js +46 -0
  66. package/dist/host/terminal-host.d.ts +2 -1
  67. package/dist/index.d.ts +10 -8
  68. package/dist/index.js +2 -2
  69. package/dist/renderables/change.d.ts +1 -3
  70. package/dist/renderables/change.js +4 -6
  71. package/dist/renderables/effect.d.ts +61 -60
  72. package/dist/renderables/effect.js +91 -81
  73. package/dist/renderables/effects.d.ts +37 -8
  74. package/dist/renderables/effects.js +193 -55
  75. package/dist/renderables/frames.d.ts +1 -10
  76. package/dist/renderables/frames.js +1 -16
  77. package/dist/renderables/glyph-effect.d.ts +12 -13
  78. package/dist/renderables/glyph-effect.js +34 -32
  79. package/dist/renderables/glyph-effects.d.ts +27 -8
  80. package/dist/renderables/glyph-effects.js +75 -7
  81. package/dist/renderables/lifecycle.d.ts +40 -32
  82. package/dist/renderables/lifecycle.js +87 -48
  83. package/dist/renderables/live.d.ts +3 -3
  84. package/dist/renderables/live.js +4 -4
  85. package/dist/renderables/markdown.js +4 -2
  86. package/dist/renderables/panel.js +6 -4
  87. package/dist/renderables/progress.d.ts +11 -4
  88. package/dist/renderables/progress.js +29 -17
  89. package/dist/renderables/progressBar.d.ts +3 -3
  90. package/dist/renderables/progressBar.js +16 -13
  91. package/dist/renderables/syntax.js +7 -2
  92. package/dist/renderables/table.js +2 -1
  93. package/dist/template-bindings/animation-funcs.d.ts +49 -18
  94. package/dist/template-bindings/animation-funcs.js +115 -32
  95. package/dist/template-bindings/index.d.ts +7 -5
  96. package/dist/template-bindings/index.js +8 -5
  97. package/dist/template-bindings/palette-funcs.d.ts +1 -1
  98. package/dist/template-bindings/palette-funcs.js +1 -1
  99. package/dist/themes/buildPalette.d.ts +15 -39
  100. package/dist/themes/buildPalette.js +196 -67
  101. package/dist/themes/colorMath.d.ts +10 -4
  102. package/dist/themes/colorMath.js +33 -1
  103. package/dist/themes/data/index.js +1 -4
  104. package/dist/themes/data/textual-ansi.js +0 -143
  105. package/dist/themes/data/types.d.ts +3 -1
  106. package/dist/themes/palette.d.ts +22 -6
  107. package/dist/themes/palette.js +13 -21
  108. package/dist/themes/ramp.d.ts +1 -1
  109. package/dist/themes/ramp.js +1 -1
  110. package/dist/themes/registry.d.ts +7 -10
  111. package/dist/themes/registry.js +9 -30
  112. package/dist/themes/terminalThemes.d.ts +4 -10
  113. package/dist/themes/terminalThemes.js +31 -46
  114. package/dist/themes/transpose.d.ts +5 -10
  115. package/dist/themes/transpose.js +13 -24
  116. package/dist/widgets/button.d.ts +1 -1
  117. package/dist/widgets/button.js +6 -6
  118. package/dist/widgets/checkbox.js +4 -4
  119. package/dist/widgets/dropdown.d.ts +1 -1
  120. package/dist/widgets/dropdown.js +7 -5
  121. package/dist/widgets/event-router.js +2 -2
  122. package/dist/widgets/ink.d.ts +5 -4
  123. package/dist/widgets/ink.js +6 -9
  124. package/dist/widgets/text-input.d.ts +1 -1
  125. package/dist/widgets/text-input.js +41 -32
  126. package/dist/widgets/themed-widget.js +3 -3
  127. package/dist/widgets/toggle.d.ts +1 -1
  128. package/dist/widgets/toggle.js +5 -5
  129. package/dist/widgets/types.d.ts +2 -2
  130. package/dist/widgets/widget-app.d.ts +3 -3
  131. package/dist/widgets/widget-app.js +5 -5
  132. package/package.json +5 -4
@@ -87,10 +87,10 @@ function chunk(text, column) {
87
87
  function place(rows) {
88
88
  return rows.flatMap((runs, row) => {
89
89
  let column = 0;
90
- return runs.map(({ text, look }) => {
90
+ return runs.map(({ text, look, inVisualOrder }) => {
91
91
  const chunks = chunk(text, column);
92
92
  const cells = chunks.reduce((sum, c) => sum + c.cells, 0);
93
- const placed = { row, column, cells, look, chunks };
93
+ const placed = { row, column, cells, look, inVisualOrder, chunks };
94
94
  column += cells;
95
95
  return placed;
96
96
  });
@@ -132,10 +132,11 @@ function backgroundSvg(run) {
132
132
  ? []
133
133
  : [`<rect fill="${run.look.background.hex}" ${cellBox(run)} shape-rendering="crispEdges"/>`];
134
134
  }
135
- function textSvg({ text, column, cells }, baseline) {
135
+ function textSvg({ text, column, cells }, baseline, inVisualOrder) {
136
136
  // A chunk of no cells has no length to stretch to; it takes the font's.
137
137
  const length = cells === 0 ? "" : ` textLength="${n(cells * CELL_WIDTH)}" lengthAdjust="spacingAndGlyphs"`;
138
- return `<text x="${n(column * CELL_WIDTH)}" y="${n(baseline)}"${length} xml:space="preserve">${escapeText(text)}</text>`;
138
+ const order = inVisualOrder ? ` unicode-bidi="bidi-override"` : "";
139
+ return `<text x="${n(column * CELL_WIDTH)}" y="${n(baseline)}"${length}${order} xml:space="preserve">${escapeText(text)}</text>`;
139
140
  }
140
141
  /**
141
142
  * One run: its glyphs, then its lines and outline over them, in a group whose
@@ -155,7 +156,7 @@ function runSvg(run, className) {
155
156
  ];
156
157
  const radius = OUTLINE_RADIUS[look.outline];
157
158
  const drawn = [
158
- ...run.chunks.map((c) => textSvg(c, baseline)),
159
+ ...run.chunks.map((c) => textSvg(c, baseline, run.inVisualOrder)),
159
160
  ...lineYs.map((y) => `<rect x="${x}" y="${n(y)}" width="${width}" height="${LINE_THICKNESS}"/>`),
160
161
  ...(radius === null
161
162
  ? []
package/dist/core/json.js CHANGED
@@ -6,10 +6,13 @@
6
6
  * imports core primitives only. `Console.printJson` delegates here, so this is
7
7
  * where the console can reach it without an upward edge. [LAW:one-way-deps]
8
8
  */
9
+ import { escaped } from "./bidi.js";
9
10
  import { RichText } from "./text.js";
10
11
  import { JSONHighlighter } from "./highlighter.js";
11
12
  import { measure, render } from "./protocol.js";
12
13
  const highlighter = new JSONHighlighter();
14
+ /** `character` as JSON escapes, a surrogate pair for one past the BMP. */
15
+ const jsonEscape = (character) => Array.from({ length: character.length }, (_, at) => `\\u${character.charCodeAt(at).toString(16).padStart(4, "0")}`).join("");
13
16
  export class JSONRenderable {
14
17
  text;
15
18
  constructor(text) {
@@ -38,8 +41,12 @@ export class JSONRenderable {
38
41
  return value;
39
42
  }
40
43
  : undefined;
41
- const jsonStr = JSON.stringify(data, replacer, indent);
42
- const text = new RichText(jsonStr);
44
+ // `undefined` for what JSON has no form of, a function or a symbol, whatever its type says.
45
+ const jsonStr = JSON.stringify(data, replacer, indent) ?? "";
46
+ // Data on show: one string holds every key and value, so a control left in
47
+ // one would act on the rest of its line, and what content deletes or the wire
48
+ // withholds would leave a document that no longer parses back to this data.
49
+ const text = new RichText(escaped(jsonStr, jsonEscape));
43
50
  if (doHighlight) {
44
51
  highlighter.highlight(text);
45
52
  }
@@ -17,9 +17,10 @@
17
17
  * and is rejected with a `MarkupSyntaxError`. For the same reason a style tag
18
18
  * may not open on one side of a plugin tag and close on the other.
19
19
  */
20
+ import { closed } from "./bidi.js";
20
21
  import { cellLen } from "./cells.js";
21
22
  import { Style, StyleSyntaxError } from "./style.js";
22
- import { RichText, stripControlChars } from "./text.js";
23
+ import { RichText, sanitized } from "./text.js";
23
24
  import { emojiReplace } from "./emoji.js";
24
25
  import { pattern } from "./pattern.js";
25
26
  import { State } from "./state.js";
@@ -545,7 +546,7 @@ function walk(tokens, source, registry, emoji) {
545
546
  // counted along the way — a span, a splice point — indexes the text it
546
547
  // lands on. Stripped only by the constructor, a control character
547
548
  // shifted every style after it one cell right.
548
- write(out, stripControlChars(emoji ? emojiReplace(text) : text));
549
+ write(out, sanitized(emoji ? emojiReplace(text) : text));
549
550
  continue;
550
551
  }
551
552
  const plugin = pluginOf(token, registry);
@@ -580,7 +581,7 @@ function walk(tokens, source, registry, emoji) {
580
581
  const children = compose(takeFrom(out, target.mark));
581
582
  const text = handler({ attrs, children, raw: source.slice(target.tag.end, token.start) });
582
583
  out.spliced.push({ at: out.length, text });
583
- write(out, text.plain);
584
+ write(out, closed(text.plain));
584
585
  }
585
586
  // Close what is still open, innermost first. The reference pops its stack
586
587
  // here (`while style_stack: start, tag = style_stack.pop()`), and popping is
@@ -64,6 +64,8 @@ export declare const INVERT_LIGHTNESS: ThemeKey;
64
64
  * the predicate lives once, here.
65
65
  */
66
66
  export declare function isIdentityKey(k: ThemeKey): boolean;
67
+ export declare function srgbToLinear(v: number): number;
68
+ export declare function linearToSrgb(v: number): number;
67
69
  /**
68
70
  * Immutable OKLCH color. `l` ∈ [0, 1] (lightness), `c` ≥ 0 (chroma; typical
69
71
  * sRGB-representable colors top out near 0.32), `h` ∈ [0, 360) (hue
@@ -57,12 +57,12 @@ export function isIdentityKey(k) {
57
57
  k.lightnessShift === 0);
58
58
  }
59
59
  // ---------------------------------------------------------------------------
60
- // Pure conversion helpers (private).
60
+ // Pure conversion helpers.
61
61
  // ---------------------------------------------------------------------------
62
- function srgbToLinear(v) {
62
+ export function srgbToLinear(v) {
63
63
  return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
64
64
  }
65
- function linearToSrgb(v) {
65
+ export function linearToSrgb(v) {
66
66
  return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
67
67
  }
68
68
  function clamp01(v) {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Orienting a drawn line for its destination's `TextOrder`.
3
+ *
4
+ * Levels are resolved over the whole logical line and a row is ordered alone,
5
+ * because UAX #9 runs L1 and L2 after line breaking. So `directed` runs ahead of
6
+ * wrapping and each row's `orient` after it, and cuts fall on logical offsets in
7
+ * every order: what survives a cut is a logical prefix of the sentence, which is
8
+ * what a reader of either direction expects.
9
+ *
10
+ * [LAW:dataflow-not-control-flow] The order chooses values and never steps: a
11
+ * `plain` line passes through both.
12
+ */
13
+ import { Segment } from "./segment.js";
14
+ import { type Style } from "./style.js";
15
+ import type { TextOrder } from "./text-order.js";
16
+ /**
17
+ * The cells that end `text` and are no content: its whitespace, once what draws no
18
+ * cell among it is gone. A mark drawn on a space is not in it, because that space
19
+ * is a cell with something on it.
20
+ */
21
+ export declare function drawnTail(text: string): string;
22
+ /**
23
+ * The cells of trailing whitespace on a wrapped line.
24
+ *
25
+ * A wrap cuts before a word, so the line it closes ends with the whitespace
26
+ * that followed *its* last word — padding the break created, not content the
27
+ * author wrote. Measuring it is what lets the overflow method tell "this line
28
+ * was cut" from "this line ends in spaces" and stamp an ellipsis only on the
29
+ * first, and what keeps a centred line from drifting half a space off true.
30
+ *
31
+ * Asked of the line's text rather than walked back through its segments,
32
+ * because where a line ends is a fact about the characters and not about how
33
+ * they were split. Walking the segments made it a fact about both: it read a
34
+ * segment with no trailing whitespace as content and stopped there, which an
35
+ * empty segment also looks like — and a crop leaves one behind. A styled line
36
+ * whose last cells were cropped therefore reported no hanging whitespace at
37
+ * all and was centred as though it were content to the edge, so a span landing
38
+ * anywhere in a wrap's whitespace un-centred the line it closed. What draws no
39
+ * cell after the whitespace is no content either: `closed` writes a bidi control
40
+ * exactly there, and a cut leaves a zero-width code point that opened the next
41
+ * word on the row before it.
42
+ */
43
+ export declare function hangingWhitespace(line: Segment[]): number;
44
+ /** What a row of a directed line is, given the text it started as and its offset in the line. */
45
+ export interface Directed {
46
+ /** The line to cut: X9's removals gone where the terminal will not need them. */
47
+ readonly line: readonly Segment[];
48
+ /** Whether a row's cells may not be merged with the padding beside them. */
49
+ readonly oriented: boolean;
50
+ /** A row of `source` at `start`: the segments it became, ready for padding. */
51
+ orient(shown: readonly Segment[], start: number, source: string): Segment[];
52
+ }
53
+ /** What `order` does with `line`, one logical line of drawn segments. */
54
+ export declare function directed(line: readonly Segment[], order: TextOrder): Directed;
55
+ /** `text` as `order` measures it: what `directed` removes ahead of wrapping is no word. */
56
+ export declare function measured(text: string, order: TextOrder): string;
57
+ /** The offsets in `text`, one row, of the characters `order` shows at its left and right edges. */
58
+ export declare function edges(text: string, order: TextOrder): {
59
+ readonly left: number;
60
+ readonly right: number;
61
+ };
62
+ /**
63
+ * A label set between `before` and `after`, which are frame and stay where they
64
+ * are, for a row drawn with no `RichText`: only the label is oriented, or a
65
+ * button's brackets would be mirrored around it. A label `directed` leaves alone
66
+ * is one segment with its frame, as it always was.
67
+ */
68
+ export declare function labelled(before: string, label: string, after: string, style: Style | undefined, order: TextOrder | undefined): Segment[];
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Orienting a drawn line for its destination's `TextOrder`.
3
+ *
4
+ * Levels are resolved over the whole logical line and a row is ordered alone,
5
+ * because UAX #9 runs L1 and L2 after line breaking. So `directed` runs ahead of
6
+ * wrapping and each row's `orient` after it, and cuts fall on logical offsets in
7
+ * every order: what survives a cut is a logical prefix of the sentence, which is
8
+ * what a reader of either direction expects.
9
+ *
10
+ * [LAW:dataflow-not-control-flow] The order chooses values and never steps: a
11
+ * `plain` line passes through both.
12
+ */
13
+ import { isBidi, lineLevels, mirrored, openersAlong, paragraph, paragraphLevel, REMOVED, shaped, visualOrder, withoutExplicit, withoutMarks } from "./bidi.js";
14
+ import { cellLen, graphemes } from "./cells.js";
15
+ import { pattern } from "./pattern.js";
16
+ import { Segment } from "./segment.js";
17
+ import { NULL_STYLE } from "./style.js";
18
+ const TRAILING_WHITESPACE_RE = pattern(/\s+$/);
19
+ // From the front, greedily: anchored at the end, a scan restarts at each format
20
+ // character of a run, and that is quadratic on content anyone can send.
21
+ const THROUGH_CONTENT = pattern(/^[^]*[^\s\p{Cf}]/u);
22
+ /**
23
+ * The cells that end `text` and are no content: its whitespace, once what draws no
24
+ * cell among it is gone. A mark drawn on a space is not in it, because that space
25
+ * is a cell with something on it.
26
+ */
27
+ export function drawnTail(text) {
28
+ const tail = text.slice(THROUGH_CONTENT.exec(text)?.[0].length ?? 0);
29
+ return Array.from(tail).filter((character) => cellLen(character) > 0).join("");
30
+ }
31
+ /**
32
+ * The cells of trailing whitespace on a wrapped line.
33
+ *
34
+ * A wrap cuts before a word, so the line it closes ends with the whitespace
35
+ * that followed *its* last word — padding the break created, not content the
36
+ * author wrote. Measuring it is what lets the overflow method tell "this line
37
+ * was cut" from "this line ends in spaces" and stamp an ellipsis only on the
38
+ * first, and what keeps a centred line from drifting half a space off true.
39
+ *
40
+ * Asked of the line's text rather than walked back through its segments,
41
+ * because where a line ends is a fact about the characters and not about how
42
+ * they were split. Walking the segments made it a fact about both: it read a
43
+ * segment with no trailing whitespace as content and stopped there, which an
44
+ * empty segment also looks like — and a crop leaves one behind. A styled line
45
+ * whose last cells were cropped therefore reported no hanging whitespace at
46
+ * all and was centred as though it were content to the edge, so a span landing
47
+ * anywhere in a wrap's whitespace un-centred the line it closed. What draws no
48
+ * cell after the whitespace is no content either: `closed` writes a bidi control
49
+ * exactly there, and a cut leaves a zero-width code point that opened the next
50
+ * word on the row before it.
51
+ */
52
+ export function hangingWhitespace(line) {
53
+ return cellLen(TRAILING_WHITESPACE_RE.exec(drawnTail(Segment.plainOf(line)))?.[0] ?? "");
54
+ }
55
+ /**
56
+ * A row ready to order: one a wrap closed loses the whitespace it left hanging,
57
+ * which rule L1 would otherwise stand at a right-to-left row's left edge. The
58
+ * last row keeps what the author wrote.
59
+ */
60
+ function rowOf(shown, last) {
61
+ const row = [...shown];
62
+ return last ? row : Segment.adjustLineLength(row, Segment.getLineLength(row) - hangingWhitespace(row), undefined, false);
63
+ }
64
+ /** `line` with `keep` applied to each segment's text. */
65
+ function rewritten(line, keep) {
66
+ return line.map((segment) => {
67
+ const text = keep(segment.text);
68
+ return text === segment.text ? segment : new Segment(text, segment.style, segment.control);
69
+ });
70
+ }
71
+ const UNTOUCHED = (line) => ({ line, oriented: false, orient: (shown) => [...shown] });
72
+ const VISUAL_STAMP = { order: "visual" };
73
+ function withStamp(line, bidi) {
74
+ return line.map((segment) => new Segment(segment.text, (segment.style ?? NULL_STYLE).withBidi(bidi), segment.control));
75
+ }
76
+ /**
77
+ * `levels` of `source` carried onto `shown`, which `source` became by losing a
78
+ * tail and gaining a marker or wider gaps: each character that survived keeps
79
+ * its level, and one that did not exist takes the level before it, the
80
+ * paragraph's when nothing precedes.
81
+ */
82
+ function alignedLevels(shown, source, levels, paragraphLevel) {
83
+ const out = [];
84
+ let at = 0;
85
+ for (let unit = 0; unit < shown.length; unit += 1) {
86
+ if (shown[unit] === source[at]) {
87
+ out.push(levels[at]);
88
+ at += 1;
89
+ }
90
+ else {
91
+ out.push(at === source.length || at === 0 ? paragraphLevel : levels[at - 1]);
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ /** L1, L2 and L4 on a row: its grapheme clusters in display order, each carrying its style. */
97
+ function displayed(shown, levels, paragraphLevel) {
98
+ const text = Segment.plainOf(shown);
99
+ const finished = lineLevels(text, levels, paragraphLevel);
100
+ // Clusters are found in the whole row, because a span edge inside one is no
101
+ // place to cut it: reversed apart, a mark lands on another letter.
102
+ const styles = shown.flatMap((segment) => new Array(segment.text.length).fill(segment.style));
103
+ const clusters = [];
104
+ let unit = 0;
105
+ let previous = paragraphLevel;
106
+ for (const cluster of graphemes(text)) {
107
+ // A cluster of what X9 removes holds no level of its own, and stays where the text put it.
108
+ const level = finished[unit] === REMOVED ? previous : finished[unit];
109
+ previous = level;
110
+ clusters.push({ text: cluster, style: styles[unit], level });
111
+ unit += cluster.length;
112
+ }
113
+ const forms = shaped(clusters.map((cluster) => cluster.text));
114
+ const order = visualOrder(clusters.map((cluster) => cluster.level));
115
+ const out = [];
116
+ let run = "";
117
+ let style;
118
+ const flush = () => {
119
+ if (run !== "")
120
+ out.push(new Segment(run, style));
121
+ run = "";
122
+ };
123
+ for (const index of order) {
124
+ const cluster = clusters[index];
125
+ if (cluster.style !== style)
126
+ flush();
127
+ style = cluster.style;
128
+ run += cluster.level & 1 ? mirroredCluster(forms[index]) : forms[index];
129
+ }
130
+ flush();
131
+ return out;
132
+ }
133
+ function mirroredCluster(cluster) {
134
+ const base = cluster.codePointAt(0);
135
+ return String.fromCodePoint(mirrored(base)) + cluster.slice(base > 0xffff ? 2 : 1);
136
+ }
137
+ /**
138
+ * What `order` takes out of a line ahead of wrapping, and so out of its
139
+ * measure: what will not reach the wire. Under `visual` that is X9's own step
140
+ * and the marks with it: they have done their work once levels are resolved, and
141
+ * L2 would move a trailing one to a row's first cell, where xterm.js advances
142
+ * for it.
143
+ */
144
+ const REMOVALS = {
145
+ plain: withoutExplicit,
146
+ logical: (text) => text,
147
+ visual: (text) => withoutMarks(withoutExplicit(text)),
148
+ };
149
+ /** What `order` does with `line`, one logical line of drawn segments. */
150
+ export function directed(line, order) {
151
+ if (!isBidi(Segment.plainOf(line)))
152
+ return UNTOUCHED(line);
153
+ if (order === "plain")
154
+ return UNTOUCHED(rewritten(line, REMOVALS.plain));
155
+ const text = Segment.plainOf(line);
156
+ if (order === "logical") {
157
+ const level = paragraphLevel(text);
158
+ const openersAt = openersAlong(text);
159
+ return {
160
+ line,
161
+ oriented: true,
162
+ orient: (shown, start, source) => withStamp(rowOf(shown, start + source.length === text.length), { order: "logical", level, opens: openersAt(start) }),
163
+ };
164
+ }
165
+ const { level, levels } = paragraph(text);
166
+ const consumed = rewritten(line, REMOVALS.visual);
167
+ const kept = Segment.plainOf(consumed);
168
+ const keptLevels = [];
169
+ let at = 0;
170
+ for (let unit = 0; unit < text.length; unit += 1) {
171
+ if (text[unit] === kept[at]) {
172
+ keptLevels.push(levels[unit]);
173
+ at += 1;
174
+ }
175
+ }
176
+ return {
177
+ line: consumed,
178
+ oriented: true,
179
+ orient: (shown, start, source) => {
180
+ const row = rowOf(shown, start + source.length === kept.length);
181
+ const own = keptLevels.slice(start, start + source.length);
182
+ return withStamp(displayed(row, alignedLevels(Segment.plainOf(row), source, own, level), level), VISUAL_STAMP);
183
+ },
184
+ };
185
+ }
186
+ /** `text` as `order` measures it: what `directed` removes ahead of wrapping is no word. */
187
+ export function measured(text, order) {
188
+ return isBidi(text) ? REMOVALS[order](text) : text;
189
+ }
190
+ /** The offsets in `text` of the code points that take a cell: a tab does, once expanded. */
191
+ function drawnOffsets(text) {
192
+ const drawn = new Set();
193
+ let unit = 0;
194
+ for (const character of text) {
195
+ if (character === "\t" || cellLen(character) > 0)
196
+ drawn.add(unit);
197
+ unit += character.length;
198
+ }
199
+ return drawn;
200
+ }
201
+ /** The offsets in `text`, one row, of the characters `order` shows at its left and right edges. */
202
+ export function edges(text, order) {
203
+ const units = order === "plain" || !isBidi(text) ? Array.from({ length: text.length }, (_, unit) => unit) : ordered(text);
204
+ const offsets = drawnOffsets(text);
205
+ const drawn = units.filter((unit) => offsets.has(unit));
206
+ return { left: drawn[0] ?? 0, right: drawn.at(-1) ?? 0 };
207
+ }
208
+ function ordered(text) {
209
+ const { level, levels } = paragraph(text);
210
+ return visualOrder(lineLevels(text, levels, level));
211
+ }
212
+ /**
213
+ * A label set between `before` and `after`, which are frame and stay where they
214
+ * are, for a row drawn with no `RichText`: only the label is oriented, or a
215
+ * button's brackets would be mirrored around it. A label `directed` leaves alone
216
+ * is one segment with its frame, as it always was.
217
+ */
218
+ export function labelled(before, label, after, style, order) {
219
+ const { line, oriented, orient } = directed([new Segment(label, style)], order ?? "plain");
220
+ const text = Segment.plainOf(line);
221
+ if (!oriented)
222
+ return [new Segment(before + text + after, style)];
223
+ return [new Segment(before, style), ...orient(line, 0, text), new Segment(after, style)].filter((segment) => segment.text !== "");
224
+ }
@@ -6,9 +6,9 @@
6
6
  * every link reaches the wire through) opens a link with `osc8Open` and closes
7
7
  * it with `OSC8_CLOSE`; every consumer that reads rendered bytes (a width measure, a
8
8
  * test extracting URLs) matches them with `OSC8`; the data-model boundary
9
- * (RichText) cleans URLs with `stripOscTerminators`. All four read the one
10
- * terminator set below, so the bytes a sanitizer removes and the bytes a
11
- * reader stops at cannot disagree.
9
+ * (RichText) cleans URLs with `stripUrlControls`. Producer and reader share the
10
+ * terminator set below, and the sanitizer removes all of it, so no URL it has
11
+ * cleaned can end a wrap early.
12
12
  *
13
13
  * [LAW:locality-or-seam] This module depends only on `fnv1a`; every consumer
14
14
  * imports downward.
@@ -30,8 +30,15 @@
30
30
  * different URIs merges nothing — the URIs still differ — so a 32-bit hash is
31
31
  * exact, not approximate.
32
32
  */
33
- /** Remove every byte that could break a URL out of its OSC 8 wrap. */
34
- export declare function stripOscTerminators(url: string): string;
33
+ /**
34
+ * `url` without a control, a line separator or a bidi control. RFC 3986 admits no
35
+ * control and RFC 3987 no bidi control: ESC, BEL and ST end the wrap, CAN and SUB
36
+ * abort it, U+009B opens a sequence inside it, and U+202E would reorder the address
37
+ * on hover. Not the whole census, because an address can hold the rest: ZWNJ is
38
+ * part of a Persian word, and taking it would send the link somewhere else.
39
+ * `sealed` reads a segment's text, so this is the one rule for a link.
40
+ */
41
+ export declare function stripUrlControls(url: string): string;
35
42
  /**
36
43
  * The bytes that open a hyperlink to `url`.
37
44
  *
package/dist/core/osc8.js CHANGED
@@ -6,9 +6,9 @@
6
6
  * every link reaches the wire through) opens a link with `osc8Open` and closes
7
7
  * it with `OSC8_CLOSE`; every consumer that reads rendered bytes (a width measure, a
8
8
  * test extracting URLs) matches them with `OSC8`; the data-model boundary
9
- * (RichText) cleans URLs with `stripOscTerminators`. All four read the one
10
- * terminator set below, so the bytes a sanitizer removes and the bytes a
11
- * reader stops at cannot disagree.
9
+ * (RichText) cleans URLs with `stripUrlControls`. Producer and reader share the
10
+ * terminator set below, and the sanitizer removes all of it, so no URL it has
11
+ * cleaned can end a wrap early.
12
12
  *
13
13
  * [LAW:locality-or-seam] This module depends only on `fnv1a`; every consumer
14
14
  * imports downward.
@@ -37,9 +37,16 @@ import { pattern } from "./pattern.js";
37
37
  * begins ST `ESC \`), BEL (`\x07`), and the 8-bit ST (`\x9c`).
38
38
  */
39
39
  const TERMINATORS = "\\x1b\\x07\\x9c";
40
- /** Remove every byte that could break a URL out of its OSC 8 wrap. */
41
- export function stripOscTerminators(url) {
42
- return url.replace(new RegExp(`[${TERMINATORS}]`, "g"), "");
40
+ /**
41
+ * `url` without a control, a line separator or a bidi control. RFC 3986 admits no
42
+ * control and RFC 3987 no bidi control: ESC, BEL and ST end the wrap, CAN and SUB
43
+ * abort it, U+009B opens a sequence inside it, and U+202E would reorder the address
44
+ * on hover. Not the whole census, because an address can hold the rest: ZWNJ is
45
+ * part of a Persian word, and taking it would send the link somewhere else.
46
+ * `sealed` reads a segment's text, so this is the one rule for a link.
47
+ */
48
+ export function stripUrlControls(url) {
49
+ return url.replace(/[\p{Cc}\p{Zl}\p{Zp}\p{Bidi_Control}]/gu, "");
43
50
  }
44
51
  /**
45
52
  * The bytes that open a hyperlink to `url`.
@@ -50,7 +57,7 @@ export function stripOscTerminators(url) {
50
57
  * built directly with `new Style({ link })` reaches here unsanitized).
51
58
  */
52
59
  export function osc8Open(url) {
53
- const clean = stripOscTerminators(url);
60
+ const clean = stripUrlControls(url);
54
61
  // Hex digits are a legal `id=` value: no `:` or `;`.
55
62
  return `\x1b]8;id=${fnv1a(clean)};${clean}\x1b\\`;
56
63
  }
@@ -16,11 +16,12 @@
16
16
  * once to probe it and again to place it, so a node at depth d was visited 2^d
17
17
  * times.
18
18
  */
19
- import { asCellCol, cellColToCodeUnitOffset, cellLen, graphemes, splitAtCells } from "./cells.js";
19
+ import { asCellCol, cellColToCodeUnitOffset, cellLen, graphemes, replaceWithheld, splitAtCells, unicodeEscape, wireText } from "./cells.js";
20
20
  import { divideLine } from "./wrap.js";
21
21
  import { Memo } from "./memo.js";
22
22
  import { Segment } from "./segment.js";
23
- import { RichText, stripControlChars } from "./text.js";
23
+ import { closed } from "./bidi.js";
24
+ import { RichText, sanitized, stripControlChars } from "./text.js";
24
25
  import { ReprHighlighter } from "./highlighter.js";
25
26
  import { drawable, render } from "./protocol.js";
26
27
  import { pattern } from "./pattern.js";
@@ -66,6 +67,12 @@ function lineColumn(column, text) {
66
67
  const lastNewline = text.lastIndexOf("\n");
67
68
  return lastNewline === -1 ? column + cellLen(text) : cellLen(text.slice(lastNewline + 1));
68
69
  }
70
+ /**
71
+ * Text a value wrote itself, as layout reads it: what the wire will hold, every
72
+ * line ending an LF, its direction closed. A soft hyphen is gone with the rest,
73
+ * because this text is on show and the rows it hangs on are cut here.
74
+ */
75
+ const spelled = (text) => closed(wireText(sanitized(text)));
69
76
  /** Text with no line break in it. */
70
77
  const flat = (text) => ({ text, margins: [], arrows: [] });
71
78
  /** A new line whose first `margin` cells are the structure's indent. */
@@ -122,7 +129,7 @@ function follow(out, laid, tail) {
122
129
  * the same pass runs either way.
123
130
  *
124
131
  * The guide offsets are counted over the text `RichText` will hold, which has
125
- * its control characters stripped; counted over the raw text, every guide past
132
+ * every `strip` code point deleted; counted over the raw text, every guide past
126
133
  * a `\r` in a key or a `toString` would land one cell right. A margin is only
127
134
  * ever spaces, so stripping cannot move one. An arrow is moved by the
128
135
  * stripping of what precedes it, and by the blank lines dropped before it;
@@ -181,7 +188,7 @@ function drawMargins(lines, indent, guide) {
181
188
  function rowsOf(line, stay, hangRoom) {
182
189
  const lead = line.slice(0, line.length - line.trimStart().length);
183
190
  const rest = line.slice(stay);
184
- const hung = splitAtCells(rest, divideLine(rest, asCellCol(hangRoom - cellLen(lead)), { fold: true }))
191
+ const hung = splitAtCells(rest, divideLine(rest, asCellCol(hangRoom - cellLen(lead)), { fold: true }).map(({ cell }) => cell))
185
192
  .map((row) => row.trim())
186
193
  .filter((row) => row !== "")
187
194
  .map((row) => lead + row);
@@ -201,7 +208,7 @@ function stayingLength(line, room, canHang) {
201
208
  return line.length;
202
209
  const cut = (fold) => {
203
210
  const first = divideLine(line, asCellCol(room), { fold })[0];
204
- return first === undefined ? line.length : cellColToCodeUnitOffset(line, first);
211
+ return first === undefined ? line.length : cellColToCodeUnitOffset(line, first.cell);
205
212
  };
206
213
  const words = cut(false);
207
214
  if (cellLen(line.slice(0, words).trimEnd()) <= room)
@@ -343,7 +350,7 @@ function take(source, limit) {
343
350
  function threw(error) {
344
351
  // An `Error` from any realm carries its `message` as a string.
345
352
  const message = error?.message;
346
- return `[Threw: ${typeof message === "string" ? message : String(error)}]`;
353
+ return `[Threw: ${spelled(typeof message === "string" ? message : String(error))}]`;
347
354
  }
348
355
  /**
349
356
  * Does this value carry its own string form, or must we reflect on its keys?
@@ -459,16 +466,13 @@ const BARE_KEY = pattern(/^(?![^]*\p{Default_Ignorable_Code_Point})(?:[\p{ID_Sta
459
466
  /**
460
467
  * What Python's `str.isprintable` refuses \u2014 control, format, private-use,
461
468
  * unassigned, and every separator but the space \u2014 which Rich's repr escapes
462
- * and JSON.stringify leaves raw past C0. Raw, DEL vanishes in `RichText`, a C1
463
- * byte reaches the terminal as a control, and a format character hides or
464
- * reorders what is around it.
469
+ * and JSON.stringify leaves raw past C0. Raw, a C0 control or DEL is a picture in
470
+ * `RichText`, a C1 control a stand-in and NEL a line break, and a format character
471
+ * hides or reorders what is around it: escaped, the repr shows which one it was.
465
472
  */
466
473
  const unprintable = () => /(?! )[\p{Cc}\p{Cf}\p{Co}\p{Cn}\p{Zl}\p{Zp}\p{Zs}]/gu;
467
- /** `value` in double quotes, every character that does not print escaped. */
468
- const quoted = (value) => JSON.stringify(value).replace(unprintable(), (c) => {
469
- const hex = c.codePointAt(0).toString(16).padStart(4, "0");
470
- return hex.length > 4 ? `\\u{${hex}}` : `\\u${hex}`;
471
- });
474
+ /** `value` in double quotes, every character that does not print or that the wire would withhold escaped. */
475
+ const quoted = (value) => replaceWithheld(JSON.stringify(value), unicodeEscape).replace(unprintable(), unicodeEscape);
472
476
  export class Pretty {
473
477
  data;
474
478
  indent;
@@ -549,9 +553,9 @@ export class Pretty {
549
553
  case "boolean":
550
554
  return String(value);
551
555
  case "symbol":
552
- return value.toString();
556
+ return spelled(value.toString());
553
557
  case "function":
554
- return `[Function: ${value.name || "anonymous"}]`;
558
+ return `[Function: ${spelled(String(value.name || "anonymous"))}]`;
555
559
  }
556
560
  return null;
557
561
  }
@@ -667,9 +671,9 @@ export class Pretty {
667
671
  holes: [{ read: () => v, tail: flat("") }],
668
672
  })));
669
673
  case "opaque":
670
- return { kind: "text", text: form.text };
674
+ return { kind: "text", text: spelled(form.text) };
671
675
  case "self":
672
- return { kind: "text", text: String(value) };
676
+ return { kind: "text", text: spelled(String(value)) };
673
677
  case "keys": {
674
678
  // No self-description, so the keys are the whole story.
675
679
  const { record } = form;