@promptctl/rich-js 0.22.0 → 0.24.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 (51) hide show
  1. package/dist/core/clock.d.ts +9 -1
  2. package/dist/core/clock.js +22 -2
  3. package/dist/core/color.d.ts +10 -0
  4. package/dist/core/color.js +36 -1
  5. package/dist/core/easing.d.ts +31 -0
  6. package/dist/core/easing.js +106 -1
  7. package/dist/core/segment.d.ts +10 -0
  8. package/dist/core/segment.js +15 -0
  9. package/dist/core/strip.d.ts +108 -31
  10. package/dist/core/strip.js +240 -131
  11. package/dist/core/style.d.ts +18 -1
  12. package/dist/core/style.js +32 -3
  13. package/dist/core/text.js +4 -1
  14. package/dist/host/app.js +2 -4
  15. package/dist/host/index.d.ts +1 -1
  16. package/dist/host/input.d.ts +37 -13
  17. package/dist/host/input.js +146 -56
  18. package/dist/host/terminal-host.d.ts +16 -0
  19. package/dist/host/terminal-host.js +1 -0
  20. package/dist/index.d.ts +11 -9
  21. package/dist/index.js +5 -4
  22. package/dist/node/terminal-host.d.ts +8 -1
  23. package/dist/node/terminal-host.js +2 -0
  24. package/dist/renderables/effect.d.ts +91 -18
  25. package/dist/renderables/effect.js +158 -46
  26. package/dist/renderables/effects.d.ts +73 -26
  27. package/dist/renderables/effects.js +412 -92
  28. package/dist/renderables/embed.d.ts +10 -0
  29. package/dist/renderables/embed.js +15 -7
  30. package/dist/renderables/frames.d.ts +95 -0
  31. package/dist/renderables/frames.js +106 -0
  32. package/dist/renderables/live.d.ts +23 -7
  33. package/dist/renderables/live.js +68 -34
  34. package/dist/renderables/markdown.d.ts +5 -2
  35. package/dist/renderables/markdown.js +210 -19
  36. package/dist/renderables/progress.d.ts +1 -1
  37. package/dist/renderables/progress.js +3 -1
  38. package/dist/renderables/progressBar.js +3 -3
  39. package/dist/renderables/spinner.d.ts +62 -28
  40. package/dist/renderables/spinner.js +99 -52
  41. package/dist/renderables/status.d.ts +8 -3
  42. package/dist/renderables/status.js +8 -5
  43. package/dist/template-bindings/animation-funcs.d.ts +37 -0
  44. package/dist/template-bindings/animation-funcs.js +83 -0
  45. package/dist/template-bindings/color-funcs.d.ts +13 -0
  46. package/dist/template-bindings/color-funcs.js +1 -1
  47. package/dist/template-bindings/helpers.d.ts +6 -0
  48. package/dist/template-bindings/helpers.js +12 -3
  49. package/dist/template-bindings/index.d.ts +6 -2
  50. package/dist/template-bindings/index.js +6 -2
  51. package/package.json +1 -1
@@ -6,14 +6,17 @@
6
6
  * draws in that frame in, a function of the frame out; and into that, a
7
7
  * cell's colours and where the cell is, the cell's new colours out. Whatever
8
8
  * holds for the whole frame is worked out in the first stage, once, and every
9
- * cell of the frame is handed to the second. Shimmer, pulse, fade and
9
+ * cell of the frame is handed to the second, but for those a wrapper keeps
10
+ * back: `confined` hands over only the cells its owners drew, so a second
11
+ * stage never counts on seeing them all. Shimmer, pulse, fade and
10
12
  * dissolve are all this one shape, differing by what the function does with
11
13
  * its inputs [LAW:one-type-per-behavior]; two effects stack by nesting one
12
14
  * wrapper in another, which is the same as calling one after the other but
13
15
  * for what the outer one measures: the inner one's output.
14
16
  *
15
- * What the element draws (`Drawn`) is measured here, in the render, off the
16
- * same reading of each segment its cells are drawn from, and handed to the
17
+ * What the element draws (`Drawn`) — the cells the second stage will be
18
+ * handed, and the colours of its words — is measured here, in the render, off
19
+ * the same reading of each segment its cells are drawn from, and handed to the
17
20
  * effect's first stage, once a frame. It is what an effect keeping words legible is
18
21
  * settled against (`onColors`, `./effects.ts`), so no caller derives it a
19
22
  * second time from a render of its own.
@@ -31,8 +34,10 @@
31
34
  * segment's link and meta and carries its anchor shifted to the run's first
32
35
  * cell (`Style.shiftedBy`), so hit testing still finds the owner.
33
36
  * - A cell whose colours come back unchanged keeps its original `ColorSpec`,
34
- * and a segment whose every cell is unchanged is yielded as it was, so the
35
- * identity effect is byte-identical. Neighbouring cells the output depth
37
+ * and a segment whose every cell is unchanged is yielded with its look as
38
+ * it was, so the identity effect is byte-identical; what it gains is when
39
+ * the frame next changes (`Style.nextChange`), which writes no byte.
40
+ * Neighbouring cells the output depth
36
41
  * writes alike stay one segment: a segment is cut only where the written
37
42
  * colours change, so two colours that land on one index are one run.
38
43
  * - New colours are built with `ColorSpec.fromRgba`, never parsed from a
@@ -45,6 +50,7 @@
45
50
  */
46
51
  import { type Anchor } from "../core/anchor.js";
47
52
  import { ColorDepth, type ColorRgba, type TerminalTheme } from "../core/color.js";
53
+ import type { Beat } from "../core/easing.js";
48
54
  import type { Measurable, Renderable, RenderOptions } from "../core/protocol.js";
49
55
  import { Segment } from "../core/segment.js";
50
56
  /**
@@ -76,6 +82,8 @@ export interface EffectCell {
76
82
  readonly fg: boolean;
77
83
  readonly bg: boolean;
78
84
  };
85
+ /** Whether the cell's style swaps its two slots, as `Pair.reverse` says: with `colors` and `terminal`, the cell's `Pair`. */
86
+ readonly reverse: boolean;
79
87
  /**
80
88
  * Who drew the cell, and where it sits in their output, as
81
89
  * `Segment.anchorAt` reads it off a frame: what tells one owner's cells
@@ -83,6 +91,8 @@ export interface EffectCell {
83
91
  */
84
92
  readonly anchor: Anchor | undefined;
85
93
  }
94
+ /** Where a cell sits and who drew it: an `EffectCell` before it has a seed or colours. */
95
+ export type Placed = Pick<EffectCell, "row" | "col" | "anchor">;
86
96
  /**
87
97
  * A cell's ink and ground as the screen shows them, which of the two are the
88
98
  * terminal's, and whether its style swaps them (`reverse`): what an `Effect`
@@ -99,13 +109,24 @@ export interface Pair extends CellColors {
99
109
  readonly reverse: boolean;
100
110
  }
101
111
  /**
102
- * The colours the element an effect wraps draws its words in, in one frame,
103
- * as the screen shows them: what an effect that must keep the words legible
104
- * is measured against. A word cell is one whose glyph is not blank and that a
105
- * strip's joiner did not draw (`JOIN`): a seam's ink is the fill beside it,
106
- * not words.
112
+ * What the element an effect wraps draws in one frame: the cells the effect
113
+ * is handed, and the colours the element draws its words in, as the screen
114
+ * shows them — what an effect that must keep the words legible is measured
115
+ * against. A word cell is one whose glyph is not blank and that a strip's
116
+ * joiner did not draw (`JOIN`): a seam's ink is the fill beside it, not words.
107
117
  */
108
118
  export interface Drawn {
119
+ /**
120
+ * Every cell the element shows something in this frame — a glyph, a
121
+ * ground that is not the terminal's, or a line its style draws — where it
122
+ * sits and who drew it, listed before the second stage is handed any: what
123
+ * an effect spread over the element — a transition, which has its last cell
124
+ * settle as it ends — is spread over. A blank cell on the terminal's ground
125
+ * that draws no line is handed but not listed, since nothing in it shows.
126
+ * `confined` narrows it to the cells its owners drew. A call, so a frame
127
+ * whose effect does not ask lists none.
128
+ */
129
+ cells(): readonly Placed[];
109
130
  /** Every distinct ink and ground of a word cell. */
110
131
  readonly pairs: readonly Pair[];
111
132
  /** The colours the element sets itself in its word cells, by hex: never the terminal's, whatever its hex. */
@@ -123,8 +144,36 @@ export interface Drawn {
123
144
  * [LAW:types-are-the-program] Per-frame work is the outer stage, so it runs
124
145
  * once a frame by the shape of the call, never by a cache asking whether this
125
146
  * cell's frame is the last one's.
147
+ *
148
+ * An effect that knows when its frame next changes says so in `nextChange`,
149
+ * asked with the same `t` and `drawn` its first stage is: a stepped one its
150
+ * next step's start, a transition `t` until it is done and `Infinity` after,
151
+ * a loop that holds every cell still at the frame's depth `Infinity` there.
152
+ * [LAW:types-are-the-program] The answer is a fact about the frame, so it is
153
+ * asked with everything the frame is drawn from; an answer asked with `t`
154
+ * alone could not say that a frame is still because of its depth. A plain
155
+ * function is an effect that moves every frame, and answers `t`
156
+ * (`nextChangeOf`): the one true answer for a frame nothing has promised to
157
+ * hold, so a loop needs no second shape.
126
158
  */
127
- export type Effect = (t: number, drawn: Drawn) => (colors: CellColors, cell: EffectCell) => CellColors;
159
+ export interface Effect {
160
+ (t: number, drawn: Drawn): (colors: CellColors, cell: EffectCell) => CellColors;
161
+ /** When the frame drawn at `t` of `drawn` next changes, in seconds on `t`'s timeline, never before `t`; absent, every frame. */
162
+ readonly nextChange?: (t: number, drawn: Drawn) => number;
163
+ }
164
+ /**
165
+ * When `effect`'s frame at `t` of `drawn` next changes: what it says, or `t`
166
+ * for an effect that says nothing. [LAW:single-enforcer] The one reading of
167
+ * an absent `nextChange`, so no caller holds its own default.
168
+ */
169
+ export declare function nextChangeOf(effect: Effect, t: number, drawn: Drawn): number;
170
+ /**
171
+ * An effect drawing as `effect` does, with `nextChange` as its answer to when
172
+ * its frame next changes, in place of whatever `effect` answered; `effect`
173
+ * itself is left as it was. A composer whose answer depends on the inner
174
+ * effect's reads it with `nextChangeOf`, as `onBeat` and `confined` do.
175
+ */
176
+ export declare function withNextChange(effect: Effect, nextChange: (t: number, drawn: Drawn) => number): Effect;
128
177
  export interface EffectedOptions {
129
178
  /** The moment to draw, in seconds. */
130
179
  readonly t: number;
@@ -148,6 +197,8 @@ export declare class Effected implements Renderable, Measurable {
148
197
  readonly theme: TerminalTheme;
149
198
  constructor(renderable: Renderable, effect: Effect, options: EffectedOptions);
150
199
  render(options: RenderOptions): Iterable<Segment>;
200
+ private _moved;
201
+ private _recolored;
151
202
  measure(options: RenderOptions): {
152
203
  minimum: number;
153
204
  maximum: number;
@@ -170,14 +221,36 @@ export declare class Effected implements Renderable, Measurable {
170
221
  private seed;
171
222
  }
172
223
  /**
173
- * `effect` on the colours `owner` drew, itself or through an owner nested in
174
- * its output, and every other colour left as it is: one item of a strip whose
175
- * items share a fill, one widget of a screen. A seam cell shows its two
176
- * neighbours side by side, a powerline arrow in the fill it points out of
177
- * over the fill it points into, so each side moves with its own item
178
- * (`colorsOwnedBy`, `../core/strip.ts`).
224
+ * `effect` sampled and held on `beat`: drawn at the moment the hold in
225
+ * progress began (`Beat.start`), so within a hold every frame is the frame
226
+ * at its start, and at each hold's start it moves on. A continuous effect
227
+ * given a short curve becomes one that steps — a `wheel` turning a sixth of
228
+ * a turn once a second — and at one redraw a second a hold shorter than a
229
+ * second is skipped, as frames are. [LAW:no-ambient-temporal-coupling] The
230
+ * held time is a function of `t`, so the same `t` draws the same bytes at
231
+ * any rate. The frame next changes at a hold's start, and only one the
232
+ * sampled effect has moved by: the first hold start at or after what it
233
+ * answers for the frame held now (`Beat.end`). An effect that moves every
234
+ * frame makes the beat's own answer, the end of this hold; one that is done
235
+ * and says never leaves the beat saying never too, since the hold after this
236
+ * one would draw the same frame. The answer is read off what the effect
237
+ * says, never off a comparison of frames: an effect that steps and is back
238
+ * on the same colour by the next hold still turns the beat there, an early
239
+ * answer the contract allows.
240
+ */
241
+ export declare function onBeat(effect: Effect, beat: Beat): Effect;
242
+ /**
243
+ * `effect` on the colours any of `owners` drew, itself or through an owner
244
+ * nested in its output, and every other colour left as it is: one item of a
245
+ * strip whose items share a fill, the items of a row one other item stands
246
+ * apart from, one widget of a screen. A seam cell shows its two neighbours
247
+ * side by side, a powerline arrow in the fill it points out of over the fill
248
+ * it points into, so each side moves with its own item (`colorsOwnedBy`,
249
+ * `../core/strip.ts`). A cell none of them drew is never handed to the
250
+ * effect, so confining one effect to many owners costs one call a cell, not
251
+ * one for each owner.
179
252
  */
180
- export declare function confined(effect: Effect, owner: object): Effect;
253
+ export declare function confined(effect: Effect, ...owners: readonly object[]): Effect;
181
254
  /**
182
255
  * What `segments`, drawn at `depth` on `theme`, draw their words in, measured
183
256
  * as `Effected` measures its child for every frame. Measure an element's
@@ -6,14 +6,17 @@
6
6
  * draws in that frame in, a function of the frame out; and into that, a
7
7
  * cell's colours and where the cell is, the cell's new colours out. Whatever
8
8
  * holds for the whole frame is worked out in the first stage, once, and every
9
- * cell of the frame is handed to the second. Shimmer, pulse, fade and
9
+ * cell of the frame is handed to the second, but for those a wrapper keeps
10
+ * back: `confined` hands over only the cells its owners drew, so a second
11
+ * stage never counts on seeing them all. Shimmer, pulse, fade and
10
12
  * dissolve are all this one shape, differing by what the function does with
11
13
  * its inputs [LAW:one-type-per-behavior]; two effects stack by nesting one
12
14
  * wrapper in another, which is the same as calling one after the other but
13
15
  * for what the outer one measures: the inner one's output.
14
16
  *
15
- * What the element draws (`Drawn`) is measured here, in the render, off the
16
- * same reading of each segment its cells are drawn from, and handed to the
17
+ * What the element draws (`Drawn`) — the cells the second stage will be
18
+ * handed, and the colours of its words — is measured here, in the render, off
19
+ * the same reading of each segment its cells are drawn from, and handed to the
17
20
  * effect's first stage, once a frame. It is what an effect keeping words legible is
18
21
  * settled against (`onColors`, `./effects.ts`), so no caller derives it a
19
22
  * second time from a render of its own.
@@ -31,8 +34,10 @@
31
34
  * segment's link and meta and carries its anchor shifted to the run's first
32
35
  * cell (`Style.shiftedBy`), so hit testing still finds the owner.
33
36
  * - A cell whose colours come back unchanged keeps its original `ColorSpec`,
34
- * and a segment whose every cell is unchanged is yielded as it was, so the
35
- * identity effect is byte-identical. Neighbouring cells the output depth
37
+ * and a segment whose every cell is unchanged is yielded with its look as
38
+ * it was, so the identity effect is byte-identical; what it gains is when
39
+ * the frame next changes (`Style.nextChange`), which writes no byte.
40
+ * Neighbouring cells the output depth
36
41
  * writes alike stay one segment: a segment is cut only where the written
37
42
  * colours change, so two colours that land on one index are one run.
38
43
  * - New colours are built with `ColorSpec.fromRgba`, never parsed from a
@@ -51,6 +56,23 @@ import { Measurement } from "../core/measure.js";
51
56
  import { Segment } from "../core/segment.js";
52
57
  import { JOIN, colorsOwnedBy } from "../core/strip.js";
53
58
  import { Style } from "../core/style.js";
59
+ /**
60
+ * When `effect`'s frame at `t` of `drawn` next changes: what it says, or `t`
61
+ * for an effect that says nothing. [LAW:single-enforcer] The one reading of
62
+ * an absent `nextChange`, so no caller holds its own default.
63
+ */
64
+ export function nextChangeOf(effect, t, drawn) {
65
+ return effect.nextChange?.(t, drawn) ?? t;
66
+ }
67
+ /**
68
+ * An effect drawing as `effect` does, with `nextChange` as its answer to when
69
+ * its frame next changes, in place of whatever `effect` answered; `effect`
70
+ * itself is left as it was. A composer whose answer depends on the inner
71
+ * effect's reads it with `nextChangeOf`, as `onBeat` and `confined` do.
72
+ */
73
+ export function withNextChange(effect, nextChange) {
74
+ return Object.assign((t, drawn) => effect(t, drawn), { nextChange });
75
+ }
54
76
  export class Effected {
55
77
  renderable;
56
78
  effect;
@@ -67,21 +89,31 @@ export class Effected {
67
89
  this.key = options.key;
68
90
  this.theme = options.theme;
69
91
  }
70
- *render(options) {
92
+ render(options) {
71
93
  const segments = this.renderable.render(options);
72
- if (options.colorSystem === null) {
73
- yield* segments;
74
- return;
75
- }
94
+ // With no colour output nothing the effect does is written, so the child
95
+ // is what is shown, and it changes when the child says it does.
96
+ if (options.colorSystem === null)
97
+ return segments;
98
+ return this._moved(segments, options);
99
+ }
100
+ *_moved(segments, options) {
76
101
  // No depth named is no downgrade, as `drawnColors` reads it.
77
102
  const depth = options.colorSystem ?? ColorDepth.TRUECOLOR;
78
103
  // [LAW:one-source-of-truth] The frame is measured before any cell is
79
- // handed to the effect, off the same looks its cells are drawn from.
104
+ // handed to the effect, off the same looks its cells are drawn from, and
105
+ // the one measure is what both stages and the effect's next change are
106
+ // asked with.
80
107
  const looked = looks(segments, depth, this.theme);
81
- const frame = this.effect(this.t, measured(looked, depth, this.theme));
82
- let row = 0;
83
- let col = 0;
84
- for (const { segment, look } of looked) {
108
+ const walked = walk(looked);
109
+ const drawn = measured(looked, () => walked, depth, this.theme);
110
+ const frame = this.effect(this.t, drawn);
111
+ // What the effect draws at `t` is drawn until its next change, and the
112
+ // cells say so, under whatever the child already said (the sooner wins).
113
+ yield* Segment.applyStyle(this._recolored(walked, frame, depth), new Style({ nextChange: nextChangeOf(this.effect, this.t, drawn) }));
114
+ }
115
+ *_recolored(walked, frame, depth) {
116
+ for (const { segment, look, glyphs } of walked) {
85
117
  if (look === undefined) {
86
118
  yield segment;
87
119
  continue;
@@ -94,21 +126,9 @@ export class Effected {
94
126
  // writes, so two truecolors landing on one index stay one run. A cell
95
127
  // the effect gave its neighbour's colours is drawn as that neighbour was.
96
128
  const runs = [];
97
- // Cells into the segment, as `base.shiftedBy` counts them below.
98
- let into = 0;
99
- for (const glyph of graphemes(segment.text)) {
100
- let colors = slots;
101
- if (glyph === "\n") {
102
- row++;
103
- col = 0;
104
- }
105
- else {
106
- const anchor = base.anchor && shiftAnchor(base.anchor, into);
107
- // Back from the screen to the style's slots: the same swap undoes itself.
108
- colors = onScreen(frame(from, { row, col, seed: this.seed(row, col), terminal, anchor }), pair.reverse);
109
- col += cellLen(glyph);
110
- }
111
- into += cellLen(glyph);
129
+ for (const { glyph, cell } of glyphs) {
130
+ // Back from the screen to the style's slots: the same swap undoes itself.
131
+ const colors = cell === undefined ? slots : onScreen(frame(from, { ...cell, seed: this.seed(cell.row, cell.col), terminal, reverse: pair.reverse }), pair.reverse);
112
132
  const last = runs[runs.length - 1];
113
133
  const writes = last !== undefined && sameColors(last.colors, colors) ? last.drawn : this.respec(wire, slots, colors, depth);
114
134
  if (last !== undefined && writesAlike(last.drawn, writes)) {
@@ -176,31 +196,122 @@ function looks(segments, depth, theme) {
176
196
  return { segment, look: { style, wire, slots, pair: { ...onScreen(slots, reverse), terminal, reverse } } };
177
197
  });
178
198
  }
179
- /** What `looked` draws its words in: `Drawn`'s measure, taken once per frame. */
180
- function measured(looked, depth, theme) {
199
+ /**
200
+ * Each glyph of `looked`, where its cell sits: rows and columns from the
201
+ * top-left of the output, a wide glyph one cell at its first column, and its
202
+ * anchor shifted to that column as `Style.shiftedBy` shifts a run's.
203
+ * [LAW:one-source-of-truth] The one walk: what `Drawn.cells` lists and what
204
+ * the render hands the effect are read off it.
205
+ */
206
+ function walk(looked) {
207
+ let row = 0;
208
+ let col = 0;
209
+ return looked.map(({ segment, look }) => {
210
+ if (look === undefined)
211
+ return { segment, look, glyphs: [] };
212
+ const anchor = look.style.anchor;
213
+ // Cells into the segment, as `base.shiftedBy` counts them in the render.
214
+ let into = 0;
215
+ const glyphs = [...graphemes(segment.text)].map((glyph) => {
216
+ const cell = glyph === "\n" ? undefined : { row, col, anchor: anchor && shiftAnchor(anchor, into) };
217
+ [row, col] = glyph === "\n" ? [row + 1, 0] : [row, col + cellLen(glyph)];
218
+ into += cellLen(glyph);
219
+ return { glyph, cell };
220
+ });
221
+ return { segment, look, glyphs };
222
+ });
223
+ }
224
+ /** Whether `style` draws in its ink on a blank glyph: a line under, through or over it, or a frame round it. */
225
+ function drawsLines(style) {
226
+ return [style.underline, style.underline2, style.strike, style.overline, style.frame, style.encircle].some((on) => on === true);
227
+ }
228
+ /**
229
+ * What `looked` draws: `Drawn`'s measure, taken once per frame. Its `cells`
230
+ * are listed off `walked` the first time they are asked for, since only an
231
+ * effect spread over the element asks, and a glyph walk is what measuring the
232
+ * words alone, a segment at a time, is spared (`drawnOf`).
233
+ */
234
+ function measured(looked, walked, depth, theme) {
235
+ const ground = theme.backgroundColor.hex;
236
+ let listed;
237
+ const list = () => walked().flatMap(({ look, glyphs }) => {
238
+ if (look === undefined)
239
+ return [];
240
+ // A blank glyph shows its ground, and any line its style draws in its ink.
241
+ const blankShows = look.pair.bg.hex !== ground || drawsLines(look.style);
242
+ return glyphs.flatMap(({ glyph, cell }) => (cell !== undefined && (glyph.trim() !== "" || blankShows) ? [cell] : []));
243
+ });
181
244
  const words = looked.flatMap(({ segment, look }) => (look !== undefined && segment.text.trim() !== "" && !drawnBy(look.style.anchor, JOIN) ? [look.pair] : []));
182
245
  const pairs = [...new Map(words.map((pair) => [`${pair.fg.hex}/${pair.bg.hex}/${pair.terminal.fg}/${pair.terminal.bg}/${pair.reverse}`, pair])).values()];
183
246
  // [LAW:one-source-of-truth] A slot the terminal fills is no colour of the element's, whatever its hex.
184
247
  const own = (slot) => pairs.flatMap((pair) => (pair.terminal[slot] ? [] : [pair[slot].hex]));
185
- return { pairs, colors: new Set([...own("fg"), ...own("bg")]), fills: new Set(own("bg")), depth, theme };
248
+ return {
249
+ cells: () => (listed ??= list()),
250
+ pairs,
251
+ colors: new Set([...own("fg"), ...own("bg")]),
252
+ fills: new Set(own("bg")),
253
+ depth,
254
+ theme,
255
+ };
256
+ }
257
+ /**
258
+ * `effect` sampled and held on `beat`: drawn at the moment the hold in
259
+ * progress began (`Beat.start`), so within a hold every frame is the frame
260
+ * at its start, and at each hold's start it moves on. A continuous effect
261
+ * given a short curve becomes one that steps — a `wheel` turning a sixth of
262
+ * a turn once a second — and at one redraw a second a hold shorter than a
263
+ * second is skipped, as frames are. [LAW:no-ambient-temporal-coupling] The
264
+ * held time is a function of `t`, so the same `t` draws the same bytes at
265
+ * any rate. The frame next changes at a hold's start, and only one the
266
+ * sampled effect has moved by: the first hold start at or after what it
267
+ * answers for the frame held now (`Beat.end`). An effect that moves every
268
+ * frame makes the beat's own answer, the end of this hold; one that is done
269
+ * and says never leaves the beat saying never too, since the hold after this
270
+ * one would draw the same frame. The answer is read off what the effect
271
+ * says, never off a comparison of frames: an effect that steps and is back
272
+ * on the same colour by the next hold still turns the beat there, an early
273
+ * answer the contract allows.
274
+ */
275
+ export function onBeat(effect, beat) {
276
+ return withNextChange((t, drawn) => effect(beat.start(t), drawn), (t, drawn) => {
277
+ const moved = nextChangeOf(effect, beat.start(t), drawn);
278
+ if (moved === Number.POSITIVE_INFINITY)
279
+ return moved;
280
+ const end = beat.end(t);
281
+ // The hold boundaries after `t` are `end`, then each hold's end in turn;
282
+ // the frame turns at the first one the effect has moved by.
283
+ return moved <= end ? end : beat.start(moved) === moved ? moved : beat.end(moved);
284
+ });
186
285
  }
187
286
  /**
188
- * `effect` on the colours `owner` drew, itself or through an owner nested in
189
- * its output, and every other colour left as it is: one item of a strip whose
190
- * items share a fill, one widget of a screen. A seam cell shows its two
191
- * neighbours side by side, a powerline arrow in the fill it points out of
192
- * over the fill it points into, so each side moves with its own item
193
- * (`colorsOwnedBy`, `../core/strip.ts`).
287
+ * `effect` on the colours any of `owners` drew, itself or through an owner
288
+ * nested in its output, and every other colour left as it is: one item of a
289
+ * strip whose items share a fill, the items of a row one other item stands
290
+ * apart from, one widget of a screen. A seam cell shows its two neighbours
291
+ * side by side, a powerline arrow in the fill it points out of over the fill
292
+ * it points into, so each side moves with its own item (`colorsOwnedBy`,
293
+ * `../core/strip.ts`). A cell none of them drew is never handed to the
294
+ * effect, so confining one effect to many owners costs one call a cell, not
295
+ * one for each owner.
194
296
  */
195
- export function confined(effect, owner) {
196
- return (t, drawn) => {
197
- const frame = effect(t, drawn);
297
+ export function confined(effect, ...owners) {
298
+ const confinedTo = new Set(owners);
299
+ // [LAW:single-enforcer] One reading of which slots are owned, for the cells listed and the cells handed alike.
300
+ const ownedOf = (cell) => colorsOwnedBy(cell.anchor, confinedTo);
301
+ const any = ({ fg, bg }) => fg || bg;
302
+ // The frame as the effect sees it: the owners' cells alone. [LAW:one-source-of-truth] One narrowing, handed to both stages.
303
+ const owned = (drawn) => ({ ...drawn, cells: () => drawn.cells().filter((cell) => any(ownedOf(cell))) });
304
+ // The owners' cells change when the effect's frame does.
305
+ return withNextChange((t, drawn) => {
306
+ const frame = effect(t, owned(drawn));
198
307
  return (colors, cell) => {
199
- const owned = colorsOwnedBy(cell.anchor, owner);
308
+ const owns = ownedOf(cell);
309
+ if (!any(owns))
310
+ return colors;
200
311
  const moved = frame(colors, cell);
201
- return { fg: owned.fg ? moved.fg : colors.fg, bg: owned.bg ? moved.bg : colors.bg };
312
+ return { fg: owns.fg ? moved.fg : colors.fg, bg: owns.bg ? moved.bg : colors.bg };
202
313
  };
203
- };
314
+ }, (t, drawn) => nextChangeOf(effect, t, owned(drawn)));
204
315
  }
205
316
  /**
206
317
  * What `segments`, drawn at `depth` on `theme`, draw their words in, measured
@@ -208,7 +319,8 @@ export function confined(effect, owner) {
208
319
  * output under an effect to read the contrast its words are left at.
209
320
  */
210
321
  export function drawnOf(segments, depth, theme) {
211
- return measured(looks(segments, depth, theme), depth, theme);
322
+ const looked = looks(segments, depth, theme);
323
+ return measured(looked, () => walk(looked), depth, theme);
212
324
  }
213
325
  /**
214
326
  * A style's colour slots as the screen shows them, or the reverse: `reverse`
@@ -10,7 +10,11 @@
10
10
  * a slow glow, sunlight moving on water, the colours of daylight, fireflies, ink
11
11
  * blooming in water, mist lifting — because a motion the eye recognises
12
12
  * reads as calm and alive, where a motion it has to learn reads as a widget.
13
- * What makes those motions familiar is what every curve here keeps:
13
+ * One effect here is not ambience but a signal, and keeps none of this on
14
+ * purpose: `steppedInk` steps its ink through colours on a beat, the news
15
+ * that something is at its limit, and jumps from slot to slot where the
16
+ * loops hold still. What makes the loops' motions familiar is what every
17
+ * curve of theirs keeps:
14
18
  *
15
19
  * - Nothing jumps. Every curve is continuous in time, built from smooth
16
20
  * noise (`core/noise.ts`) and smooth waveforms, so at one frame a second a cell
@@ -48,11 +52,12 @@ import { type Drawn, type Effect, type EffectCell } from "./effect.js";
48
52
  /** What a loop does to one colour at strength `w` in [0, 1]: at 0, nothing. */
49
53
  export type Touch = (color: ColorRgba, w: number) => ColorRgba;
50
54
  /**
51
- * How strongly a loop acts at a moment `t` on each cell, in [0, 1]; `onColors`
52
- * holds a field outside it to it. Staged as `Effect` is: what holds for the
53
- * whole moment is worked out once, then every cell is asked.
55
+ * How strongly a loop acts at a moment `t` on each cell of a frame drawing
56
+ * what `Drawn` says, in [0, 1]; `onColors` holds a field outside it to it.
57
+ * Staged as `Effect` is: what holds for the whole moment is worked out once,
58
+ * then every cell is asked.
54
59
  */
55
- export type Field = (t: number) => (cell: EffectCell) => number;
60
+ export type Field = (t: number, drawn: Drawn) => (cell: EffectCell) => number;
56
61
  /** A looping effect: what it does to a colour, and where and when, how strongly. */
57
62
  export interface Loop {
58
63
  readonly touch: Touch;
@@ -79,9 +84,10 @@ export declare function shares({ pairs, depth, theme }: Drawn, colors: ReadonlyS
79
84
  * A loop as an `Effect` on the element's own colours, by hex, wherever a cell
80
85
  * shows them, each touched at its share of the cell's strength: the share
81
86
  * `shares` settles for it against the pairs the element draws its words in
82
- * (`Drawn`), touched as `Effected` writes them at the frame's depth — below
83
- * truecolor a touched colour lands on a colour the slot can write, and the
84
- * contrast it spends is that colour's. `colors` are the ones lit, by default every colour the element
87
+ * (`Drawn`), touched as `Effected` writes them at the frame's depth — at 256
88
+ * colours a touched colour lands on a colour the slot can write, and the
89
+ * contrast it spends is that colour's; on a sixteen-colour terminal nothing
90
+ * moves (`stillOnSlots`). `colors` are the ones lit, by default every colour the element
85
91
  * sets itself. A powerline strip's fill is the ground of its cells and the
86
92
  * ink of the seam glyphs between them, so the arrow moves with the cell it
87
93
  * points out of. A colour the terminal supplies (`EffectCell.terminal`) is
@@ -177,8 +183,9 @@ export declare function pulse(curve: Curve, glow: ColorRgba, z: number): Loop;
177
183
  /**
178
184
  * Shimmer, as sunlight moving across water. Swing: how far into `light` the
179
185
  * brightest glint goes, 0–1. A soft band of light, `width` columns either
180
- * side of its centre, crosses columns 0 to `span`, entering and leaving
181
- * fully off the row, `STRIDE` columns in about three quarters of a period. Its passes
186
+ * side of its centre, crosses the element from the first column a frame
187
+ * lists (`Drawn.cells`) to the last, entering and leaving fully off it,
188
+ * `STRIDE` columns in about three quarters of a period. Its passes
182
189
  * come the way light comes and goes on water as clouds move: while the sun
183
190
  * is out they are bright and follow close on one another, one sometimes
184
191
  * catching the last; under cloud they thin to faint ones with the row still
@@ -188,11 +195,17 @@ export declare function pulse(curve: Curve, glow: ColorRgba, z: number): Loop;
188
195
  * on a slow current and re-forming as they go, so the glints dance while
189
196
  * the band glides. Before the first pass enters, the row is untouched.
190
197
  */
191
- export declare function shimmer(curve: Curve, span: number, width: number, glow: ColorRgba, z: number): Loop;
198
+ export declare function shimmer(curve: Curve, width: number, glow: ColorRgba, z: number): Loop;
192
199
  /**
193
200
  * Sparkle, as fireflies. Swing: how far into the firefly's `light` a glow
194
- * goes at its brightest, 0–1. About one firefly to every eight columns of
195
- * `span`, each keeping near a home of its own. A firefly flashes — kindles,
201
+ * goes at its brightest, 0–1. Fireflies keep to a lattice laid from the
202
+ * element's first column and top row as a frame lists its cells
203
+ * (`Drawn.cells`): one every `PER` columns along a row of the lattice, a row
204
+ * of the lattice every `ROWS` rows, each firefly near a home of its own.
205
+ * The lattice does not hang on how wide or deep the element is, so one that
206
+ * grows or shrinks at its last column or bottom row moves no firefly; one
207
+ * whose first column moves, as centred words that change length do, carries
208
+ * its fireflies with it. A firefly flashes — kindles,
196
209
  * drifts a few cells glowing, slowing as it fades — then flies on in the
197
210
  * dark and flashes again somewhere a few cells off, the way fireflies at
198
211
  * dusk are seen: a short lit stroke, then gone, each time a little
@@ -203,13 +216,14 @@ export declare function shimmer(curve: Curve, span: number, width: number, glow:
203
216
  * turn: its brightest flash, rise and fall, takes `FLASH` of it, and a
204
217
  * dimmer one less.
205
218
  */
206
- export declare function sparkle(curve: Curve, span: number, glow: ColorRgba, z: number): Loop;
219
+ export declare function sparkle(curve: Curve, glow: ColorRgba, z: number): Loop;
207
220
  /**
208
- * How much of each cell shows at `t`: 1 is the cell as drawn, 0 is the cell
209
- * gone to the terminal's ground. Read per cell, so cells can arrive or leave
210
- * apart, and staged as `Field` is. One outside [0, 1] is held to it.
221
+ * How much of each cell shows at `t` in a frame drawing what `Drawn` says: 1
222
+ * is the cell as drawn, 0 is the cell gone to the terminal's ground. Read per
223
+ * cell, so cells can arrive or leave apart, and staged as `Effect` is. One
224
+ * outside [0, 1] is held to it.
211
225
  */
212
- export type Visibility = (t: number) => (cell: EffectCell) => number;
226
+ export type Visibility = (t: number, drawn: Drawn) => (cell: EffectCell) => number;
213
227
  /**
214
228
  * A cell mixed toward the ground of the terminal the frame is drawn on
215
229
  * (`Drawn.theme`) by how hidden it is: its fill toward the ground, and its ink
@@ -225,6 +239,8 @@ export declare function veiled(visibility: Visibility, swing: number): Effect;
225
239
  * draws it at the time since it began. `done(t)` is true from the moment
226
240
  * every cell has arrived or gone, so the caller knows when to stop drawing an
227
241
  * element that is leaving, or to drop the effect from one that has arrived.
242
+ * Until then the effect's frame changes every frame, and from then never:
243
+ * its `nextChange` is `done` read as a time. [LAW:one-source-of-truth]
228
244
  */
229
245
  export interface Transition {
230
246
  readonly effect: Effect;
@@ -259,19 +275,50 @@ export declare function dissolveOut(curve: Curve, z: number): Transition;
259
275
  * together; on the terminal's ground, a run of text's ink is a segment of
260
276
  * its own. A segment's stray is slow noise of its own, at `STRAY_PERIODS`,
261
277
  * so no two keep step, and the row never shows one spread of hues twice.
262
- * Lightness and chroma are kept, so the words read as they did; the turn
263
- * only happens to the element's own colours (`Drawn.colors`), never the
264
- * terminal's.
278
+ * A colour keeps its luminance as it turns (`hueTurned`), so every pair of
279
+ * words reads at the contrast it rested at; at 256 colours a turned colour
280
+ * lands on a colour the depth writes, one that keeps every pair of words it
281
+ * is drawn in at its floor (`floorOf`) — its resting contrast or WCAG AA,
282
+ * whichever is lower — as `shares` keeps the light's (`turnedBounds`). The
283
+ * turn only happens to the element's own colours (`Drawn.colors`), never the
284
+ * terminal's. On a sixteen-colour terminal nothing turns (`stillOnSlots`).
265
285
  */
266
286
  export declare function wheel(curve: Curve, z: number): Effect;
287
+ /** One step of a signal: the ink it draws, and how long it is held, in seconds. */
288
+ export interface ColorStep {
289
+ readonly color: ColorRgba;
290
+ readonly seconds: number;
291
+ }
292
+ /**
293
+ * A signal: every cell's ink stepping through `steps`' colours on a beat,
294
+ * one colour a hold, looping, for the time it is handed — a gauge's
295
+ * chevrons in bright changing colours. The ground is left as the cell draws
296
+ * it, and the words stay legible on it: each step's ink is written as the
297
+ * colour nearest the step's (OKLab's ΔE) that the depth draws and that
298
+ * reads on the cell's ground, as `Effected` writes it, at the cell's floor
299
+ * (`floorOf`) — its resting contrast or `LEGIBLE`, whichever is lower, the
300
+ * floor every loop here keeps. In truecolor that is the step itself, or its
301
+ * hue and chroma at the lightness nearest it that reaches the floor
302
+ * (`ensureContrast`, the one slide a template's `readableOn` makes too);
303
+ * below truecolor, the nearest of the colours the depth has an index for, so on a
304
+ * sixteen-colour terminal it steps from slot to slot, the one effect here
305
+ * that does (`stillOnSlots`). A cell whose ground no offered colour reads
306
+ * on keeps the ink it rested in.
307
+ *
308
+ * The search is settled once for each ink, ground and step, so a frame
309
+ * inside a step redoes none, and nor does a later pass of the beat: at sixty
310
+ * frames a second a one-second step is sixty identical frames, and the
311
+ * frame says it changes next when the step ends (`Beat.end`).
312
+ * [LAW:no-ambient-temporal-coupling] The beat is `core/easing.ts`'s, and
313
+ * nothing here reads a clock.
314
+ */
315
+ export declare function steppedInk(steps: readonly ColorStep[]): Effect;
267
316
  /**
268
- * What a catalogue effect is made for: the element it is drawn on, as wide as
269
- * `span` columns — how far a shimmer crosses, how many fireflies keep to it —
270
- * and at `z` in the noise, so two elements under one effect do not move in
271
- * lockstep.
317
+ * What a catalogue effect is made for: the element it is drawn on, at `z` in
318
+ * the noise, so two elements under one effect do not move in lockstep. How
319
+ * wide the element is, the effect reads off each frame (`Drawn.cells`).
272
320
  */
273
321
  export interface EffectTarget {
274
- readonly span: number;
275
322
  readonly z: number;
276
323
  }
277
324
  /** The names of the effects that loop. */