@promptctl/rich-js 0.25.0 → 0.25.1

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.
@@ -2,6 +2,10 @@
2
2
  * Terminal color representation, parsing, downgrading, and ANSI code generation.
3
3
  */
4
4
  import { Memo, WeakMemo } from "./memo.js";
5
+ import { pattern } from "./pattern.js";
6
+ // Runtime-importing `Palette` from `themes/palette.ts` is safe: that module
7
+ // only `import type`s from `core/color.ts`, so there is no runtime cycle.
8
+ import { Palette } from "../themes/palette.js";
5
9
  // --- ColorRgba ---
6
10
  function hex2(byte) {
7
11
  return byte.toString(16).padStart(2, "0");
@@ -950,11 +954,6 @@ export const WINDOWS_TABLE = new ColorTable(buildWindowsTable());
950
954
  // terminal a colour's depth assumes, built from that depth's own sixteen with
951
955
  // an empty Palette — sufficient for the lookups it serves, which never read
952
956
  // `palette`.
953
- //
954
- // Runtime-importing `Palette` from `themes/palette.ts` is safe: that module
955
- // only `import type`s from `core/color.ts`, so there is no runtime cycle.
956
- import { Palette } from "../themes/palette.js";
957
- import { pattern } from "./pattern.js";
958
957
  const INTERNAL_DEFAULT_THEME = new TerminalTheme(new ColorRgba(0, 0, 0), new ColorRgba(255, 255, 255), STANDARD_TABLE, new Palette("default", true, new Map()));
959
958
  /** The Windows console in its default Campbell scheme: a WINDOWS colour's terminal. */
960
959
  const WINDOWS_CONSOLE_THEME = new TerminalTheme(WINDOWS_TABLE.get(0), WINDOWS_TABLE.get(7), WINDOWS_TABLE, new Palette("windows", true, new Map()));
@@ -28,6 +28,7 @@
28
28
  import type { Anchor } from "./anchor.js";
29
29
  import { Segment } from "./segment.js";
30
30
  import { Style } from "./style.js";
31
+ import { ColorDepth, ColorSpec } from "./color.js";
31
32
  import { type Measurable, type Renderable, type RenderOptions } from "./protocol.js";
32
33
  /**
33
34
  * Items in a `Strip` expose their per-edge style so joiners can paint the
@@ -70,21 +71,11 @@ export interface Joiner<T extends StyledRenderable = StyledRenderable> {
70
71
  * not draw at all. An effect reads it to leave a seam out of the words it
71
72
  * keeps legible (`drawnOf`, `../renderables/effect.ts`).
72
73
  *
73
- * Inside it, every cell a `CellJoiner` draws names whose colour each of its
74
- * two sides shows (`colorsOwnedBy`). One seam cell can show two neighbours —
75
- * a powerline arrow is inked in the fill it points out of, over the fill it
76
- * points into — so a cell has no single owner; each side has one.
74
+ * Inside it, every cell a `CellJoiner` draws is stamped with what it was
75
+ * resolved from (`JoinedCell`), so a cell whose neighbours an effect moves is
76
+ * resolved again from where they moved to.
77
77
  */
78
78
  export declare const JOIN: object;
79
- /**
80
- * Which of a cell's two colours on screen are one of `owners`': both, for a
81
- * cell one of them drew, itself or through an owner nested in its output; for
82
- * a cell a `CellJoiner` drew, the sides shown in one of their colours.
83
- */
84
- export declare function colorsOwnedBy(anchor: Anchor | undefined, owners: ReadonlySet<object>): {
85
- readonly fg: boolean;
86
- readonly bg: boolean;
87
- };
88
79
  /**
89
80
  * [LAW:single-enforcer] What `joiner` draws at one join, every cell stamped
90
81
  * as `JOIN`'s: the one way `Strip` and `FlexStrip` draw a join.
@@ -203,6 +194,86 @@ export interface JoinCaps {
203
194
  * difference, because a seam is one cell wide.
204
195
  */
205
196
  export declare const SEAM_MIN_DELTA_E = 0.04;
197
+ /** The parts of each neighbour's edge a join cell paints with. */
198
+ interface CellParts {
199
+ readonly left: ReadonlySet<"fg" | "bg">;
200
+ readonly right: ReadonlySet<"fg" | "bg">;
201
+ }
202
+ /** A neighbour a join cell paints with, and its edge as the cell was resolved from it. */
203
+ export interface JoinSide {
204
+ readonly owner: object;
205
+ readonly edge: Style;
206
+ }
207
+ interface Edges {
208
+ readonly left: Style | undefined;
209
+ readonly right: Style | undefined;
210
+ }
211
+ /**
212
+ * Where an effect moved one slot or both of a neighbour's edge cell to, each
213
+ * the spec the depth writes the slot in, a palette slot as that slot:
214
+ * `color` for its text, `bgcolor` for its ground. A slot left unset did not
215
+ * move.
216
+ */
217
+ export interface EdgeMove {
218
+ readonly color?: ColorSpec;
219
+ readonly bgcolor?: ColorSpec;
220
+ }
221
+ /**
222
+ * A cell a `CellJoiner` drew, as what it is: one function of its two
223
+ * neighbours' edges. It holds the cell's paints, the neighbour each side of
224
+ * them reads, and the edges and render it was resolved from, and it is stamped
225
+ * on the cell as the owner of its innermost anchor level.
226
+ *
227
+ * [LAW:one-source-of-truth] A seam's colours are its neighbours', so nothing
228
+ * paints them but this resolution. An effect paints a strip's items and never
229
+ * a join cell that `follows` them; once the items are painted, each such cell
230
+ * is handed what moved at the item cells beside it and resolved again through
231
+ * `cellStyle`, the path the join was drawn by, lift included
232
+ * (`../renderables/effect.ts`). A seam then follows its neighbours wherever an
233
+ * effect takes them, a mix of the two included, and an effect that paints
234
+ * both one colour leaves the seam's ink lifted off it. A cell painted from
235
+ * roles and the terminal's ground alone, as `plain`'s separator is, follows
236
+ * nobody, and an effect paints it as it paints any cell.
237
+ */
238
+ export declare class JoinedCell {
239
+ private readonly cell;
240
+ private readonly parts;
241
+ private readonly neighbours;
242
+ private readonly edges;
243
+ private readonly options;
244
+ /** What the cell's paints read on each side: that neighbour and its edge as the cell was resolved from it, or `null` for a side they read nothing of. */
245
+ readonly reads: {
246
+ readonly left: JoinSide | null;
247
+ readonly right: JoinSide | null;
248
+ };
249
+ /** Whether the cell's paints read either neighbour, so it follows them under an effect. */
250
+ readonly follows: boolean;
251
+ /** The cell as drawn from its edges: the one resolution, at join time and after an effect alike. */
252
+ readonly style: Style;
253
+ constructor(cell: JoinCell, parts: CellParts, neighbours: {
254
+ readonly left: object | null;
255
+ readonly right: object | null;
256
+ }, edges: Edges, options: RenderOptions);
257
+ /**
258
+ * The cell resolved again once each side's edge has moved as `moves` says:
259
+ * its `colors`, as `cellStyle` draws them, and `style`, the cell's own, with
260
+ * the record of that stamped where this one was, so an effect drawn over
261
+ * this one moves on from these edges. Resolved, lift included, at `depth`,
262
+ * the depth the effect writes the frame at. None when nothing moved of what
263
+ * the cell paints with: a seam of arrows in two grounds is as it was drawn
264
+ * while an effect moves only the lettering beside it, and its lift is not
265
+ * walked again for a frame that cannot change it.
266
+ */
267
+ rederived(moves: {
268
+ readonly left?: EdgeMove;
269
+ readonly right?: EdgeMove;
270
+ }, style: Style, depth: ColorDepth): {
271
+ readonly colors: Style;
272
+ readonly style: Style;
273
+ } | undefined;
274
+ }
275
+ /** The record a `CellJoiner` stamped on the cell `anchor` names, or none for a cell no `CellJoiner` drew. */
276
+ export declare function joinedCell(anchor: Anchor | undefined): JoinedCell | undefined;
206
277
  /**
207
278
  * The one joiner: what it draws is a `JoinSeam` between neighbours and a
208
279
  * `JoinCaps` at the ends of each run, picked independently, so a new joiner is
@@ -308,3 +379,4 @@ export declare function joinerNamed(spec: JoinerSpec): CellJoiner;
308
379
  * draws the caps the ramp is ended in.
309
380
  */
310
381
  export declare function gradientSeam(steps?: number): JoinSeam;
382
+ export {};
@@ -39,41 +39,11 @@ import { drawable, getStyle, isAscii, render } from "./protocol.js";
39
39
  * not draw at all. An effect reads it to leave a seam out of the words it
40
40
  * keeps legible (`drawnOf`, `../renderables/effect.ts`).
41
41
  *
42
- * Inside it, every cell a `CellJoiner` draws names whose colour each of its
43
- * two sides shows (`colorsOwnedBy`). One seam cell can show two neighbours —
44
- * a powerline arrow is inked in the fill it points out of, over the fill it
45
- * points into — so a cell has no single owner; each side has one.
42
+ * Inside it, every cell a `CellJoiner` draws is stamped with what it was
43
+ * resolved from (`JoinedCell`), so a cell whose neighbours an effect moves is
44
+ * resolved again from where they moved to.
46
45
  */
47
46
  export const JOIN = Object.freeze({ name: "join" });
48
- /**
49
- * Whose colour each side of a join cell shows on screen, its ink and its
50
- * ground: the neighbour whose edge a `Paint` names, or `null` for a role, a
51
- * mix or the terminal's ground, which are no neighbour's.
52
- */
53
- class ColorOwners {
54
- fg;
55
- bg;
56
- constructor(fg, bg) {
57
- this.fg = fg;
58
- this.bg = bg;
59
- }
60
- }
61
- /**
62
- * Which of a cell's two colours on screen are one of `owners`': both, for a
63
- * cell one of them drew, itself or through an owner nested in its output; for
64
- * a cell a `CellJoiner` drew, the sides shown in one of their colours.
65
- */
66
- export function colorsOwnedBy(anchor, owners) {
67
- for (let level = anchor; level; level = level.inner) {
68
- if (owners.has(level.owner))
69
- return { fg: true, bg: true };
70
- if (level.owner instanceof ColorOwners) {
71
- const { fg, bg } = level.owner;
72
- return { fg: fg !== null && owners.has(fg), bg: bg !== null && owners.has(bg) };
73
- }
74
- }
75
- return { fg: false, bg: false };
76
- }
77
47
  /**
78
48
  * [LAW:single-enforcer] What `joiner` draws at one join, every cell stamped
79
49
  * as `JOIN`'s: the one way `Strip` and `FlexStrip` draw a join.
@@ -262,9 +232,12 @@ function parseCaps(raw) {
262
232
  function joinForms(seam, caps) {
263
233
  return { seam: seam.seam.flatMap((cell) => ("cap" in cell ? caps[cell.cap] : [cell])), lead: caps.lead, tail: caps.tail };
264
234
  }
235
+ // The parts of `side`'s edge `cell` paints with: its ground (`bg`), its text (`fg`).
236
+ const partsRead = (cell, side) => new Set([cell.fg, cell.bg].flatMap((color) => (color === "ground" ? [] : [...paintsIn(color)].flatMap((p) => ("edge" in p && p.edge === side ? [p.part] : [])))));
265
237
  // Whether any of `cells` paints with `side`'s ground.
266
238
  const readsGround = (cells, side) => cells.some((cell) => [cell.fg, cell.bg].some((color) => color !== "ground" && [...paintsIn(color)].some((p) => "edge" in p && p.edge === side && p.part === "bg")));
267
239
  function formOf(shape) {
240
+ const cells = (part) => shape[part].map((cell) => ({ cell, parts: { left: partsRead(cell, "left"), right: partsRead(cell, "right") } }));
268
241
  return {
269
242
  shape,
270
243
  reads: {
@@ -272,6 +245,7 @@ function formOf(shape) {
272
245
  lead: readsGround(shape.lead, "right"),
273
246
  tail: readsGround(shape.tail, "left"),
274
247
  },
248
+ cells: { seam: cells("seam"), lead: cells("lead"), tail: cells("tail") },
275
249
  };
276
250
  }
277
251
  // A bg with a real colour to paint. `undefined` (no bg) and the terminal
@@ -380,6 +354,9 @@ function resolvePaint(paint, edges, options) {
380
354
  // A mix with no colour at one end has nothing to blend toward.
381
355
  return a && b && ColorSpec.blend(a, b, t);
382
356
  }
357
+ // [LAW:single-enforcer] The one resolution of a join cell's paints: at join
358
+ // time, and again whenever an effect moves the neighbours it reads
359
+ // (`JoinedCell`).
383
360
  function cellStyle(cell, edges, options) {
384
361
  // [LAW:dataflow-not-control-flow] "ground" is the absence of a colour on its
385
362
  // side; as the fg it is reverse video over the bg colour, so the glyph's ink
@@ -391,6 +368,84 @@ function cellStyle(cell, edges, options) {
391
368
  // the gap measured between them decides how far it moves, usually not at all.
392
369
  return new Style({ color: standOff(resolvePaint(cell.fg, edges, options), bgcolor, options.colorSystem ?? ColorDepth.TRUECOLOR), bgcolor });
393
370
  }
371
+ /**
372
+ * A cell a `CellJoiner` drew, as what it is: one function of its two
373
+ * neighbours' edges. It holds the cell's paints, the neighbour each side of
374
+ * them reads, and the edges and render it was resolved from, and it is stamped
375
+ * on the cell as the owner of its innermost anchor level.
376
+ *
377
+ * [LAW:one-source-of-truth] A seam's colours are its neighbours', so nothing
378
+ * paints them but this resolution. An effect paints a strip's items and never
379
+ * a join cell that `follows` them; once the items are painted, each such cell
380
+ * is handed what moved at the item cells beside it and resolved again through
381
+ * `cellStyle`, the path the join was drawn by, lift included
382
+ * (`../renderables/effect.ts`). A seam then follows its neighbours wherever an
383
+ * effect takes them, a mix of the two included, and an effect that paints
384
+ * both one colour leaves the seam's ink lifted off it. A cell painted from
385
+ * roles and the terminal's ground alone, as `plain`'s separator is, follows
386
+ * nobody, and an effect paints it as it paints any cell.
387
+ */
388
+ export class JoinedCell {
389
+ cell;
390
+ parts;
391
+ neighbours;
392
+ edges;
393
+ options;
394
+ /** What the cell's paints read on each side: that neighbour and its edge as the cell was resolved from it, or `null` for a side they read nothing of. */
395
+ reads;
396
+ /** Whether the cell's paints read either neighbour, so it follows them under an effect. */
397
+ follows;
398
+ /** The cell as drawn from its edges: the one resolution, at join time and after an effect alike. */
399
+ style;
400
+ constructor(cell, parts, neighbours, edges, options) {
401
+ this.cell = cell;
402
+ this.parts = parts;
403
+ this.neighbours = neighbours;
404
+ this.edges = edges;
405
+ this.options = options;
406
+ const read = (side) => {
407
+ if (parts[side].size === 0)
408
+ return null;
409
+ const [owner, edge] = [neighbours[side], edges[side]];
410
+ // [LAW:no-silent-failure] Unreachable: a join draws a part only beside
411
+ // the neighbours its cells may name: a cap naming the one it is drawn
412
+ // without is refused when parsed (`parsePaint`).
413
+ if (owner === null || edge === undefined)
414
+ throw new Error(`a join cell paints with its ${side} neighbour's edge, and the join has no ${side} neighbour`);
415
+ return { owner, edge };
416
+ };
417
+ this.reads = { left: read("left"), right: read("right") };
418
+ this.follows = this.reads.left !== null || this.reads.right !== null;
419
+ this.style = cellStyle(cell, edges, options);
420
+ }
421
+ /**
422
+ * The cell resolved again once each side's edge has moved as `moves` says:
423
+ * its `colors`, as `cellStyle` draws them, and `style`, the cell's own, with
424
+ * the record of that stamped where this one was, so an effect drawn over
425
+ * this one moves on from these edges. Resolved, lift included, at `depth`,
426
+ * the depth the effect writes the frame at. None when nothing moved of what
427
+ * the cell paints with: a seam of arrows in two grounds is as it was drawn
428
+ * while an effect moves only the lettering beside it, and its lift is not
429
+ * walked again for a frame that cannot change it.
430
+ */
431
+ rederived(moves, style, depth) {
432
+ const reaches = (side) => (moves[side]?.color !== undefined && this.parts[side].has("fg")) || (moves[side]?.bgcolor !== undefined && this.parts[side].has("bg"));
433
+ if (!reaches("left") && !reaches("right"))
434
+ return undefined;
435
+ const moved = (edge, move) => move === undefined ? edge : new Style({ color: move.color ?? edge?.color, bgcolor: move.bgcolor ?? edge?.bgcolor });
436
+ const edges = { left: moved(this.edges.left, moves.left), right: moved(this.edges.right, moves.right) };
437
+ const next = new JoinedCell(this.cell, this.parts, this.neighbours, edges, { ...this.options, colorSystem: depth });
438
+ const restamp = (level) => ({ ...level, owner: level.owner === this ? next : level.owner, inner: level.inner && restamp(level.inner) });
439
+ return { colors: next.style, style: style.withAnchor(style.anchor && restamp(style.anchor)) };
440
+ }
441
+ }
442
+ /** The record a `CellJoiner` stamped on the cell `anchor` names, or none for a cell no `CellJoiner` drew. */
443
+ export function joinedCell(anchor) {
444
+ for (let level = anchor; level; level = level.inner)
445
+ if (level.owner instanceof JoinedCell)
446
+ return level.owner;
447
+ return undefined;
448
+ }
394
449
  const shapeGlyphs = (shape) => PARTS.flatMap((part) => shape[part].flatMap(glyphsOf)).join("");
395
450
  /**
396
451
  * The one joiner: what it draws is a `JoinSeam` between neighbours and a
@@ -422,7 +477,7 @@ export class CellJoiner {
422
477
  const forms = this._forms;
423
478
  return {
424
479
  *render(options) {
425
- const { shape: drawn, reads } = drawable(options, forms.glyph, forms.ascii, (form) => shapeGlyphs(form.shape));
480
+ const { cells, reads } = drawable(options, forms.glyph, forms.ascii, (form) => shapeGlyphs(form.shape));
426
481
  const edges = { left: left?.edgeStyle("right", options), right: right?.edgeStyle("left", options) };
427
482
  const leftBg = paintableBg(edges.left?.bgcolor);
428
483
  const rightBg = paintableBg(edges.right?.bgcolor);
@@ -431,12 +486,9 @@ export class CellJoiner {
431
486
  const leads = hasRight && (!reads.lead || rightBg !== undefined);
432
487
  const tails = hasLeft && (!reads.tail || leftBg !== undefined);
433
488
  const neighbours = { left, right };
434
- // On screen a cell's ink is its `fg` paint and its ground its `bg`,
435
- // reverse video or not (`cellStyle`).
436
- const ownerOf = (color) => (color !== "ground" && "edge" in color ? neighbours[color.edge] : null);
437
- const styled = (part) => drawn[part].map((cell, at) => {
438
- const owners = new ColorOwners(ownerOf(cell.fg), ownerOf(cell.bg));
439
- return new Segment(pickGlyph(cell, part, index, at), cellStyle(cell, edges, options).withAnchor({ owner: owners, row: 0, col: 0, inner: undefined }));
489
+ const styled = (part) => cells[part].map(({ cell, parts }, at) => {
490
+ const joined = new JoinedCell(cell, parts, neighbours, edges, options);
491
+ return new Segment(pickGlyph(cell, part, index, at), joined.style.withAnchor({ owner: joined, row: 0, col: 0, inner: undefined }));
440
492
  });
441
493
  if (!hasLeft || !hasRight) {
442
494
  if (leads || tails)
@@ -8,11 +8,16 @@
8
8
  * (`EffectFrame`), and it says when that next changes as every
9
9
  * animation does. Whatever holds for the whole frame is worked out once, in
10
10
  * the effect and its `at(t)`, and every cell of the frame is handed to the
11
- * function `at(t)` returns, but for those a wrapper keeps back: `confined`
11
+ * function `at(t)` returns, but for those a wrapper keeps back — `confined`
12
12
  * hands over only the cells its owners drew, so an effect never counts on
13
- * seeing them all. Shimmer, pulse, fade, dissolve and a blinking attribute
14
- * are all this one shape, differing by what the function does with its
15
- * inputs [LAW:one-type-per-behavior]; two effects stack by nesting one
13
+ * seeing them all — and for a strip's join cells painted from the items beside
14
+ * them, which are no one's to paint: a seam's colours are its neighbours'
15
+ * edges, so once the effect has painted every other cell, each such cell is
16
+ * resolved again from the cells beside it as the effect left them
17
+ * (`JoinedCell`, `../core/strip.ts`), the way the joiner drew it. Shimmer,
18
+ * pulse, fade, dissolve and a blinking attribute are all this one shape,
19
+ * differing by what the function does with its inputs
20
+ * [LAW:one-type-per-behavior]; two effects stack by nesting one
16
21
  * wrapper in another, which is the same as calling one after the other but
17
22
  * for what the outer one measures: the inner one's output.
18
23
  *
@@ -149,9 +154,12 @@ export interface Drawn {
149
154
  * sits and who drew it, listed before the effect is handed any: what
150
155
  * an effect spread over the element — a transition, which has its last cell
151
156
  * settle as it ends — is spread over. A blank cell on the terminal's ground
152
- * that draws no line is handed but not listed, since nothing in it shows.
153
- * `confined` narrows it to the cells its owners drew. A call, so a frame
154
- * whose effect does not ask lists none.
157
+ * that draws no line is handed but not listed, since nothing in it shows,
158
+ * and a strip's join cell painted from the items beside it is listed but
159
+ * never handed its own look, since it follows them: it is handed only a
160
+ * neighbour's edge the frame does not show, to be painted where the seam
161
+ * shows it. `confined` narrows it to the cells its owners drew. A call, so a
162
+ * frame whose effect does not ask lists none.
155
163
  */
156
164
  cells(): readonly Placed[];
157
165
  /** Every distinct ink and ground of a word cell. */
@@ -220,6 +228,16 @@ export declare class Recolored implements Renderable, Measurable {
220
228
  /** The frame the effect draws at `t`, and the hold it says that is in. */
221
229
  moved(options: RenderOptions): Moved;
222
230
  private _recolored;
231
+ /** `segment`'s cells drawn in the looks the effect painted them. */
232
+ private _repainted;
233
+ /**
234
+ * A join cell's `segment` resolved again from the edges beside it, as the
235
+ * effect left them: each neighbour's nearest cell to the join on its row,
236
+ * its slots as the depth writes them. A side whose neighbour the effect moved
237
+ * nothing of keeps the edge the cell was drawn from, so with neither moved
238
+ * the cell is as it was drawn.
239
+ */
240
+ private _rejoined;
223
241
  measure(options: RenderOptions): {
224
242
  minimum: number;
225
243
  maximum: number;
@@ -297,13 +315,12 @@ export declare function walkGlyphs(segments: readonly Segment[]): (readonly Walk
297
315
  */
298
316
  export declare function inked(text: string): boolean;
299
317
  /**
300
- * `effect` on the colours any of `owners` drew, itself or through an owner
301
- * nested in its output, and every other colour left as it is: one item of a
318
+ * `effect` on the cells any of `owners` drew, itself or through an owner
319
+ * nested in its output, and every other cell left as it is: one item of a
302
320
  * strip whose items share a fill, the items of a row one other item stands
303
- * apart from, one widget of a screen. A seam cell shows its two neighbours
304
- * side by side, a powerline arrow in the fill it points out of over the fill
305
- * it points into, so each side moves with its own item (`colorsOwnedBy`,
306
- * `../core/strip.ts`). A cell none of them drew is never handed to the
321
+ * apart from, one widget of a screen. A strip's join cell is no item's, so it
322
+ * is never handed; one painted from the items beside it follows them, the
323
+ * moved ones included (`Recolored`). A cell none of them drew is never handed to the
307
324
  * effect, so confining one effect to many owners costs one call a cell, not
308
325
  * one for each owner.
309
326
  */
@@ -8,11 +8,16 @@
8
8
  * (`EffectFrame`), and it says when that next changes as every
9
9
  * animation does. Whatever holds for the whole frame is worked out once, in
10
10
  * the effect and its `at(t)`, and every cell of the frame is handed to the
11
- * function `at(t)` returns, but for those a wrapper keeps back: `confined`
11
+ * function `at(t)` returns, but for those a wrapper keeps back — `confined`
12
12
  * hands over only the cells its owners drew, so an effect never counts on
13
- * seeing them all. Shimmer, pulse, fade, dissolve and a blinking attribute
14
- * are all this one shape, differing by what the function does with its
15
- * inputs [LAW:one-type-per-behavior]; two effects stack by nesting one
13
+ * seeing them all — and for a strip's join cells painted from the items beside
14
+ * them, which are no one's to paint: a seam's colours are its neighbours'
15
+ * edges, so once the effect has painted every other cell, each such cell is
16
+ * resolved again from the cells beside it as the effect left them
17
+ * (`JoinedCell`, `../core/strip.ts`), the way the joiner drew it. Shimmer,
18
+ * pulse, fade, dissolve and a blinking attribute are all this one shape,
19
+ * differing by what the function does with its inputs
20
+ * [LAW:one-type-per-behavior]; two effects stack by nesting one
16
21
  * wrapper in another, which is the same as calling one after the other but
17
22
  * for what the outer one measures: the inner one's output.
18
23
  *
@@ -71,7 +76,7 @@ import { fnv1a } from "../core/fnv1a.js";
71
76
  import { Measurement } from "../core/measure.js";
72
77
  import { render } from "../core/protocol.js";
73
78
  import { Segment } from "../core/segment.js";
74
- import { JOIN, colorsOwnedBy } from "../core/strip.js";
79
+ import { JOIN, joinedCell } from "../core/strip.js";
75
80
  import { ATTRIBUTE_NAMES, Style } from "../core/style.js";
76
81
  import { finiteTime } from "../core/easing.js";
77
82
  /** What drew nothing the effect can change: held forever, as far as the effect says. */
@@ -118,48 +123,154 @@ export class Recolored {
118
123
  return { segments: this._recolored(walked, shown.value, depth), hold: holdOf(shown, this.t) };
119
124
  }
120
125
  *_recolored(walked, frame, depth) {
121
- for (const { segment, look, glyphs } of walked) {
122
- if (look === undefined) {
123
- yield segment;
124
- continue;
125
- }
126
- const { style: base, wire, slots, pair, attributes } = look;
127
- const { terminal } = pair;
128
- const from = { fg: pair.fg, bg: pair.bg, attributes };
129
- // [LAW:one-source-of-truth] A run is cells the wire writes alike: each
130
- // cell is drawn at the output depth first and merged on the colours that
131
- // writes and on its attributes, so two truecolors landing on one index
132
- // stay one run. A cell the effect gave its neighbour's look is drawn as
133
- // that neighbour was.
134
- const runs = [];
135
- for (const { glyph, cell } of glyphs) {
136
- const moved = cell === undefined ? from : returned(frame(from, { ...cell, seed: cellSeed(this.key, cell.row, cell.col), terminal, reverse: pair.reverse }));
137
- // Back from the screen to the style's slots: the same swap undoes itself.
138
- const colors = onScreen(moved, pair.reverse);
139
- const last = runs[runs.length - 1];
140
- const writes = last !== undefined && sameColors(last.colors, colors) ? last.drawn : this.respec(wire, slots, colors, depth);
141
- if (last !== undefined && writesAlike(last.drawn, writes) && sameAttributes(last.attributes, moved.attributes)) {
142
- last.text += glyph;
143
- last.cells += cellLen(glyph);
144
- last.colors = colors;
145
- }
146
- else {
147
- runs.push({ text: glyph, cells: cellLen(glyph), colors, attributes: moved.attributes, drawn: writes });
148
- }
149
- }
150
- if (runs.every((run) => writesAlike(run.drawn, wire) && sameAttributes(run.attributes, attributes))) {
126
+ // [LAW:one-source-of-truth] Every cell but a join cell that follows its
127
+ // neighbours is painted first, so that cell is resolved from the cells
128
+ // beside it as the effect left them, wherever in the frame they fall.
129
+ const drawings = walked.map(({ look, glyphs }) => {
130
+ if (look === undefined)
131
+ return undefined;
132
+ const joined = joinedCell(look.style.anchor);
133
+ if (joined?.follows === true)
134
+ return { joined };
135
+ const from = { fg: look.pair.fg, bg: look.pair.bg, attributes: look.attributes };
136
+ const { terminal, reverse } = look.pair;
137
+ return { painted: glyphs.map(({ cell }) => (cell === undefined ? from : returned(frame(from, { ...cell, seed: cellSeed(this.key, cell.row, cell.col), terminal, reverse })))) };
138
+ });
139
+ const beside = edgesBeside(walked, drawings);
140
+ for (const [i, { segment, look, glyphs }] of walked.entries()) {
141
+ const drawing = drawings[i];
142
+ if (look === undefined || drawing === undefined)
151
143
  yield segment;
152
- continue;
144
+ else if ("joined" in drawing)
145
+ yield this._rejoined(segment, look, drawing.joined, glyphs, beside, frame, depth);
146
+ else
147
+ yield* this._repainted(segment, look, glyphs, drawing.painted, depth);
148
+ }
149
+ }
150
+ /** `segment`'s cells drawn in the looks the effect painted them. */
151
+ *_repainted(segment, look, glyphs, moved, depth) {
152
+ const { style: base, wire, slots, pair, attributes } = look;
153
+ // [LAW:one-source-of-truth] A run is cells the wire writes alike: each
154
+ // cell is drawn at the output depth first and merged on the colours that
155
+ // writes and on its attributes, so two truecolors landing on one index
156
+ // stay one run. A cell the effect gave its neighbour's look is drawn as
157
+ // that neighbour was.
158
+ const runs = [];
159
+ for (const [k, { glyph }] of glyphs.entries()) {
160
+ const now = moved[k];
161
+ // Back from the screen to the style's slots: the same swap undoes itself.
162
+ const colors = onScreen(now, pair.reverse);
163
+ const last = runs[runs.length - 1];
164
+ const writes = last !== undefined && sameColors(last.colors, colors) ? last.drawn : this.respec(wire, slots, colors, depth);
165
+ if (last !== undefined && writesAlike(last.drawn, writes) && sameAttributes(last.attributes, now.attributes)) {
166
+ last.text += glyph;
167
+ last.cells += cellLen(glyph);
168
+ last.colors = colors;
153
169
  }
154
- let offset = 0;
155
- for (const run of runs) {
156
- const placed = base.shiftedBy(offset);
157
- const shifted = sameAttributes(run.attributes, attributes) ? placed : placed.withAttributes(cellAttributes(run.attributes));
158
- const style = writesAlike(run.drawn, wire) ? shifted : shifted.add(Style.fromColor(run.drawn.color, run.drawn.bgcolor));
159
- yield new Segment(run.text, style);
160
- offset += run.cells;
170
+ else {
171
+ runs.push({ text: glyph, cells: cellLen(glyph), colors, attributes: now.attributes, drawn: writes });
161
172
  }
162
173
  }
174
+ if (runs.every((run) => writesAlike(run.drawn, wire) && sameAttributes(run.attributes, attributes))) {
175
+ yield segment;
176
+ return;
177
+ }
178
+ let offset = 0;
179
+ for (const run of runs) {
180
+ const placed = base.shiftedBy(offset);
181
+ const shifted = sameAttributes(run.attributes, attributes) ? placed : placed.withAttributes(cellAttributes(run.attributes));
182
+ const style = writesAlike(run.drawn, wire) ? shifted : shifted.add(Style.fromColor(run.drawn.color, run.drawn.bgcolor));
183
+ yield new Segment(run.text, style);
184
+ offset += run.cells;
185
+ }
186
+ }
187
+ /**
188
+ * A join cell's `segment` resolved again from the edges beside it, as the
189
+ * effect left them: each neighbour's nearest cell to the join on its row,
190
+ * its slots as the depth writes them. A side whose neighbour the effect moved
191
+ * nothing of keeps the edge the cell was drawn from, so with neither moved
192
+ * the cell is as it was drawn.
193
+ */
194
+ _rejoined(segment, look, joined, glyphs, beside, frame, depth) {
195
+ const cells = glyphs.flatMap(({ cell }) => (cell === undefined ? [] : [cell]));
196
+ // A join cell that draws no cell shows nothing an effect could move.
197
+ const [at] = cells;
198
+ if (at === undefined)
199
+ return segment;
200
+ const { theme } = this;
201
+ // [LAW:one-source-of-truth] A moved edge is the spec the depth writes its
202
+ // slot in, the spec the joiner reads of an item drawn so: a palette slot
203
+ // stays that slot, which the lift tells apart by number, never an RGB read
204
+ // back through a palette the writer did not use. Only the terminal's own
205
+ // colour names none, and the terminal the effect draws on says what it is.
206
+ // Each such spec is kept, so the writer below knows it by identity: an
207
+ // RGB a blend or another colour lands on is not the terminal's own.
208
+ const terminals = new Set();
209
+ const edgeOf = (spec, foreground) => {
210
+ if (!spec?.isDefault)
211
+ return spec;
212
+ const own = ColorSpec.fromRgba(spec.getTruecolor(theme, foreground));
213
+ terminals.add(own);
214
+ return own;
215
+ };
216
+ const moved = (from, to) => {
217
+ const specs = movedSpecs(from, to, depth, theme);
218
+ return specs.fg === undefined && specs.bg === undefined ? undefined : { color: edgeOf(specs.fg, true), bgcolor: edgeOf(specs.bg, false) };
219
+ };
220
+ // [LAW:one-source-of-truth] A neighbour the frame shows no cell of beside
221
+ // the join (cut off by a narrower container, scrolled out of a `Viewport`,
222
+ // covered, or an item that draws nothing) is painted by the effect where
223
+ // the seam shows it: its edge, as the seam drew it, handed to the frame at
224
+ // the seam's own cell. An effect over the whole element moves it as it
225
+ // moves everything there; one confined to some owners leaves it, since no
226
+ // owner drew the seam. Where the frame is whole and every item draws, each
227
+ // neighbour a seam reads has a cell on its row, so only a partial frame
228
+ // comes here [LAW:no-silent-failure].
229
+ const unseen = (edge) => {
230
+ const wire = edge.drawnColors(depth);
231
+ const slots = { fg: (wire.color ?? DEFAULT).getTruecolor(theme, true), bg: (wire.bgcolor ?? DEFAULT).getTruecolor(theme, false) };
232
+ const terminal = { fg: wire.color?.isDefault ?? true, bg: wire.bgcolor?.isDefault ?? true };
233
+ return moved(slots, returned(frame({ ...slots, attributes: cellAttributes(edge) }, { ...at, seed: cellSeed(this.key, at.row, at.col), terminal, reverse: false })));
234
+ };
235
+ const moveOf = (side) => {
236
+ const read = joined.reads[side];
237
+ if (read === null)
238
+ return undefined;
239
+ const cell = beside(cells, side, read.owner);
240
+ return cell === undefined ? unseen(read.edge) : moved(cell.look.slots, onScreen(cell.moved, cell.look.pair.reverse));
241
+ };
242
+ const rederived = joined.rederived({ left: moveOf("left"), right: moveOf("right") }, look.style, depth);
243
+ if (rederived === undefined)
244
+ return segment;
245
+ const { colors, style } = rederived;
246
+ // [LAW:one-source-of-truth] Each slot is written as the depth writes what
247
+ // it resolved to, by the writer the lift measured it with
248
+ // (`Style.drawnColors`), so a lifted ink stands off its ground in the bytes
249
+ // as it did when measured. A slot resolved to the colour it was before
250
+ // keeps the spec it was written in, as a painted cell's unmoved slot does.
251
+ // The terminal's own colours, which stand in a moved edge as the spec of
252
+ // the RGB the effect's terminal gives them and reach a slot unchanged or
253
+ // not at all, are written by the writer that wrote them on the item
254
+ // beside, on that terminal, which writes its own ground below truecolor as
255
+ // `default`.
256
+ const was = joined.style;
257
+ const drawn = colors.drawnColors(depth);
258
+ const { wire, slots } = look;
259
+ const rounded = this.respec(wire, slots, { fg: colors.color?.getTruecolor(theme, true) ?? slots.fg, bg: colors.bgcolor?.getTruecolor(theme, false) ?? slots.bg }, depth);
260
+ const written = (now, before, wrote, drew, round) => {
261
+ if (sameSpec(now, before))
262
+ return wrote;
263
+ // [LAW:no-silent-failure] Unreachable: a move only fills an edge in, so
264
+ // a slot that resolved to a colour when drawn resolves to one after.
265
+ if (now === undefined)
266
+ throw new Error(`a seam cell resolved to ${before?.name ?? "nothing"} when drawn and to nothing after an effect moved its neighbours`);
267
+ return terminals.has(now) ? round : drew;
268
+ };
269
+ const writes = {
270
+ color: written(colors.color, was.color, wire.color, drawn.color, rounded.color),
271
+ bgcolor: written(colors.bgcolor, was.bgcolor, wire.bgcolor, drawn.bgcolor, rounded.bgcolor),
272
+ };
273
+ return new Segment(segment.text, writesAlike(writes, wire) ? style : style.add(Style.fromColor(writes.color, writes.bgcolor)));
163
274
  }
164
275
  measure(options) {
165
276
  return Measurement.get(options, this.renderable);
@@ -293,6 +404,45 @@ function walk(looked) {
293
404
  const placed = walkGlyphs(looked.map(({ segment }) => segment));
294
405
  return looked.map((item, i) => ({ ...item, glyphs: placed[i] }));
295
406
  }
407
+ /**
408
+ * Where to find the cells beside a join in `walked`, painted as `painted` says.
409
+ * Read by owner, never by column alone: a `FlexStrip` can set a gap or a
410
+ * justified fill between a seam and the items it joins.
411
+ */
412
+ function edgesBeside(walked, drawings) {
413
+ let rows;
414
+ const index = () => {
415
+ const byRow = new Map();
416
+ walked.forEach(({ look, glyphs }, i) => {
417
+ const drawing = drawings[i];
418
+ if (look === undefined || drawing === undefined || !("painted" in drawing))
419
+ return;
420
+ const moved = drawing.painted;
421
+ glyphs.forEach(({ cell }, k) => {
422
+ if (cell === undefined)
423
+ return;
424
+ const row = byRow.get(cell.row) ?? [];
425
+ row.push({ col: cell.col, anchor: cell.anchor, look, moved: moved[k] });
426
+ byRow.set(cell.row, row);
427
+ });
428
+ });
429
+ return byRow;
430
+ };
431
+ return (cells, side, owner) => {
432
+ // Cells on a row are listed left to right, as the walk places them.
433
+ const row = (rows ??= index()).get(cells[0].row) ?? [];
434
+ const first = cells[0].col;
435
+ const last = cells[cells.length - 1].col;
436
+ if (side === "right")
437
+ return row.find((cell) => cell.col > last && drawnBy(cell.anchor, owner));
438
+ for (let k = row.length - 1; k >= 0; k--) {
439
+ const cell = row[k];
440
+ if (cell.col < first && drawnBy(cell.anchor, owner))
441
+ return cell;
442
+ }
443
+ return undefined;
444
+ };
445
+ }
296
446
  /** Whether `style` draws in its ink on a blank glyph: a line under, through or over it, or a frame round it. */
297
447
  function drawsLines(style) {
298
448
  return [style.underline, style.underline2, style.strike, style.overline, style.frame, style.encircle].some((on) => on === true);
@@ -327,32 +477,28 @@ function measured(looked, walked, depth, theme) {
327
477
  };
328
478
  }
329
479
  /**
330
- * `effect` on the colours any of `owners` drew, itself or through an owner
331
- * nested in its output, and every other colour left as it is: one item of a
480
+ * `effect` on the cells any of `owners` drew, itself or through an owner
481
+ * nested in its output, and every other cell left as it is: one item of a
332
482
  * strip whose items share a fill, the items of a row one other item stands
333
- * apart from, one widget of a screen. A seam cell shows its two neighbours
334
- * side by side, a powerline arrow in the fill it points out of over the fill
335
- * it points into, so each side moves with its own item (`colorsOwnedBy`,
336
- * `../core/strip.ts`). A cell none of them drew is never handed to the
483
+ * apart from, one widget of a screen. A strip's join cell is no item's, so it
484
+ * is never handed; one painted from the items beside it follows them, the
485
+ * moved ones included (`Recolored`). A cell none of them drew is never handed to the
337
486
  * effect, so confining one effect to many owners costs one call a cell, not
338
487
  * one for each owner.
339
488
  */
340
489
  export function confined(effect, ...owners) {
341
490
  const confinedTo = new Set(owners);
342
- // [LAW:single-enforcer] One reading of which slots are owned, for the cells listed and the cells handed alike.
343
- const ownedOf = (cell) => colorsOwnedBy(cell.anchor, confinedTo);
344
- const any = ({ fg, bg }) => fg || bg;
491
+ // [LAW:single-enforcer] One reading of whose a cell is, for the cells listed and the cells handed alike.
492
+ const owned = (cell) => {
493
+ for (let level = cell.anchor; level; level = level.inner)
494
+ if (confinedTo.has(level.owner))
495
+ return true;
496
+ return false;
497
+ };
345
498
  // The frame as the effect sees it: the owners' cells alone. [LAW:one-source-of-truth] One narrowing, handed to both stages.
346
- const owned = (drawn) => ({ ...drawn, cells: () => drawn.cells().filter((cell) => any(ownedOf(cell))) });
499
+ const narrowed = (drawn) => ({ ...drawn, cells: () => drawn.cells().filter(owned) });
347
500
  // The owners' cells change when the effect's frame does.
348
- return (drawn) => Animation.map(effect(owned(drawn)), (frame) => (look, cell) => {
349
- const owns = ownedOf(cell);
350
- if (!any(owns))
351
- return look;
352
- const moved = frame(look, cell);
353
- // An attribute is drawn on the glyph, in its ink, so it moves with whoever owns the ink.
354
- return { fg: owns.fg ? moved.fg : look.fg, bg: owns.bg ? moved.bg : look.bg, attributes: owns.fg ? moved.attributes : look.attributes };
355
- });
501
+ return (drawn) => Animation.map(effect(narrowed(drawn)), (frame) => (look, cell) => (owned(cell) ? frame(look, cell) : look));
356
502
  }
357
503
  /**
358
504
  * What `segments`, drawn at `depth` on `theme`, draw their words in, measured
@@ -424,6 +570,10 @@ export function cellAttributes(of) {
424
570
  function sameAttributes(a, b) {
425
571
  return a === b || MOVED.every((name) => a[name] === b[name]);
426
572
  }
573
+ /** Whether two specs are one colour as written: the same kind, naming the same colour. */
574
+ function sameSpec(a, b) {
575
+ return a === b || (a !== undefined && b !== undefined && a.type === b.type && a.name === b.name);
576
+ }
427
577
  function sameColors(a, b) {
428
578
  return sameColor(a.fg, b.fg) && sameColor(a.bg, b.bg);
429
579
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/rich-js",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "Rich text and beautiful formatting in the terminal — a TypeScript port of Python's Rich",
5
5
  "repository": {
6
6
  "type": "git",