@promptctl/rich-js 0.19.0 → 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 (53) hide show
  1. package/README.md +3 -2
  2. package/dist/core/color.d.ts +1 -0
  3. package/dist/core/color.js +3 -2
  4. package/dist/core/noise.d.ts +31 -0
  5. package/dist/core/noise.js +94 -0
  6. package/dist/core/paint.js +13 -2
  7. package/dist/core/pretty.d.ts +23 -2
  8. package/dist/core/pretty.js +54 -18
  9. package/dist/core/protocol.d.ts +2 -0
  10. package/dist/core/protocol.js +3 -2
  11. package/dist/core/selection.d.ts +12 -0
  12. package/dist/core/selection.js +14 -0
  13. package/dist/core/strip.d.ts +103 -72
  14. package/dist/core/strip.js +240 -212
  15. package/dist/core/style.js +9 -7
  16. package/dist/host/app.d.ts +68 -2
  17. package/dist/host/app.js +125 -15
  18. package/dist/host/index.d.ts +3 -1
  19. package/dist/host/index.js +1 -0
  20. package/dist/host/input.d.ts +84 -0
  21. package/dist/host/input.js +498 -0
  22. package/dist/host/terminal-host.d.ts +12 -0
  23. package/dist/host/terminal-host.js +2 -0
  24. package/dist/index.d.ts +9 -4
  25. package/dist/index.js +6 -3
  26. package/dist/node/terminal-host.d.ts +7 -0
  27. package/dist/node/terminal-host.js +7 -0
  28. package/dist/renderables/effect.d.ts +23 -8
  29. package/dist/renderables/effect.js +48 -26
  30. package/dist/renderables/effects.d.ts +252 -0
  31. package/dist/renderables/effects.js +565 -0
  32. package/dist/renderables/embed.d.ts +11 -5
  33. package/dist/renderables/embed.js +13 -6
  34. package/dist/renderables/list-view.d.ts +42 -0
  35. package/dist/renderables/list-view.js +78 -0
  36. package/dist/renderables/progress.d.ts +6 -0
  37. package/dist/renderables/progress.js +11 -4
  38. package/dist/renderables/progressBar.d.ts +22 -2
  39. package/dist/renderables/progressBar.js +51 -7
  40. package/dist/renderables/rule.d.ts +2 -2
  41. package/dist/renderables/rule.js +12 -10
  42. package/dist/renderables/table.js +5 -7
  43. package/dist/renderables/viewport.d.ts +30 -0
  44. package/dist/renderables/viewport.js +59 -3
  45. package/dist/widgets/dropdown.js +3 -3
  46. package/dist/widgets/event-router.d.ts +31 -40
  47. package/dist/widgets/event-router.js +52 -483
  48. package/dist/widgets/index.d.ts +2 -1
  49. package/dist/widgets/types.d.ts +8 -25
  50. package/dist/widgets/types.js +8 -0
  51. package/dist/widgets/widget-app.d.ts +22 -7
  52. package/dist/widgets/widget-app.js +22 -12
  53. package/package.json +22 -18
@@ -29,7 +29,8 @@ import { Segment } from "./segment.js";
29
29
  import { Style } from "./style.js";
30
30
  import { ColorDepth, ColorSpec, SURFACE_BLACK } from "./color.js";
31
31
  import { Oklch } from "./oklch.js";
32
- import { drawable } from "./protocol.js";
32
+ import { cellLen } from "./cells.js";
33
+ import { drawable, getStyle, isAscii } from "./protocol.js";
33
34
  // --- Strip ---
34
35
  export class Strip {
35
36
  items;
@@ -84,65 +85,128 @@ export class Strip {
84
85
  return { minimum: width, maximum: width };
85
86
  }
86
87
  }
87
- // --- Helpers ---
88
- const EMPTY = {
89
- *render(_options) {
90
- // intentionally empty
91
- },
92
- };
93
- /** A renderable whose segments are computed from the options it is rendered with. */
94
- function deferred(emit) {
95
- return { render: emit };
88
+ const PARTS = ["seam", "divider", "lead", "tail"];
89
+ /**
90
+ * The least ΔE_OK two neighbouring backgrounds must differ by for a seam drawn
91
+ * between them to be seen. Below it the seam is drawn in a colour the eye
92
+ * cannot tell from its own background, so the joiner draws the divider
93
+ * instead. Twice the ~.02 threshold of a visible difference, because a seam is
94
+ * one cell wide.
95
+ */
96
+ export const SEAM_MIN_DELTA_E = 0.04;
97
+ function* paintsIn(paint) {
98
+ yield paint;
99
+ if ("mix" in paint) {
100
+ yield* paintsIn(paint.mix[0]);
101
+ yield* paintsIn(paint.mix[1]);
102
+ }
103
+ }
104
+ const glyphsOf = (cell) => (typeof cell.glyph === "string" ? [cell.glyph] : cell.glyph.oneOf);
105
+ const cellWidth = (cell) => cellLen(glyphsOf(cell)[0] ?? "");
106
+ const partWidth = (cells) => cells.reduce((width, cell) => width + cellWidth(cell), 0);
107
+ // `raw` as a `Paint`, or refused. `absent` is the neighbour this part may not
108
+ // name, because the part is also drawn where that neighbour does not exist.
109
+ function parsePaint(raw, absent, refuse) {
110
+ if (raw === "ground")
111
+ return refuse("mixes ground, whose colour is unknown");
112
+ const p = (typeof raw === "object" && raw !== null ? raw : {});
113
+ if ((p.edge === "left" || p.edge === "right") && (p.part === "bg" || p.part === "fg")) {
114
+ if (p.edge === absent)
115
+ refuse(`names the ${absent} neighbour, which this part is drawn without`);
116
+ return Object.freeze({ edge: p.edge, part: p.part });
117
+ }
118
+ if (typeof p.role === "string")
119
+ return Object.freeze({ role: p.role });
120
+ if (Array.isArray(p.mix) && p.mix.length === 3) {
121
+ const [from, to, t] = p.mix;
122
+ if (typeof t !== "number" || !(t >= 0 && t <= 1))
123
+ refuse(`mixes at ${String(t)}, outside 0..1`);
124
+ return Object.freeze({ mix: Object.freeze([parsePaint(from, absent, refuse), parsePaint(to, absent, refuse), t]) });
125
+ }
126
+ return refuse(`has a colour that is not a Paint: ${JSON.stringify(raw)}`);
127
+ }
128
+ const parseColor = (raw, absent, refuse) => raw === "ground" ? raw : parsePaint(raw, absent, refuse);
129
+ const ABSENT = { seam: undefined, divider: undefined, lead: "left", tail: "right" };
130
+ function parseList(raw, where) {
131
+ if (!Array.isArray(raw))
132
+ throw new Error(`CellJoiner: ${where} is not a list of cells`);
133
+ return raw.map((cell, i) => {
134
+ if (typeof cell !== "object" || cell === null)
135
+ throw new Error(`CellJoiner: ${where}[${i}] is not a cell`);
136
+ return cell;
137
+ });
138
+ }
139
+ /**
140
+ * [LAW:parse-dont-validate] The one place a shape is checked, so every rule
141
+ * the cell model states is refused at construction and the render reads a
142
+ * shape that already holds them. A shape is often authored as data — a
143
+ * consumer's config — so the rules the types state are checked here too, and
144
+ * what comes back is a frozen copy the caller can no longer change.
145
+ */
146
+ function parseShape(raw, form) {
147
+ const parseCells = (part) => Object.freeze(parseList(raw[part], `${form} ${part}`).map((cell, i) => {
148
+ const refuse = (why) => {
149
+ throw new Error(`CellJoiner: ${form} ${part}[${i}] ${why}`);
150
+ };
151
+ const glyph = cell.glyph;
152
+ const oneOf = typeof glyph === "object" && glyph !== null ? glyph.oneOf : undefined;
153
+ if (typeof glyph !== "string" && !(Array.isArray(oneOf) && oneOf.every((g) => typeof g === "string"))) {
154
+ refuse(`has a glyph that is neither a string nor a oneOf of strings: ${JSON.stringify(glyph)}`);
155
+ }
156
+ if (cell.fg === "ground" && cell.bg === "ground")
157
+ refuse("has ground on both sides");
158
+ const glyphs = glyphsOf(cell);
159
+ if (glyphs.length === 0)
160
+ refuse("has an empty oneOf");
161
+ if (glyphs.some((glyph) => cellLen(glyph) !== cellWidth(cell)))
162
+ refuse("has oneOf members of different widths");
163
+ if (form === "ascii" && !glyphs.every(isAscii))
164
+ refuse("is not ASCII");
165
+ return Object.freeze({
166
+ glyph: typeof cell.glyph === "string" ? cell.glyph : Object.freeze({ oneOf: Object.freeze([...glyphs]) }),
167
+ fg: parseColor(cell.fg, ABSENT[part], refuse),
168
+ bg: parseColor(cell.bg, ABSENT[part], refuse),
169
+ });
170
+ }));
171
+ const seam = parseCells("seam");
172
+ // A divider authored as the seam itself stays the seam, so a render can see
173
+ // it has no choice to make.
174
+ const divider = raw.divider === raw.seam ? seam : parseCells("divider");
175
+ if (partWidth(divider) !== partWidth(seam))
176
+ throw new Error(`CellJoiner: ${form} divider is not the width of the seam`);
177
+ return Object.freeze({ seam, divider, lead: parseCells("lead"), tail: parseCells("tail") });
96
178
  }
97
- // Endpoint and powerline join glyphs paint the adjacent item's edge bg
98
- // *as* their fg. If the edge has no bgcolor, the glyph degrades to the
99
- // default fg — which renders as nothing visible against the terminal
100
- // background, the right outcome for an unstyled edge.
101
- function bgAsFg(edge) {
102
- return new Style({ color: edge.bgcolor });
179
+ // Whether any cell of `shape` paints with `side`'s ground.
180
+ const readsGround = (shape, side) => PARTS.flatMap((part) => shape[part]).some((cell) => [cell.fg, cell.bg].some((color) => color !== "ground" && [...paintsIn(color)].some((p) => "edge" in p && p.edge === side && p.part === "bg")));
181
+ function formOf(shape) {
182
+ return { shape, reads: { left: readsGround(shape, "left"), right: readsGround(shape, "right") } };
103
183
  }
104
184
  // A bg with a real colour to paint. `undefined` (no bg) and the terminal
105
185
  // DEFAULT colour (transparent — identical to the terminal background) are the
106
- // two representations of "nothing to paint"; the powerline separator treats
107
- // them the same, so the gate cannot be fooled by an explicit `… on default`.
186
+ // two representations of "nothing to paint", so an explicit `… on default`
187
+ // cannot smuggle a seam.
108
188
  function paintableBg(bg) {
109
189
  return bg !== undefined && !bg.isDefault ? bg : undefined;
110
190
  }
111
- // A cap, arrow or gradient is its cell continuing: that cell's ground as the
112
- // writer draws it (`Style.drawnColors`, onto the terminal's black), so a
113
- // translucent ground is not composited a second time over whatever it enters.
191
+ // A neighbour's ground as the writer draws it (`Style.drawnColors`, onto the
192
+ // terminal's black), so a translucent ground is not composited a second time
193
+ // over whatever a join cell enters.
114
194
  function drawnGround(bg) {
115
195
  return bg.flattenAlpha(SURFACE_BLACK);
116
196
  }
117
- // A cap is its glyph in its cell's ground, and "" is a flat end: no glyph, so
118
- // no segment — the same output as a join with nothing to paint.
119
- function* cap(glyph, bg) {
120
- if (glyph !== "")
121
- yield new Segment(glyph, new Style({ color: drawnGround(bg) }));
122
- }
123
- // --- PowerlineJoiner ---
124
- /**
125
- * The least ΔE_OK two neighbouring backgrounds must differ by for the powerline
126
- * arrow between them to be seen. Below it the arrow is drawn in a colour the
127
- * eye cannot tell from its own background, so the joiner draws the divider
128
- * instead. Twice the ~.02 threshold of a visible difference, because a seam is
129
- * one cell wide.
130
- */
131
- export const SEAM_MIN_DELTA_E = 0.04;
132
- // An arrow the eye cannot tell from the ground it is drawn on. What is
133
- // measured is what is drawn — `Style.drawnColors`, the colours the writer
134
- // encodes: the arrow's colour flattened onto its ground and the ground onto
135
- // the terminal's black, both downgraded to the depth the render encodes at,
136
- // so two grounds 256 colours round to one cube entry are the one entry they
137
- // render as. A colour whose RGB is the terminal theme's own (ANSI 0–15, a
138
- // blend reaching one) has no value here, so two of them are the same only
139
- // when they are one expression: the same palette slot — its `number`, never
140
- // the name or the depth that spells it ("red", "color(1)", an EIGHT_BIT spec
141
- // on 0–15 are one slot) — or the same blend, whose name spells each end by its
142
- // slot for the same reason.
143
- function vanishes(arrow, colorSystem) {
144
- // No colour emitted draws nothing to tell apart; measure what was handed.
145
- const { color, bgcolor } = arrow.drawnColors(colorSystem ?? ColorDepth.TRUECOLOR);
197
+ // A seam the eye cannot tell from the ground it is drawn on. What is measured
198
+ // is what is drawn — `Style.drawnColors`, the colours the writer encodes: the
199
+ // left ground flattened onto the right and the right onto the terminal's
200
+ // black, both downgraded to the depth the render encodes at, so two grounds
201
+ // 256 colours round to one cube entry are the one entry they render as. A
202
+ // colour whose RGB is the terminal theme's own (ANSI 0–15, a blend reaching
203
+ // one) has no value here, so two of them are the same only when they are one
204
+ // expression: the same palette slot — its `number`, never the name or the
205
+ // depth that spells it ("red", "color(1)", an EIGHT_BIT spec on 0–15 are one
206
+ // slot) — or the same blend, whose name spells each end by its slot for the
207
+ // same reason.
208
+ function vanishes(leftBg, rightBg, colorSystem) {
209
+ const { color, bgcolor } = new Style({ color: drawnGround(leftBg), bgcolor: rightBg }).drawnColors(colorSystem ?? ColorDepth.TRUECOLOR);
146
210
  if (color === undefined || bgcolor === undefined)
147
211
  return false;
148
212
  const av = color.fixedValue;
@@ -151,176 +215,140 @@ function vanishes(arrow, colorSystem) {
151
215
  ? Oklch.fromRgba(av).deltaE(Oklch.fromRgba(bv)) < SEAM_MIN_DELTA_E
152
216
  : color.number !== undefined ? color.number === bgcolor.number : color.name === bgcolor.name;
153
217
  }
218
+ function resolvePaint(paint, edges, options) {
219
+ if ("edge" in paint) {
220
+ const edge = edges[paint.edge];
221
+ if (paint.part === "fg")
222
+ return edge?.color;
223
+ const bg = paintableBg(edge?.bgcolor);
224
+ return bg && drawnGround(bg);
225
+ }
226
+ if ("role" in paint)
227
+ return getStyle(options, paint.role).color;
228
+ const [from, to, t] = paint.mix;
229
+ const a = resolvePaint(from, edges, options);
230
+ const b = resolvePaint(to, edges, options);
231
+ // A mix with no colour at one end has nothing to blend toward.
232
+ return a && b && ColorSpec.blend(a, b, t);
233
+ }
234
+ function cellStyle(cell, edges, options) {
235
+ // [LAW:dataflow-not-control-flow] "ground" is the absence of a colour on its
236
+ // side; as the fg it is reverse video over the bg colour, so the glyph's ink
237
+ // is the terminal's own background.
238
+ if (cell.fg === "ground")
239
+ return new Style({ color: cell.bg === "ground" ? undefined : resolvePaint(cell.bg, edges, options), reverse: true });
240
+ const color = resolvePaint(cell.fg, edges, options);
241
+ return new Style({ color, bgcolor: cell.bg === "ground" ? undefined : resolvePaint(cell.bg, edges, options) });
242
+ }
243
+ const shapeGlyphs = (shape) => PARTS.flatMap((part) => shape[part].flatMap(glyphsOf)).join("");
154
244
  /**
155
- * The powerline set: U+E0B0 (right-arrow) divided by U+E0B1 (thin right-arrow),
156
- * led by U+E0D7 (Nerd Fonts ple-left_hard_divider_inverse, the right-arrow's
157
- * inverse) and tailed by the arrow itself. The lead leaves empty a triangle
158
- * whose base is its cell's left edge and whose point touches the first item, so
159
- * the run opens notched — as if an arrow before it pointed in — rather than
160
- * with a shape pointing out. The notch is the terminal's own background showing
161
- * through, never a colour painted to imitate it, so it matches under a
162
- * translucent terminal background too. The lead is a Powerline Extra glyph: a
163
- * classic powerline font lacks it.
245
+ * The one joiner: what it draws at each join is the `JoinShape` it is given,
246
+ * so a new joiner is a value, never a class.
247
+ *
248
+ * Which part a join draws is read off its two neighbours. A neighbour is
249
+ * missing at the strip's ends, and counts as missing — an endpoint — when the
250
+ * shape paints with its ground anywhere and it has none to paint. With both present the
251
+ * join is the seam, or the divider where the two grounds are drawn as one
252
+ * colour at `options.colorSystem`; with only the right, the lead; with only
253
+ * the left, the tail; with neither, nothing.
254
+ *
255
+ * `ascii` is the shape an ASCII-only output draws instead, whole.
164
256
  */
165
- export const POWERLINE_JOINER_GLYPHS = Object.freeze({
166
- glyph: "\ue0b0",
167
- divider: "\ue0b1",
168
- lead: "\ue0d7",
169
- tail: "\ue0b0",
170
- });
171
- /** The powerline set in ASCII: an arrow for every join, runs begun flat. */
172
- const ASCII_POWERLINE_GLYPHS = Object.freeze({
173
- glyph: ">",
174
- divider: ">",
175
- lead: "",
176
- tail: ">",
177
- });
178
- const powerlineGlyphs = ({ glyph, divider, lead, tail }) => glyph + divider + lead + tail;
179
- export class PowerlineJoiner {
180
- _glyphs;
181
- constructor(options = POWERLINE_JOINER_GLYPHS) {
182
- this._glyphs = options;
257
+ export class CellJoiner {
258
+ shape;
259
+ ascii;
260
+ _forms;
261
+ constructor(shape, ascii) {
262
+ this.shape = parseShape(shape, "glyph");
263
+ this.ascii = parseShape(ascii, "ascii");
264
+ this._forms = { glyph: formOf(this.shape), ascii: formOf(this.ascii) };
183
265
  }
184
266
  join(left, right) {
185
- // [LAW:dataflow-not-control-flow] One table for all three positions (start
186
- // cap, mid-join, end cap), read off which side has a colour to paint. The
187
- // endpoints are not control-flow special cases; they are the DATA cases
188
- // where a neighbour (hence its bg) is absent:
189
- // • both bgs — the arrow in the LEFT bg (the colour bleeding rightward)
190
- // over the RIGHT bg.
191
- // • left bg only — the end cap, OR a coloured item before a colourless
192
- // one — the tail bleeds the left colour out over the terminal
193
- // background (fg = left bg, no bg).
194
- // • right bg only — the start cap, OR a colourless item before a coloured
195
- // one — the lead: the right colour painted over the terminal
196
- // background in the lead's shape (fg = right bg, no bg).
197
- // • neither — nothing to paint, so the join yields nothing.
198
- // Equal REAL bgs still emit: a same-bg seam between two distinct items is a
199
- // structural boundary, never suppressed. The arrow would be drawn in its own
200
- // background colour there and vanish, so the seam is the DIVIDER instead, in
201
- // the left item's text colour — the vim-airline convention for neighbours
202
- // that share a background. "Equal" is perceptual (SEAM_MIN_DELTA_E) and
203
- // measured on the colours the terminal draws at `options.colorSystem`: an
204
- // arrow a hair off its background is as invisible as one exactly on it,
205
- // and two backgrounds 256 colours round to one entry are one background.
206
- // Background colour is paint, not structure; its ABSENCE (nothing to paint)
207
- // is the only thing that elides the separator, and that is paint logic.
208
- // "Absent" = no bg OR the terminal default (transparent) — paintableBg folds
209
- // both to undefined so an explicit `… on default` cannot smuggle a separator.
210
- const glyphs = this._glyphs;
211
- return deferred(function* (options) {
212
- const { glyph, divider, lead, tail } = drawable(options, glyphs, ASCII_POWERLINE_GLYPHS, powerlineGlyphs);
213
- const leftEdge = left?.edgeStyle("right", options);
214
- const leftBg = paintableBg(leftEdge?.bgcolor);
215
- const rightBg = paintableBg(right?.edgeStyle("left", options).bgcolor);
216
- if (leftBg === undefined) {
217
- if (rightBg !== undefined)
218
- yield* cap(lead, rightBg);
219
- return;
220
- }
221
- if (rightBg === undefined) {
222
- yield* cap(tail, leftBg);
223
- return;
224
- }
225
- const arrow = new Style({ color: drawnGround(leftBg), bgcolor: rightBg });
226
- // The divider is the left item's text on the left item's own ground,
227
- // so it reads exactly as well as that item's text does, at any depth.
228
- yield vanishes(arrow, options.colorSystem)
229
- ? new Segment(divider, new Style({ color: leftEdge?.color, bgcolor: leftBg }))
230
- : new Segment(glyph, arrow);
231
- });
267
+ const forms = this._forms;
268
+ return {
269
+ *render(options) {
270
+ const { shape: drawn, reads } = drawable(options, forms.glyph, forms.ascii, (form) => shapeGlyphs(form.shape));
271
+ const edges = { left: left?.edgeStyle("right", options), right: right?.edgeStyle("left", options) };
272
+ const leftBg = paintableBg(edges.left?.bgcolor);
273
+ const rightBg = paintableBg(edges.right?.bgcolor);
274
+ const hasLeft = left !== null && (!reads.left || leftBg !== undefined);
275
+ const hasRight = right !== null && (!reads.right || rightBg !== undefined);
276
+ const divides = drawn.divider !== drawn.seam && leftBg !== undefined && rightBg !== undefined && vanishes(leftBg, rightBg, options.colorSystem);
277
+ const cells = hasLeft && hasRight ? (divides ? drawn.divider : drawn.seam) : hasRight ? drawn.lead : hasLeft ? drawn.tail : [];
278
+ // A oneOf draws its first member until a join knows its seam index
279
+ // (rich-joiners-9ntp.bex).
280
+ for (const cell of cells)
281
+ yield new Segment(glyphsOf(cell)[0], cellStyle(cell, edges, options));
282
+ },
283
+ };
232
284
  }
233
285
  }
234
- const ASCII_CAPS = { left: "(", right: ")" };
235
- const capGlyphs = ({ left, right }) => left + right;
236
- export class CapsuleJoiner {
237
- _caps;
238
- _separator;
239
- constructor(options) {
240
- this._caps = { left: options?.left ?? "", right: options?.right ?? "" };
241
- this._separator = options?.separator ?? " ";
242
- }
243
- *_emit(left, right, options) {
244
- const caps = drawable(options, this._caps, ASCII_CAPS, capGlyphs);
245
- const separator = drawable(options, this._separator, " ");
246
- if (left === null && right === null)
247
- return;
248
- if (left === null) {
249
- yield new Segment(caps.left, bgAsFg(right.edgeStyle("left", options)));
250
- return;
251
- }
252
- if (right === null) {
253
- yield new Segment(caps.right, bgAsFg(left.edgeStyle("right", options)));
254
- return;
255
- }
256
- // Middle: close the left capsule, separator (unstyled), open the right.
257
- yield new Segment(caps.right, bgAsFg(left.edgeStyle("right", options)));
258
- if (separator.length > 0)
259
- yield new Segment(separator);
260
- yield new Segment(caps.left, bgAsFg(right.edgeStyle("left", options)));
261
- }
262
- join(left, right) {
263
- return deferred((options) => this._emit(left, right, options));
264
- }
286
+ // --- The four joiners as shapes ---
287
+ const LEFT_BG = { edge: "left", part: "bg" };
288
+ const RIGHT_BG = { edge: "right", part: "bg" };
289
+ const LEFT_FG = { edge: "left", part: "fg" };
290
+ const GROUND = "ground";
291
+ /**
292
+ * The powerline joiner: U+E0B0 (right-arrow) in the left ground over the
293
+ * right, divided by U+E0B1 (thin right-arrow) in the left item's text. The
294
+ * lead is the arrow with the left ground replaced by the terminal's — drawn
295
+ * in reverse video it leaves a notch whose base is the cell's left edge, inked
296
+ * in the terminal's background colour. That ink is opaque, so under a
297
+ * translucent terminal the notch does not let what is behind it through. The
298
+ * tail is the arrow over the terminal's ground.
299
+ * ASCII draws `>` everywhere and begins each run flat.
300
+ */
301
+ export const POWERLINE_JOINER = new CellJoiner({
302
+ seam: [{ glyph: "", fg: LEFT_BG, bg: RIGHT_BG }],
303
+ divider: [{ glyph: "", fg: LEFT_FG, bg: LEFT_BG }],
304
+ lead: [{ glyph: "", fg: GROUND, bg: RIGHT_BG }],
305
+ tail: [{ glyph: "", fg: LEFT_BG, bg: GROUND }],
306
+ }, {
307
+ seam: [{ glyph: ">", fg: LEFT_BG, bg: RIGHT_BG }],
308
+ divider: [{ glyph: ">", fg: LEFT_FG, bg: LEFT_BG }],
309
+ lead: [],
310
+ tail: [{ glyph: ">", fg: LEFT_BG, bg: GROUND }],
311
+ });
312
+ function capsule(open, close) {
313
+ const seam = [
314
+ { glyph: close, fg: LEFT_BG, bg: GROUND },
315
+ { glyph: " ", fg: LEFT_FG, bg: GROUND },
316
+ { glyph: open, fg: RIGHT_BG, bg: GROUND },
317
+ ];
318
+ return { seam, divider: seam, lead: [{ glyph: open, fg: RIGHT_BG, bg: GROUND }], tail: [{ glyph: close, fg: LEFT_BG, bg: GROUND }] };
265
319
  }
266
- export class PlainJoiner {
267
- _separator;
268
- _style;
269
- constructor(options) {
270
- this._separator = options?.separator ?? " | ";
271
- this._style = options?.style ?? Style.parse("dim");
272
- }
273
- join(left, right) {
274
- // Endpoints are empty — a fixed separator has no natural cap.
275
- if (left === null || right === null)
276
- return EMPTY;
277
- const separator = this._separator;
278
- const style = this._style;
279
- return deferred((options) => [new Segment(drawable(options, separator, " | "), style)]);
280
- }
320
+ /**
321
+ * Rounded pills: each run opened by U+E0B6 and closed by U+E0B4, both in its
322
+ * ground over the terminal's, a space between pills. ASCII draws `(` and `)`.
323
+ */
324
+ export const CAPSULE_JOINER = new CellJoiner(capsule("", ""), capsule("(", ")"));
325
+ const PLAIN_SEAM = [{ glyph: " | ", fg: { role: "strip.separator" }, bg: GROUND }];
326
+ const PLAIN = { seam: PLAIN_SEAM, divider: PLAIN_SEAM, lead: [], tail: [] };
327
+ /**
328
+ * ` | ` between every pair of items, in the theme's `strip.separator` colour;
329
+ * nothing at the ends. Its shape names no neighbour, so it joins items with no
330
+ * ground too.
331
+ */
332
+ export const PLAIN_JOINER = new CellJoiner(PLAIN, PLAIN);
333
+ function gradient(steps, glyph) {
334
+ // Midpoint sampling across `2 * steps` half-cell positions: cell i takes
335
+ // samples 2i (its ink, the left half) and 2i+1 (its ground, the right half),
336
+ // so no sample equals either neighbour's ground.
337
+ const samples = 2 * steps;
338
+ const seam = Array.from({ length: steps }, (_, i) => ({
339
+ glyph,
340
+ fg: { mix: [LEFT_BG, RIGHT_BG, (2 * i + 0.5) / samples] },
341
+ bg: { mix: [LEFT_BG, RIGHT_BG, (2 * i + 1.5) / samples] },
342
+ }));
343
+ return { seam, divider: seam, lead: [], tail: [] };
281
344
  }
282
345
  /**
283
- * Half-block dithering glyph: paints the cell's left half with the foreground
284
- * colour and the right half with the background colour. Lets each cell carry
285
- * two colour samples — `2 * steps` samples in `steps` cells — so the gradient
286
- * looks twice as smooth as one-colour-per-cell at the same width.
346
+ * A ramp of `steps` cells from the left ground to the right. Each cell is the
347
+ * half block U+258C, ink on its left half and ground on its right, so it
348
+ * carries two samples; ASCII draws a space, keeping the grounds alone. Nothing
349
+ * at the ends, or beside a neighbour with no ground — there is no colour to
350
+ * ramp toward.
287
351
  */
288
- const HALF_BLOCK = "▌"; // ▌
289
- export class GradientJoiner {
290
- _steps;
291
- constructor(options) {
292
- this._steps = options?.steps ?? 4;
293
- }
294
- join(left, right) {
295
- // [LAW:dataflow-not-control-flow] Endpoints have no opposite anchor to
296
- // interpolate toward — the data (a missing neighbor) makes the gradient
297
- // empty. Same for edges with nothing to paint: nothing to blend between.
298
- if (left === null || right === null)
299
- return EMPTY;
300
- const steps = this._steps;
301
- return deferred(function* (options) {
302
- const lbg = paintableBg(left.edgeStyle("right", options).bgcolor);
303
- const rbg = paintableBg(right.edgeStyle("left", options).bgcolor);
304
- if (!lbg || !rbg)
305
- return;
306
- const from = drawnGround(lbg);
307
- const to = drawnGround(rbg);
308
- // An ASCII cell carries one sample, the background: the gradient keeps
309
- // its colours at half the resolution.
310
- const half = drawable(options, HALF_BLOCK, " ");
311
- const samples = 2 * steps;
312
- // Midpoint sampling across `2 * steps` half-cell positions: sample j has
313
- // t = (j + 0.5) / samples. Cell i takes samples 2i (left half) and 2i+1
314
- // (right half). No sample ever equals either anchor.
315
- for (let i = 0; i < steps; i++) {
316
- const tLeft = (2 * i + 0.5) / samples;
317
- const tRight = (2 * i + 1.5) / samples;
318
- // Each sample stays a mix of the two grounds, so a theme that draws
319
- // `blue` in its own shade draws the ramp from that shade.
320
- const fg = ColorSpec.blend(from, to, tLeft);
321
- const bg = ColorSpec.blend(from, to, tRight);
322
- yield new Segment(half, new Style({ color: fg, bgcolor: bg }));
323
- }
324
- });
325
- }
352
+ export function gradientJoiner(steps = 4) {
353
+ return new CellJoiner(gradient(steps, "▌"), gradient(steps, " "));
326
354
  }
@@ -642,8 +642,10 @@ export const DEFAULT_STYLES = {
642
642
  "log.line_no": Style.parse("cyan"),
643
643
  "log.level": NULL_STYLE,
644
644
  // Rule
645
- "rule.line": Style.parse("green"),
646
- "rule.text": Style.parse("bold"),
645
+ "rule.line": Style.parse("bright_green"),
646
+ "rule.text": NULL_STYLE,
647
+ // Strip
648
+ "strip.separator": Style.parse("bright_black"),
647
649
  // JSON
648
650
  "json.brace": Style.parse("bold"),
649
651
  "json.bool_true": Style.parse("italic bright_green"),
@@ -677,9 +679,9 @@ export const DEFAULT_STYLES = {
677
679
  "markdown.link_url": Style.parse("blue"),
678
680
  // Progress
679
681
  "progress.description": NULL_STYLE,
680
- "progress.percentage": Style.parse("cyan"),
682
+ "progress.percentage": Style.parse("magenta"),
681
683
  "progress.remaining": Style.parse("cyan"),
682
- "progress.elapsed": Style.parse("cyan"),
684
+ "progress.elapsed": Style.parse("yellow"),
683
685
  "progress.spinner": Style.parse("green"),
684
686
  "progress.download": Style.parse("green"),
685
687
  "progress.filesize": Style.parse("green"),
@@ -687,9 +689,9 @@ export const DEFAULT_STYLES = {
687
689
  "progress.data.speed": Style.parse("red"),
688
690
  // Bar
689
691
  "bar.back": Style.parse("grey23"),
690
- "bar.complete": Style.parse("magenta"),
691
- "bar.finished": Style.parse("green"),
692
- "bar.pulse": Style.parse("magenta"),
692
+ "bar.complete": Style.parse("rgb(249,38,114)"),
693
+ "bar.finished": Style.parse("rgb(114,156,31)"),
694
+ "bar.pulse": Style.parse("rgb(249,38,114)"),
693
695
  // Scrollbar
694
696
  "scrollbar.thumb": NULL_STYLE,
695
697
  "scrollbar.track": Style.parse("grey37"),