@promptctl/rich-js 0.25.2 → 0.26.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.
package/dist/index.d.ts CHANGED
@@ -61,7 +61,7 @@ export type { ConsoleOptions, ConsoleSink, ConsoleStream, ConsoleEnvironment, Li
61
61
  export type { Env } from "./core/env.js";
62
62
  export { Constrain } from "./renderables/constrain.js";
63
63
  export { Effected, confined, drawnOf } from "./renderables/effect.js";
64
- export type { CellAttributes, CellColors, CellLook, Drawn, Effect, EffectCell, EffectedOptions, EffectFrame, Pair, Placed } from "./renderables/effect.js";
64
+ export type { CellAttributes, CellColors, CellLook, Drawn, Effect, EffectCell, EffectedOptions, EffectFrame, ListedCell, Pair, Placed } from "./renderables/effect.js";
65
65
  export { ATTRIBUTE_DEFAULTS, EFFECT_CATALOGUE, EFFECT_CURVES, EFFECT_LIGHTS, blink, dissolveOut, fadeIn, gradient, onBeat, onColors, pulse, shimmer, sparkle, steppedInk, wheel, } from "./renderables/effects.js";
66
66
  export type { AttributeLoopName, Blink, ColorStep, Curve, EffectTarget, Field, Loop, LoopName, Touch, Transition, TransitionName } from "./renderables/effects.js";
67
67
  export { crossfade, wipe } from "./renderables/change.js";
@@ -125,6 +125,20 @@ export interface EffectCell {
125
125
  }
126
126
  /** Where a cell sits and who drew it: an `EffectCell` before it has a seed or colours. */
127
127
  export type Placed = Pick<EffectCell, "row" | "col" | "anchor">;
128
+ /**
129
+ * A cell as `Drawn.all` lists it: where it sits and who drew it, the
130
+ * columns its glyph takes, its ink and ground as the screen shows them, and
131
+ * whether a strip's joiner drew it — a seam, no item's, whose colours are its
132
+ * neighbours' edges. Each is read off the one look and the one walk the cell
133
+ * is later handed with, so an effect that groups cells into the runs they
134
+ * are drawn in — `wheel`'s segments — reads what the frame hands, never a
135
+ * second measure. [LAW:one-source-of-truth]
136
+ */
137
+ export interface ListedCell extends Placed {
138
+ readonly width: number;
139
+ readonly pair: Pair;
140
+ readonly seam: boolean;
141
+ }
128
142
  /**
129
143
  * A cell's ink and ground as the screen shows them, which of the two are the
130
144
  * terminal's, and whether its style swaps them (`reverse`): what an `Effect`
@@ -149,19 +163,28 @@ export interface Pair extends CellColors {
149
163
  */
150
164
  export interface Drawn {
151
165
  /**
152
- * Every cell the element shows something in this frame — a glyph, a
153
- * ground that is not the terminal's, or a line its style draws — where it
154
- * sits and who drew it, listed before the effect is handed any: what
155
- * an effect spread over the element — a transition, which has its last cell
156
- * settle as it ends — is spread over. A blank cell on the terminal's ground
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.
166
+ * Every cell the element draws this frame, shown or not — where it sits
167
+ * and who drew it, what it shows and how wide it is (`ListedCell`) — in
168
+ * the order it draws them, listed before the effect is handed any. A blank
169
+ * cell on the terminal's ground that draws no line is here: the effect is
170
+ * handed it, and its ink, unseen, is the ink of the words beside it, so an
171
+ * effect that turns the element's runs each at their own pace — `wheel` —
172
+ * reads the runs off this and a styled sentence is one run across its
173
+ * spaces. A strip's join cell painted from the items beside it is here,
174
+ * as a `seam`, but never handed its own look, since it follows them: it is
175
+ * handed only a neighbour's edge the frame does not show, to be painted
176
+ * where the seam shows it. `confined` narrows it to the cells its owners
177
+ * drew. A call, so a frame whose effect does not ask lists none.
178
+ */
179
+ all(): readonly ListedCell[];
180
+ /**
181
+ * Of `all`, every cell the element shows something in — a glyph, a ground
182
+ * that is not the terminal's, or a line its style draws: what an effect
183
+ * spread over the element — a transition, which has its last cell settle
184
+ * as it ends — is spread over. A blank cell on the terminal's ground that
185
+ * draws no line is handed but not here, since nothing in it shows.
163
186
  */
164
- cells(): readonly Placed[];
187
+ cells(): readonly ListedCell[];
165
188
  /** Every distinct ink and ground of a word cell. */
166
189
  readonly pairs: readonly Pair[];
167
190
  /** The colours the element sets itself in its word cells, by hex: never the terminal's, whatever its hex. */
@@ -297,6 +320,8 @@ export declare function cellSeed(key: string, row: number, col: number): number;
297
320
  export interface WalkedGlyph {
298
321
  readonly glyph: string;
299
322
  readonly cell: Placed | undefined;
323
+ /** The columns the glyph takes: the one measure the walk steps by, so a listed cell's width is the walk's. [LAW:one-source-of-truth] */
324
+ readonly width: number;
300
325
  }
301
326
  /**
302
327
  * Each glyph of each of `segments`, where its cell sits: rows and columns
@@ -362,7 +362,7 @@ function looks(segments, depth, theme) {
362
362
  const slots = { fg: (wire.color ?? DEFAULT).getTruecolor(theme, true), bg: (wire.bgcolor ?? DEFAULT).getTruecolor(theme, false) };
363
363
  const reverse = style.reverse === true;
364
364
  const terminal = onScreen({ fg: wire.color?.isDefault ?? true, bg: wire.bgcolor?.isDefault ?? true }, reverse);
365
- return { segment, look: { style, wire, slots, pair: { ...onScreen(slots, reverse), terminal, reverse }, attributes: cellAttributes(style) } };
365
+ return { segment, look: { style, wire, slots, pair: { ...onScreen(slots, reverse), terminal, reverse }, attributes: cellAttributes(style), seam: drawnBy(style.anchor, JOIN) } };
366
366
  });
367
367
  }
368
368
  /**
@@ -385,9 +385,10 @@ export function walkGlyphs(segments) {
385
385
  let into = 0;
386
386
  return [...graphemes(segment.text)].map((glyph) => {
387
387
  const cell = glyph === "\n" ? undefined : { row, col, anchor: anchor && shiftAnchor(anchor, into) };
388
- [row, col] = glyph === "\n" ? [row + 1, 0] : [row, col + cellLen(glyph)];
389
- into += cellLen(glyph);
390
- return { glyph, cell };
388
+ const width = cellLen(glyph);
389
+ [row, col] = glyph === "\n" ? [row + 1, 0] : [row, col + width];
390
+ into += width;
391
+ return { glyph, cell, width };
391
392
  });
392
393
  });
393
394
  }
@@ -448,27 +449,33 @@ function drawsLines(style) {
448
449
  return [style.underline, style.underline2, style.strike, style.overline, style.frame, style.encircle].some((on) => on === true);
449
450
  }
450
451
  /**
451
- * What `looked` draws: `Drawn`'s measure, taken once per frame. Its `cells`
452
- * are listed off `walked` the first time they are asked for, since only an
453
- * effect spread over the element asks, and a glyph walk is what measuring the
454
- * words alone, a segment at a time, is spared (`drawnOf`).
452
+ * What `looked` draws: `Drawn`'s measure, taken once per frame. Its `all`
453
+ * and `cells` are listed off `walked` the first time either is asked for,
454
+ * since only an effect spread over the element or turning its runs asks, and
455
+ * a glyph walk is what measuring the words alone, a segment at a time, is
456
+ * spared (`drawnOf`).
455
457
  */
456
458
  function measured(looked, walked, depth, theme) {
457
459
  const ground = theme.backgroundColor.hex;
458
- let listed;
460
+ // [LAW:one-source-of-truth] One listing, each cell with whether it shows; `all` and `cells` are read off it.
461
+ let everything;
459
462
  const list = () => walked().flatMap(({ look, glyphs }) => {
460
463
  if (look === undefined)
461
464
  return [];
462
465
  // A blank glyph shows its ground, and any line its style draws in its ink.
463
466
  const blankShows = look.pair.bg.hex !== ground || drawsLines(look.style);
464
- return glyphs.flatMap(({ glyph, cell }) => (cell !== undefined && (inked(glyph) || blankShows) ? [cell] : []));
467
+ return glyphs.flatMap(({ glyph, cell, width }) => (cell === undefined ? [] : [{ cell: { ...cell, width, pair: look.pair, seam: look.seam }, shows: inked(glyph) || blankShows }]));
465
468
  });
466
- const words = looked.flatMap(({ segment, look }) => (look !== undefined && inked(segment.text) && !drawnBy(look.style.anchor, JOIN) ? [look.pair] : []));
469
+ const listed = () => (everything ??= list());
470
+ let all;
471
+ let shown;
472
+ const words = looked.flatMap(({ segment, look }) => (look !== undefined && inked(segment.text) && !look.seam ? [look.pair] : []));
467
473
  const pairs = [...new Map(words.map((pair) => [`${pair.fg.hex}/${pair.bg.hex}/${pair.terminal.fg}/${pair.terminal.bg}/${pair.reverse}`, pair])).values()];
468
474
  // [LAW:one-source-of-truth] A slot the terminal fills is no colour of the element's, whatever its hex.
469
475
  const own = (slot) => pairs.flatMap((pair) => (pair.terminal[slot] ? [] : [pair[slot].hex]));
470
476
  return {
471
- cells: () => (listed ??= list()),
477
+ all: () => (all ??= listed().map(({ cell }) => cell)),
478
+ cells: () => (shown ??= listed().flatMap(({ cell, shows }) => (shows ? [cell] : []))),
472
479
  pairs,
473
480
  colors: new Set([...own("fg"), ...own("bg")]),
474
481
  fills: new Set(own("bg")),
@@ -496,7 +503,7 @@ export function confined(effect, ...owners) {
496
503
  return false;
497
504
  };
498
505
  // The frame as the effect sees it: the owners' cells alone. [LAW:one-source-of-truth] One narrowing, handed to both stages.
499
- const narrowed = (drawn) => ({ ...drawn, cells: () => drawn.cells().filter(owned) });
506
+ const narrowed = (drawn) => ({ ...drawn, all: () => drawn.all().filter(owned), cells: () => drawn.cells().filter(owned) });
500
507
  // The owners' cells change when the effect's frame does.
501
508
  return (drawn) => Animation.map(effect(narrowed(drawn)), (frame) => (look, cell) => (owned(cell) ? frame(look, cell) : look));
502
509
  }
@@ -339,15 +339,21 @@ export declare function dissolveOut(curve: Curve, z: number): Transition;
339
339
  * way round once a period — far too slowly to be seen moving, so a glance
340
340
  * sees a steady palette and an hour's absence a different one — each
341
341
  * segment of the element at its own pace. Swing: how far the segments stray
342
- * from one another, 0–1, 1 being half a turn apart. A segment is a fill of
343
- * the element's own — one of `fills`, the colours it draws as a ground — and
344
- * it turns at its own pace wherever it is drawn: as the ground of its cells,
345
- * and as the ink of the seam glyph pointing out of it into the next, so a
346
- * powerline cap keeps the colour of the cell it caps. An ink that is no fill
347
- * turns with the ground it sits on, so a cell's words and fill turn
348
- * together; on the terminal's ground, a run of text's ink is a segment of
349
- * its own. A segment's stray is slow noise of its own, at `STRAY_PERIODS`,
350
- * so no two keep step, and the row never shows one spread of hues twice.
342
+ * from one another, 0–1, 1 being half a turn apart. A segment is a region
343
+ * of adjacent cells of one colour of the element's own, carried as their
344
+ * ground or, on the terminal's ground, as their ink (`segmentsOf`), and it
345
+ * paces by where it sits, not by its colour: two
346
+ * regions of one fill at two places turn apart, and each colour of the seam
347
+ * a joiner drew between them turns with the region of that colour beside
348
+ * it, so a powerline cap keeps the colour of the cell it caps. An ink that
349
+ * is no fill turns with the ground it sits on, so a cell's words and fill
350
+ * turn together. A segment's stray is slow noise at its place, at
351
+ * `STRAY_PERIODS` in time and `STRAY_SPREAD` across the element, so
352
+ * neighbours pace a little apart and distant segments on their own, the
353
+ * row never shows one spread of hues twice, and a segment that grows by a
354
+ * column moves its hue a little, and those it pushes along as little. Only the
355
+ * stray reads the place: a place in the shared turn would jump a growing
356
+ * run by `360·t·Δ/P`, without bound in `t`.
351
357
  * A colour keeps its luminance as it turns (`hueTurned`), so every pair of
352
358
  * words reads at the contrast it rested at; at 256 colours a turned colour
353
359
  * lands on a colour the depth writes, one that keeps every pair of words it
@@ -813,20 +813,135 @@ export function dissolveOut(curve, z) {
813
813
  const STRAY_PERIODS = [211, 337];
814
814
  /** How far a segment may stray from the shared turn at swing 1, in degrees: half a turn — fully apart. */
815
815
  const STRAY = 180;
816
+ /**
817
+ * The columns between two places at which their strays no longer pace
818
+ * alike: the noise a stray rides on is sampled at the place over this many
819
+ * columns a unit, so a strip's items, a dozen columns apart, pace a little
820
+ * apart and segments a screen apart pace on their own. Rows are the same
821
+ * distance apart as columns.
822
+ */
823
+ const STRAY_SPREAD = 24;
824
+ /** `cell` as a place of its own, as wide as its glyph: where no region covers it. */
825
+ const ownPlace = (cell, width) => ({ row: cell.row, centre: cell.col + width / 2 });
826
+ /**
827
+ * The segments `drawn` draws, as where each colour of each cell turns: a
828
+ * segment is a region of adjacent cells — beside or above one another — of
829
+ * one colour of the element's own, each cell carrying it as its ground or,
830
+ * on the terminal's ground, as its ink, read off `Drawn.all` once a frame.
831
+ * So a panel's ground is one segment whatever words lie on it, a blank
832
+ * carrying the ink keeps a styled paragraph one segment across its spaces
833
+ * and lines, a caption in a bar's colour written under the bar turns with
834
+ * it, and a blank in the terminal's ink splits a segment. A seam a joiner
835
+ * drew is in none, since its colours are its neighbours' edges: each colour
836
+ * of a seam cell turns with the region of that colour past the seam on
837
+ * either side — a two-cell rail reads past its other cell — so a powerline
838
+ * cap keeps the colour of the cell it caps, a custom joiner's handed cap
839
+ * included, and with the cell's own place where none is beside it — a
840
+ * neighbour's edge the frame does not show, handed at the seam's cell. Any
841
+ * other cell's colours turn with the region covering it: a letter in a
842
+ * neighbour's fill colour is a word, not a cap. A cell the frame lists
843
+ * nowhere, which a hand-built `Drawn` hands, is one column wide. The place
844
+ * is the region's centre column from the element's left edge, with its
845
+ * centre row: a region that grows by a column moves its centre by half of
846
+ * one, and those it pushes along by a column theirs by one, where a share
847
+ * of the element's extent would move every region's place with any
848
+ * region's growth.
849
+ */
850
+ function segmentsOf(drawn) {
851
+ const { colors } = drawn;
852
+ const at = (row, col) => `${row}:${col}`;
853
+ const listed = new Map();
854
+ // Every column a seam cell covers: what a seam's colours look past to the region beside the seam.
855
+ const seams = new Set();
856
+ // The colour a cell is a region of: its own ground, else on the terminal's ground its own ink, else none.
857
+ const regionColour = (cell) => {
858
+ if (cell.seam)
859
+ return undefined;
860
+ const own = (terminal, hex) => (!terminal && colors.has(hex) ? hex : undefined);
861
+ return own(cell.pair.terminal.bg, cell.pair.bg.hex) ?? own(cell.pair.terminal.fg, cell.pair.fg.hex);
862
+ };
863
+ // Every column a region's cell covers, in walk order, indexed by where it is.
864
+ const covered = [];
865
+ const index = new Map();
866
+ for (const cell of drawn.all()) {
867
+ listed.set(at(cell.row, cell.col), cell);
868
+ const hex = regionColour(cell);
869
+ for (let col = cell.col; col < cell.col + cell.width; col++) {
870
+ if (cell.seam)
871
+ seams.add(at(cell.row, col));
872
+ if (hex === undefined)
873
+ continue;
874
+ index.set(at(cell.row, col), covered.length);
875
+ covered.push({ row: cell.row, col, hex });
876
+ }
877
+ }
878
+ // Union-find over the covered columns: two of one colour beside or above one another are one region.
879
+ const parent = covered.map((_, i) => i);
880
+ const find = (i) => {
881
+ while (parent[i] !== i) {
882
+ parent[i] = parent[parent[i]];
883
+ i = parent[i];
884
+ }
885
+ return i;
886
+ };
887
+ const joinWith = (i, row, col) => {
888
+ const j = index.get(at(row, col));
889
+ if (j !== undefined && covered[j].hex === covered[i].hex)
890
+ parent[find(i)] = find(j);
891
+ };
892
+ covered.forEach(({ row, col }, i) => {
893
+ joinWith(i, row, col - 1);
894
+ joinWith(i, row - 1, col);
895
+ });
896
+ const regions = new Map();
897
+ covered.forEach(({ row, col, hex }, i) => {
898
+ const root = find(i);
899
+ const region = regions.get(root);
900
+ if (region === undefined)
901
+ regions.set(root, { hex, top: row, bottom: row, left: col, right: col });
902
+ else
903
+ [region.top, region.bottom, region.left, region.right] = [Math.min(region.top, row), Math.max(region.bottom, row), Math.min(region.left, col), Math.max(region.right, col)];
904
+ });
905
+ const regionAt = (row, col) => {
906
+ const i = index.get(at(row, col));
907
+ return i === undefined ? undefined : regions.get(find(i));
908
+ };
909
+ const placeOf = (region) => ({ row: (region.top + region.bottom) / 2, centre: (region.left + region.right + 1) / 2 });
910
+ // The region past the seam from `col` in `step`'s direction: what a seam's colour turns with.
911
+ const pastSeam = (row, col, step) => {
912
+ let past = col;
913
+ while (seams.has(at(row, past)))
914
+ past += step;
915
+ return regionAt(row, past);
916
+ };
917
+ return (cell, hex) => {
918
+ const own = listed.get(at(cell.row, cell.col));
919
+ const width = own?.width ?? 1;
920
+ const beside = (own?.seam === true ? [pastSeam(cell.row, cell.col - 1, -1), pastSeam(cell.row, cell.col + width, 1)] : []).find((region) => region?.hex === hex);
921
+ const region = beside ?? regionAt(cell.row, cell.col);
922
+ return region === undefined ? ownPlace(cell, width) : placeOf(region);
923
+ };
924
+ }
816
925
  /**
817
926
  * Wheel, as the colour of daylight going round: every hue turning the whole
818
927
  * way round once a period — far too slowly to be seen moving, so a glance
819
928
  * sees a steady palette and an hour's absence a different one — each
820
929
  * segment of the element at its own pace. Swing: how far the segments stray
821
- * from one another, 0–1, 1 being half a turn apart. A segment is a fill of
822
- * the element's own — one of `fills`, the colours it draws as a ground — and
823
- * it turns at its own pace wherever it is drawn: as the ground of its cells,
824
- * and as the ink of the seam glyph pointing out of it into the next, so a
825
- * powerline cap keeps the colour of the cell it caps. An ink that is no fill
826
- * turns with the ground it sits on, so a cell's words and fill turn
827
- * together; on the terminal's ground, a run of text's ink is a segment of
828
- * its own. A segment's stray is slow noise of its own, at `STRAY_PERIODS`,
829
- * so no two keep step, and the row never shows one spread of hues twice.
930
+ * from one another, 0–1, 1 being half a turn apart. A segment is a region
931
+ * of adjacent cells of one colour of the element's own, carried as their
932
+ * ground or, on the terminal's ground, as their ink (`segmentsOf`), and it
933
+ * paces by where it sits, not by its colour: two
934
+ * regions of one fill at two places turn apart, and each colour of the seam
935
+ * a joiner drew between them turns with the region of that colour beside
936
+ * it, so a powerline cap keeps the colour of the cell it caps. An ink that
937
+ * is no fill turns with the ground it sits on, so a cell's words and fill
938
+ * turn together. A segment's stray is slow noise at its place, at
939
+ * `STRAY_PERIODS` in time and `STRAY_SPREAD` across the element, so
940
+ * neighbours pace a little apart and distant segments on their own, the
941
+ * row never shows one spread of hues twice, and a segment that grows by a
942
+ * column moves its hue a little, and those it pushes along as little. Only the
943
+ * stray reads the place: a place in the shared turn would jump a growing
944
+ * run by `360·t·Δ/P`, without bound in `t`.
830
945
  * A colour keeps its luminance as it turns (`hueTurned`), so every pair of
831
946
  * words reads at the contrast it rested at; at 256 colours a turned colour
832
947
  * lands on a colour the depth writes, one that keeps every pair of words it
@@ -839,46 +954,57 @@ export function wheel(curve, z) {
839
954
  requireCurve(curve);
840
955
  requireZ("wheel", z);
841
956
  const P = curve.seconds;
842
- // A segment's seed, from the colour it is.
843
- const seed = (hex) => hash(Number.parseInt(hex.slice(1), 16) * 1e-4, 17.3 + z);
844
- const stray = (s, t) => noise(t / STRAY_PERIODS[0], 5.9 + 97 * s, z) + 0.5 * noise(t / STRAY_PERIODS[1], 2.3 + 89 * s, z + 7.1);
957
+ // A segment's stray at its place. The fractions of the `y` offsets, .9
958
+ // and .3, are ones no centre — a half-integer over `STRAY_SPREAD` columns
959
+ // — reaches, so no sample's `y` is whole: a sample with a whole `y` and
960
+ // `z` sits on a lattice line along `t`, where the noise is zero at every
961
+ // multiple of the period and every segment on it would return to the
962
+ // shared turn together. `z` needs no such offset; its two differ so the
963
+ // two noises are read from different fields.
964
+ const stray = ({ row, centre }, t) => noise(t / STRAY_PERIODS[0], centre / STRAY_SPREAD + 5.9, z + row / STRAY_SPREAD + 2.3) + 0.5 * noise(t / STRAY_PERIODS[1], centre / STRAY_SPREAD + 97.3, z + row / STRAY_SPREAD + 7.1);
845
965
  // Measured from where it stood at the start, as pulse's wander is, so the
846
966
  // first frame turns nothing and the second has turned only a frame's worth.
847
- const turned = (hex, t) => {
848
- const s = seed(hex);
849
- return (360 * t) / P + curve.swing * STRAY * ((stray(s, t) - stray(s, 0)) / 1.5);
850
- };
967
+ const turned = (place, t) => (360 * t) / P + curve.swing * STRAY * ((stray(place, t) - stray(place, 0)) / 1.5);
851
968
  // A turned colour's bounds rest on what a frame draws, never on `t`: settled once for each.
852
969
  const settled = new WeakMemo();
853
970
  return stillOnSlots((drawn) => {
854
971
  const { colors, fills, depth, theme } = drawn;
855
972
  // Whether a slot shows one of the element's colours: never the terminal's, whatever its hex.
856
973
  const own = (terminal, color) => !terminal && colors.has(color.hex);
857
- // The segment an own colour turns with: itself if a fill or beside no own colour, else the one beside it.
858
- const segmentOf = (color, other, theirs) => (fills.has(color.hex) || !theirs ? color.hex : other.hex);
859
- // A colour as it turns with a segment: written one way wherever it is drawn in a frame.
860
- const keyOf = (color, segment) => `${color.hex} ${segment}`;
974
+ // The colour an own colour is bounded with, under which its bounds are
975
+ // kept: itself if a fill or beside no own colour, else the ground beside
976
+ // it. Where it turns is `segmentsOf`'s, not this.
977
+ const boundWith = (color, other, theirs) => (fills.has(color.hex) || !theirs ? color.hex : other.hex);
978
+ // The bounds a colour turns within, by the colour it is bounded with: one bound for every place a frame writes it at.
979
+ const keyOf = (color, bound) => `${color.hex} ${bound}`;
861
980
  const bounds = settled.get(theme, () => new Memo()).get(drawnKey(drawn), () => turnedBounds(drawn.pairs, depth, (pair, side) => {
862
981
  const other = side === "fg" ? "bg" : "fg";
863
- return own(pair.terminal[side], pair[side]) ? keyOf(pair[side], segmentOf(pair[side], pair[other], own(pair.terminal[other], pair[other]))) : undefined;
982
+ return own(pair.terminal[side], pair[side]) ? keyOf(pair[side], boundWith(pair[side], pair[other], own(pair.terminal[other], pair[other]))) : undefined;
864
983
  }));
865
984
  // 256 colours writes a turned colour as one it has an index for; truecolor
866
985
  // as one of the two either side of its luminance.
867
986
  const indexed = measuredIndexed(depth, theme);
987
+ // Where each cell's colours turn, read off the frame's listing once.
988
+ const placeOf = segmentsOf(drawn);
868
989
  return Animation.continuous((t) => {
990
+ // [LAW:one-source-of-truth] A colour turned at a place within a
991
+ // segment's bounds is written once a frame, wherever it falls: the
992
+ // key is the bounds' key with the place, so one entry never serves
993
+ // two bounds.
869
994
  const written = new Map();
870
- const choose = (color, segment) => {
871
- const turn = hueTurned(color, turned(segment, t));
872
- return nearestWithin(turn.looks, color, depth === ColorDepth.TRUECOLOR ? turn.either.map(measured) : indexed, bounds.get(keyOf(color, segment)) ?? UNBOUNDED);
995
+ const choose = (color, bound, place) => {
996
+ const turn = hueTurned(color, turned(place, t));
997
+ return nearestWithin(turn.looks, color, depth === ColorDepth.TRUECOLOR ? turn.either.map(measured) : indexed, bounds.get(keyOf(color, bound)) ?? UNBOUNDED);
873
998
  };
874
- const write = (color, segment) => {
875
- const found = written.get(keyOf(color, segment)) ?? choose(color, segment);
876
- written.set(keyOf(color, segment), found);
999
+ const write = (color, bound, place) => {
1000
+ const key = `${keyOf(color, bound)} ${place.row}:${place.centre}`;
1001
+ const found = written.get(key) ?? choose(color, bound, place);
1002
+ written.set(key, found);
877
1003
  return found;
878
1004
  };
879
1005
  return (shown, cell) => {
880
1006
  const mine = { fg: own(cell.terminal.fg, shown.fg), bg: own(cell.terminal.bg, shown.bg) };
881
- const turn = (color, ours, other, theirs) => (ours ? write(color, segmentOf(color, other, theirs)) : color);
1007
+ const turn = (color, ours, other, theirs) => ours ? write(color, boundWith(color, other, theirs), placeOf(cell, color.hex)) : color;
882
1008
  return { ...shown, fg: turn(shown.fg, mine.fg, shown.bg, mine.bg), bg: turn(shown.bg, mine.bg, shown.fg, mine.fg) };
883
1009
  };
884
1010
  });
@@ -937,9 +1063,10 @@ const ROUNDING_GAP = Math.log(1.031);
937
1063
  * A pair's contrast to spare above its floor, as a gap between stops, is
938
1064
  * shared out evenly between its sides that turn: the lighter may land that
939
1065
  * much darker, the darker that much lighter, and never further. So a colour
940
- * written once for every pair it is drawn in is bounded by each of them at
941
- * once, and a frame writes it one way wherever it falls. A pair whose floor
942
- * is 1:1 holds however it is drawn, and bounds nothing.
1066
+ * bounded once for every pair it is drawn in is bounded by each of them at
1067
+ * once, and every way a frame writes it — the wheel turns it at each place
1068
+ * it falls — lands within them. A pair whose floor is 1:1 holds however it
1069
+ * is drawn, and bounds nothing.
943
1070
  */
944
1071
  function turnedBounds(pairs, depth, turnedAs) {
945
1072
  const bounds = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/rich-js",
3
- "version": "0.25.2",
3
+ "version": "0.26.0",
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",