@promptctl/rich-js 0.21.0 → 0.23.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.
@@ -259,6 +259,15 @@ export declare class ColorSpec {
259
259
  * on a ground (`flattenAlpha`).
260
260
  */
261
261
  static matchOn(value: ColorRgba, depth: ColorDepth, theme: TerminalTheme, foreground: boolean): ColorSpec;
262
+ /**
263
+ * The spec a slot is written in once its colour moves from `was`, what its
264
+ * written spec draws on `theme`, to the opaque `now`: `matchOn`'s — or
265
+ * none, where the written spec is as near `now` as that match. A move the
266
+ * depth cannot show is no move, so below truecolor a colour moved a little
267
+ * off any colour the depth can write — a theme slot, the default colour,
268
+ * the cube — keeps the spec it was written in.
269
+ */
270
+ static matchMove(was: ColorRgba, now: ColorRgba, depth: ColorDepth, theme: TerminalTheme, foreground: boolean): ColorSpec | undefined;
262
271
  /**
263
272
  * Parse a color string. Memoized (`Memo`): a string parsed again while
264
273
  * remembered returns the same instance.
@@ -624,6 +624,18 @@ export class ColorSpec {
624
624
  ];
625
625
  return candidates.reduce((best, next) => (rgbDistance(next[1], value) < rgbDistance(best[1], value) ? next : best))[0];
626
626
  }
627
+ /**
628
+ * The spec a slot is written in once its colour moves from `was`, what its
629
+ * written spec draws on `theme`, to the opaque `now`: `matchOn`'s — or
630
+ * none, where the written spec is as near `now` as that match. A move the
631
+ * depth cannot show is no move, so below truecolor a colour moved a little
632
+ * off any colour the depth can write — a theme slot, the default colour,
633
+ * the cube — keeps the spec it was written in.
634
+ */
635
+ static matchMove(was, now, depth, theme, foreground) {
636
+ const match = ColorSpec.matchOn(now, depth, theme, foreground);
637
+ return rgbDistance(was, now) <= rgbDistance(match.getTruecolor(theme, foreground), now) ? undefined : match;
638
+ }
627
639
  /**
628
640
  * Parse a color string. Memoized (`Memo`): a string parsed again while
629
641
  * remembered returns the same instance.
@@ -77,11 +77,11 @@ export interface Joiner<T extends StyledRenderable = StyledRenderable> {
77
77
  */
78
78
  export declare const JOIN: object;
79
79
  /**
80
- * Which of a cell's two colours on screen are `owner`'s: both, for a cell
81
- * `owner` drew, itself or through an owner nested in its output; for a cell a
82
- * `CellJoiner` drew, the sides shown in `owner`'s colour.
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
83
  */
84
- export declare function colorsOwnedBy(anchor: Anchor | undefined, owner: object): {
84
+ export declare function colorsOwnedBy(anchor: Anchor | undefined, owners: ReadonlySet<object>): {
85
85
  readonly fg: boolean;
86
86
  readonly bg: boolean;
87
87
  };
@@ -59,16 +59,18 @@ class ColorOwners {
59
59
  }
60
60
  }
61
61
  /**
62
- * Which of a cell's two colours on screen are `owner`'s: both, for a cell
63
- * `owner` drew, itself or through an owner nested in its output; for a cell a
64
- * `CellJoiner` drew, the sides shown in `owner`'s colour.
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
65
  */
66
- export function colorsOwnedBy(anchor, owner) {
66
+ export function colorsOwnedBy(anchor, owners) {
67
67
  for (let level = anchor; level; level = level.inner) {
68
- if (level.owner === owner)
68
+ if (owners.has(level.owner))
69
69
  return { fg: true, bg: true };
70
- if (level.owner instanceof ColorOwners)
71
- return { fg: level.owner.fg === owner, bg: level.owner.bg === owner };
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
+ }
72
74
  }
73
75
  return { fg: false, bg: false };
74
76
  }
package/dist/index.d.ts CHANGED
@@ -57,8 +57,8 @@ export type { Env } from "./core/env.js";
57
57
  export { Constrain } from "./renderables/constrain.js";
58
58
  export { Effected, confined, drawnOf } from "./renderables/effect.js";
59
59
  export type { CellColors, Drawn, Effect, EffectCell, EffectedOptions, Pair } from "./renderables/effect.js";
60
- export { EFFECT_CURVES, EFFECT_LIGHTS, SHIMMER_WIDTH, dissolveOut, fadeIn, onColors, pulse, shimmer, sparkle, wheel, } from "./renderables/effects.js";
61
- export type { Curve, Field, Loop, Touch, Transition } from "./renderables/effects.js";
60
+ export { EFFECT_CATALOGUE, EFFECT_CURVES, EFFECT_LIGHTS, dissolveOut, fadeIn, onColors, pulse, shimmer, sparkle, wheel, } from "./renderables/effects.js";
61
+ export type { Curve, EffectTarget, Field, Loop, LoopName, Touch, Transition, TransitionName } from "./renderables/effects.js";
62
62
  export { Align } from "./renderables/align.js";
63
63
  export type { Alignment } from "./core/place.js";
64
64
  export { Padding } from "./renderables/padding.js";
package/dist/index.js CHANGED
@@ -70,7 +70,7 @@ export { Console } from "./core/console.js";
70
70
  // Renderables
71
71
  export { Constrain } from "./renderables/constrain.js";
72
72
  export { Effected, confined, drawnOf } from "./renderables/effect.js";
73
- export { EFFECT_CURVES, EFFECT_LIGHTS, SHIMMER_WIDTH, dissolveOut, fadeIn, onColors, pulse, shimmer, sparkle, wheel, } from "./renderables/effects.js";
73
+ export { EFFECT_CATALOGUE, EFFECT_CURVES, EFFECT_LIGHTS, dissolveOut, fadeIn, onColors, pulse, shimmer, sparkle, wheel, } from "./renderables/effects.js";
74
74
  export { Align } from "./renderables/align.js";
75
75
  export { Padding } from "./renderables/padding.js";
76
76
  export { Viewport, ViewportPerKey, SCROLLBAR } from "./renderables/viewport.js";
@@ -2,16 +2,21 @@
2
2
  * Effected — a renderable whose finished cells' colours pass through an
3
3
  * effect at a moment `t`.
4
4
  *
5
- * An effect is one pure function: a cell's colours, where the cell is, a time,
6
- * and what the element draws in that frame in, the cell's new colours out.
7
- * Shimmer, pulse, fade and dissolve are all this one shape, differing by what
8
- * the function does with its inputs [LAW:one-type-per-behavior]; two effects
9
- * stack by nesting one wrapper in another, which is the same as calling one
10
- * after the other but for what the outer one measures: the inner one's output.
5
+ * An effect is one pure function in two stages: a time and what the element
6
+ * draws in that frame in, a function of the frame out; and into that, a
7
+ * cell's colours and where the cell is, the cell's new colours out. Whatever
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, 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
12
+ * dissolve are all this one shape, differing by what the function does with
13
+ * its inputs [LAW:one-type-per-behavior]; two effects stack by nesting one
14
+ * wrapper in another, which is the same as calling one after the other but
15
+ * for what the outer one measures: the inner one's output.
11
16
  *
12
17
  * What the element draws (`Drawn`) is measured here, in the render, off the
13
- * same reading of each segment its cells are drawn from, and handed to every
14
- * cell of the frame alike. It is what an effect keeping words legible is
18
+ * same reading of each segment its cells are drawn from, and handed to the
19
+ * effect's first stage, once a frame. It is what an effect keeping words legible is
15
20
  * settled against (`onColors`, `./effects.ts`), so no caller derives it a
16
21
  * second time from a render of its own.
17
22
  *
@@ -81,11 +86,19 @@ export interface EffectCell {
81
86
  readonly anchor: Anchor | undefined;
82
87
  }
83
88
  /**
84
- * A cell's ink and ground as the screen shows them, and which of the two are
85
- * the terminal's: what an `Effect` is handed for that cell.
89
+ * A cell's ink and ground as the screen shows them, which of the two are the
90
+ * terminal's, and whether its style swaps them (`reverse`): what an `Effect`
91
+ * is handed for that cell.
86
92
  */
87
93
  export interface Pair extends CellColors {
88
94
  readonly terminal: EffectCell["terminal"];
95
+ /**
96
+ * Whether the ink is written in the style's ground slot and the ground in
97
+ * its ink slot. Below truecolor the slot decides what a moved colour can
98
+ * land on: a slot set to `default` draws the terminal's foreground in one
99
+ * and its background in the other.
100
+ */
101
+ readonly reverse: boolean;
89
102
  }
90
103
  /**
91
104
  * The colours the element an effect wraps draws its words in, in one frame,
@@ -107,10 +120,13 @@ export interface Drawn {
107
120
  readonly theme: TerminalTheme;
108
121
  }
109
122
  /**
110
- * A cell's colours at time `t`, in seconds, in a frame whose element draws
111
- * `drawn`. Return `colors` to leave it be.
123
+ * The frame at time `t`, in seconds, whose element draws `drawn`: each cell's
124
+ * colours in that frame. Return `colors` to leave a cell be.
125
+ * [LAW:types-are-the-program] Per-frame work is the outer stage, so it runs
126
+ * once a frame by the shape of the call, never by a cache asking whether this
127
+ * cell's frame is the last one's.
112
128
  */
113
- export type Effect = (colors: CellColors, cell: EffectCell, t: number, drawn: Drawn) => CellColors;
129
+ export type Effect = (t: number, drawn: Drawn) => (colors: CellColors, cell: EffectCell) => CellColors;
114
130
  export interface EffectedOptions {
115
131
  /** The moment to draw, in seconds. */
116
132
  readonly t: number;
@@ -138,31 +154,35 @@ export declare class Effected implements Renderable, Measurable {
138
154
  minimum: number;
139
155
  maximum: number;
140
156
  };
157
+ /**
158
+ * The colours the screen shows for `pair` once it is drawn moved to `to`,
159
+ * both as the screen shows them, at `depth` on `theme`: what an effect
160
+ * moving a pair's colours leaves its words reading in.
161
+ * [LAW:one-source-of-truth] Read through `movedSpecs`, the one writer a
162
+ * moved slot is written with.
163
+ */
164
+ static shows(pair: Pair, to: CellColors, depth: ColorDepth, theme: TerminalTheme): CellColors;
141
165
  /**
142
166
  * The specs that draw a cell's slot colours `to` where the segment drew
143
- * `from` with `wire`: a slot the effect left alone keeps its written spec,
144
- * and a moved one is laid on what is beneath it — the ground on
145
- * `SURFACE_BLACK`, the glyph on that ground, as the writer lays any
146
- * translucent colour (`Style.drawnColors`) — then drawn in the colour nearest it at `depth`
147
- * that the terminal shows (`ColorSpec.matchOn`). A move the depth cannot
148
- * show is no move: where the written spec is as near the moved colour as
149
- * that match, the slot keeps it, so below truecolor a colour moved a little
150
- * off any colour it can write — a theme slot, the default colour, the cube —
151
- * is still drawn as that one.
167
+ * `from` with `wire`: each moved slot in the spec `movedSpecs` writes it
168
+ * in, the rest as `wire` wrote them.
152
169
  */
153
170
  private respec;
154
171
  /** A cell's seed: FNV-1a of the key and the position, scaled into [0, 1). */
155
172
  private seed;
156
173
  }
157
174
  /**
158
- * `effect` on the colours `owner` drew, itself or through an owner nested in
159
- * its output, and every other colour left as it is: one item of a strip whose
160
- * items share a fill, one widget of a screen. A seam cell shows its two
161
- * neighbours side by side, a powerline arrow in the fill it points out of
162
- * over the fill it points into, so each side moves with its own item
163
- * (`colorsOwnedBy`, `../core/strip.ts`).
175
+ * `effect` on the colours any of `owners` drew, itself or through an owner
176
+ * nested in its output, and every other colour left as it is: one item of a
177
+ * strip whose items share a fill, the items of a row one other item stands
178
+ * apart from, one widget of a screen. A seam cell shows its two neighbours
179
+ * side by side, a powerline arrow in the fill it points out of over the fill
180
+ * it points into, so each side moves with its own item (`colorsOwnedBy`,
181
+ * `../core/strip.ts`). A cell none of them drew is never handed to the
182
+ * effect, so confining one effect to many owners costs one call a cell, not
183
+ * one for each owner.
164
184
  */
165
- export declare function confined(effect: Effect, owner: object): Effect;
185
+ export declare function confined(effect: Effect, ...owners: readonly object[]): Effect;
166
186
  /**
167
187
  * What `segments`, drawn at `depth` on `theme`, draw their words in, measured
168
188
  * as `Effected` measures its child for every frame. Measure an element's
@@ -2,16 +2,21 @@
2
2
  * Effected — a renderable whose finished cells' colours pass through an
3
3
  * effect at a moment `t`.
4
4
  *
5
- * An effect is one pure function: a cell's colours, where the cell is, a time,
6
- * and what the element draws in that frame in, the cell's new colours out.
7
- * Shimmer, pulse, fade and dissolve are all this one shape, differing by what
8
- * the function does with its inputs [LAW:one-type-per-behavior]; two effects
9
- * stack by nesting one wrapper in another, which is the same as calling one
10
- * after the other but for what the outer one measures: the inner one's output.
5
+ * An effect is one pure function in two stages: a time and what the element
6
+ * draws in that frame in, a function of the frame out; and into that, a
7
+ * cell's colours and where the cell is, the cell's new colours out. Whatever
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, 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
12
+ * dissolve are all this one shape, differing by what the function does with
13
+ * its inputs [LAW:one-type-per-behavior]; two effects stack by nesting one
14
+ * wrapper in another, which is the same as calling one after the other but
15
+ * for what the outer one measures: the inner one's output.
11
16
  *
12
17
  * What the element draws (`Drawn`) is measured here, in the render, off the
13
- * same reading of each segment its cells are drawn from, and handed to every
14
- * cell of the frame alike. It is what an effect keeping words legible is
18
+ * same reading of each segment its cells are drawn from, and handed to the
19
+ * effect's first stage, once a frame. It is what an effect keeping words legible is
15
20
  * settled against (`onColors`, `./effects.ts`), so no caller derives it a
16
21
  * second time from a render of its own.
17
22
  *
@@ -42,7 +47,7 @@
42
47
  */
43
48
  import { drawnBy, shiftAnchor } from "../core/anchor.js";
44
49
  import { cellLen, graphemes } from "../core/cells.js";
45
- import { ColorDepth, ColorSpec, SURFACE_BLACK, rgbDistance } from "../core/color.js";
50
+ import { ColorDepth, ColorSpec, SURFACE_BLACK } from "../core/color.js";
46
51
  import { fnv1a } from "../core/fnv1a.js";
47
52
  import { Measurement } from "../core/measure.js";
48
53
  import { Segment } from "../core/segment.js";
@@ -75,7 +80,7 @@ export class Effected {
75
80
  // [LAW:one-source-of-truth] The frame is measured before any cell is
76
81
  // handed to the effect, off the same looks its cells are drawn from.
77
82
  const looked = looks(segments, depth, this.theme);
78
- const drawn = measured(looked, depth, this.theme);
83
+ const frame = this.effect(this.t, measured(looked, depth, this.theme));
79
84
  let row = 0;
80
85
  let col = 0;
81
86
  for (const { segment, look } of looked) {
@@ -102,7 +107,7 @@ export class Effected {
102
107
  else {
103
108
  const anchor = base.anchor && shiftAnchor(base.anchor, into);
104
109
  // Back from the screen to the style's slots: the same swap undoes itself.
105
- colors = onScreen(this.effect(from, { row, col, seed: this.seed(row, col), terminal, anchor }, this.t, drawn), base);
110
+ colors = onScreen(frame(from, { row, col, seed: this.seed(row, col), terminal, anchor }), pair.reverse);
106
111
  col += cellLen(glyph);
107
112
  }
108
113
  into += cellLen(glyph);
@@ -133,27 +138,26 @@ export class Effected {
133
138
  measure(options) {
134
139
  return Measurement.get(options, this.renderable);
135
140
  }
141
+ /**
142
+ * The colours the screen shows for `pair` once it is drawn moved to `to`,
143
+ * both as the screen shows them, at `depth` on `theme`: what an effect
144
+ * moving a pair's colours leaves its words reading in.
145
+ * [LAW:one-source-of-truth] Read through `movedSpecs`, the one writer a
146
+ * moved slot is written with.
147
+ */
148
+ static shows(pair, to, depth, theme) {
149
+ const from = onScreen(pair, pair.reverse);
150
+ const moved = movedSpecs(from, onScreen(to, pair.reverse), depth, theme);
151
+ return onScreen({ fg: moved.fg?.getTruecolor(theme, true) ?? from.fg, bg: moved.bg?.getTruecolor(theme, false) ?? from.bg }, pair.reverse);
152
+ }
136
153
  /**
137
154
  * The specs that draw a cell's slot colours `to` where the segment drew
138
- * `from` with `wire`: a slot the effect left alone keeps its written spec,
139
- * and a moved one is laid on what is beneath it — the ground on
140
- * `SURFACE_BLACK`, the glyph on that ground, as the writer lays any
141
- * translucent colour (`Style.drawnColors`) — then drawn in the colour nearest it at `depth`
142
- * that the terminal shows (`ColorSpec.matchOn`). A move the depth cannot
143
- * show is no move: where the written spec is as near the moved colour as
144
- * that match, the slot keeps it, so below truecolor a colour moved a little
145
- * off any colour it can write — a theme slot, the default colour, the cube —
146
- * is still drawn as that one.
155
+ * `from` with `wire`: each moved slot in the spec `movedSpecs` writes it
156
+ * in, the rest as `wire` wrote them.
147
157
  */
148
158
  respec(wire, from, to, depth) {
149
- // [LAW:one-source-of-truth] The writer's surface, so one translucent colour draws one way in a frame.
150
- const ground = to.bg.compositeOver(SURFACE_BLACK);
151
- const glyph = to.fg.compositeOver(ground);
152
- const drawn = (spec, was, now, foreground) => {
153
- const match = ColorSpec.matchOn(now, depth, this.theme, foreground);
154
- return rgbDistance(was, now) <= rgbDistance(match.getTruecolor(this.theme, foreground), now) ? spec : match;
155
- };
156
- return { color: drawn(wire.color, from.fg, glyph, true), bgcolor: drawn(wire.bgcolor, from.bg, ground, false) };
159
+ const moved = movedSpecs(from, to, depth, this.theme);
160
+ return { color: moved.fg ?? wire.color, bgcolor: moved.bg ?? wire.bgcolor };
157
161
  }
158
162
  /** A cell's seed: FNV-1a of the key and the position, scaled into [0, 1). */
159
163
  seed(row, col) {
@@ -169,31 +173,41 @@ function looks(segments, depth, theme) {
169
173
  const style = segment.style ?? Style.null();
170
174
  const wire = style.drawnColors(depth);
171
175
  const slots = { fg: (wire.color ?? DEFAULT).getTruecolor(theme, true), bg: (wire.bgcolor ?? DEFAULT).getTruecolor(theme, false) };
172
- const terminal = onScreen({ fg: wire.color?.isDefault ?? true, bg: wire.bgcolor?.isDefault ?? true }, style);
173
- return { segment, look: { style, wire, slots, pair: { ...onScreen(slots, style), terminal } } };
176
+ const reverse = style.reverse === true;
177
+ const terminal = onScreen({ fg: wire.color?.isDefault ?? true, bg: wire.bgcolor?.isDefault ?? true }, reverse);
178
+ return { segment, look: { style, wire, slots, pair: { ...onScreen(slots, reverse), terminal, reverse } } };
174
179
  });
175
180
  }
176
181
  /** What `looked` draws its words in: `Drawn`'s measure, taken once per frame. */
177
182
  function measured(looked, depth, theme) {
178
183
  const words = looked.flatMap(({ segment, look }) => (look !== undefined && segment.text.trim() !== "" && !drawnBy(look.style.anchor, JOIN) ? [look.pair] : []));
179
- const pairs = [...new Map(words.map((pair) => [`${pair.fg.hex}/${pair.bg.hex}/${pair.terminal.fg}/${pair.terminal.bg}`, pair])).values()];
184
+ const pairs = [...new Map(words.map((pair) => [`${pair.fg.hex}/${pair.bg.hex}/${pair.terminal.fg}/${pair.terminal.bg}/${pair.reverse}`, pair])).values()];
180
185
  // [LAW:one-source-of-truth] A slot the terminal fills is no colour of the element's, whatever its hex.
181
186
  const own = (slot) => pairs.flatMap((pair) => (pair.terminal[slot] ? [] : [pair[slot].hex]));
182
187
  return { pairs, colors: new Set([...own("fg"), ...own("bg")]), fills: new Set(own("bg")), depth, theme };
183
188
  }
184
189
  /**
185
- * `effect` on the colours `owner` drew, itself or through an owner nested in
186
- * its output, and every other colour left as it is: one item of a strip whose
187
- * items share a fill, one widget of a screen. A seam cell shows its two
188
- * neighbours side by side, a powerline arrow in the fill it points out of
189
- * over the fill it points into, so each side moves with its own item
190
- * (`colorsOwnedBy`, `../core/strip.ts`).
190
+ * `effect` on the colours any of `owners` drew, itself or through an owner
191
+ * nested in its output, and every other colour left as it is: one item of a
192
+ * strip whose items share a fill, the items of a row one other item stands
193
+ * apart from, one widget of a screen. A seam cell shows its two neighbours
194
+ * side by side, a powerline arrow in the fill it points out of over the fill
195
+ * it points into, so each side moves with its own item (`colorsOwnedBy`,
196
+ * `../core/strip.ts`). A cell none of them drew is never handed to the
197
+ * effect, so confining one effect to many owners costs one call a cell, not
198
+ * one for each owner.
191
199
  */
192
- export function confined(effect, owner) {
193
- return (colors, cell, t, drawn) => {
194
- const owned = colorsOwnedBy(cell.anchor, owner);
195
- const moved = effect(colors, cell, t, drawn);
196
- return { fg: owned.fg ? moved.fg : colors.fg, bg: owned.bg ? moved.bg : colors.bg };
200
+ export function confined(effect, ...owners) {
201
+ const confinedTo = new Set(owners);
202
+ return (t, drawn) => {
203
+ const frame = effect(t, drawn);
204
+ return (colors, cell) => {
205
+ const owned = colorsOwnedBy(cell.anchor, confinedTo);
206
+ if (!owned.fg && !owned.bg)
207
+ return colors;
208
+ const moved = frame(colors, cell);
209
+ return { fg: owned.fg ? moved.fg : colors.fg, bg: owned.bg ? moved.bg : colors.bg };
210
+ };
197
211
  };
198
212
  }
199
213
  /**
@@ -208,8 +222,22 @@ export function drawnOf(segments, depth, theme) {
208
222
  * A style's colour slots as the screen shows them, or the reverse: `reverse`
209
223
  * swaps glyph and ground, and swapping twice is where you started.
210
224
  */
211
- function onScreen(slots, style) {
212
- return style.reverse === true ? { fg: slots.bg, bg: slots.fg } : slots;
225
+ function onScreen(slots, reverse) {
226
+ return reverse ? { fg: slots.bg, bg: slots.fg } : slots;
227
+ }
228
+ /**
229
+ * The specs a cell's slots are written in once their colours move from
230
+ * `from`, what the written specs draw, to `to`: each laid on what is beneath
231
+ * it — the ground on `SURFACE_BLACK`, the glyph on that ground, as the writer
232
+ * lays any translucent colour (`Style.drawnColors`) — then matched at `depth`
233
+ * (`ColorSpec.matchMove`). A slot the depth shows no move in has none, and
234
+ * keeps the spec it was written in.
235
+ */
236
+ function movedSpecs(from, to, depth, theme) {
237
+ // [LAW:one-source-of-truth] The writer's surface, so one translucent colour draws one way in a frame.
238
+ const ground = to.bg.compositeOver(SURFACE_BLACK);
239
+ const glyph = to.fg.compositeOver(ground);
240
+ return { fg: ColorSpec.matchMove(from.fg, glyph, depth, theme, true), bg: ColorSpec.matchMove(from.bg, ground, depth, theme, false) };
213
241
  }
214
242
  /**
215
243
  * Whether two cells' colours are written alike. A slot with no colour is not
@@ -3,7 +3,8 @@
3
3
  * long as they are drawn, and fade-in and dissolve-out, which run once and
4
4
  * are done. Each is an `Effect` for `Effected` (`./effect.ts`); the defaults
5
5
  * in `EFFECT_CURVES` are the ones signed off by eye in the effects-feel demo
6
- * (`npm run effects-feel`), at 30 fps and at one frame a second.
6
+ * (`npm run effects-feel`), at 30 fps and at one frame a second, and
7
+ * `EFFECT_CATALOGUE` makes every one of them at those, by name, one way.
7
8
  *
8
9
  * Each effect is something a first-time observer already knows from life —
9
10
  * a slow glow, sunlight moving on water, the colours of daylight, fireflies, ink
@@ -43,11 +44,15 @@
43
44
  */
44
45
  import { ColorRgba } from "../core/color.js";
45
46
  import { type Ease } from "../core/easing.js";
46
- import type { Drawn, Effect, EffectCell, Pair } from "./effect.js";
47
+ import { type Drawn, type Effect, type EffectCell } from "./effect.js";
47
48
  /** What a loop does to one colour at strength `w` in [0, 1]: at 0, nothing. */
48
49
  export type Touch = (color: ColorRgba, w: number) => ColorRgba;
49
- /** How strongly a loop acts on a cell at a moment, in [0, 1]; `onColors` holds a field outside it to it. */
50
- export type Field = (cell: EffectCell, t: number) => number;
50
+ /**
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.
54
+ */
55
+ export type Field = (t: number) => (cell: EffectCell) => number;
51
56
  /** A looping effect: what it does to a colour, and where and when, how strongly. */
52
57
  export interface Loop {
53
58
  readonly touch: Touch;
@@ -55,9 +60,10 @@ export interface Loop {
55
60
  }
56
61
  /**
57
62
  * How much of `touch` each of `colors` (hex) can take: the largest share in
58
- * [0, 1] at which every pair in `pairs` that shows it, touched at the same
63
+ * [0, 1] at which every pair `drawn` shows it in, touched at the same
59
64
  * strength, still reads at its resting contrast or `LEGIBLE`, whichever is
60
- * lower. A colour in a pair but not in `colors` takes none, and so does one
65
+ * lower, in the colours `Effected` writes it in at `drawn`'s depth and theme.
66
+ * A colour in a pair but not in `colors` takes none, and so does one
61
67
  * the terminal supplies, whatever its hex, as in `onColors`. One of `colors`
62
68
  * no pair writes has nothing to be measured against, and takes it all.
63
69
  *
@@ -68,14 +74,14 @@ export interface Loop {
68
74
  * the fill room to glow — and a darker colour left as it is always holds,
69
75
  * because its lighter partner was settled against exactly that.
70
76
  */
71
- export declare function shares(pairs: readonly Pair[], colors: ReadonlySet<string>, touch: Touch): Map<string, number>;
77
+ export declare function shares({ pairs, depth, theme }: Drawn, colors: ReadonlySet<string>, touch: Touch): Map<string, number>;
72
78
  /**
73
79
  * A loop as an `Effect` on the element's own colours, by hex, wherever a cell
74
80
  * shows them, each touched at its share of the cell's strength: the share
75
81
  * `shares` settles for it against the pairs the element draws its words in
76
- * (`Drawn`), touched as the frame's depth writes them — at 256 colours a
77
- * touched colour lands on the cube, and the contrast it spends is the cube
78
- * colour's. `colors` are the ones lit, by default every colour the element
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
79
85
  * sets itself. A powerline strip's fill is the ground of its cells and the
80
86
  * ink of the seam glyphs between them, so the arrow moves with the cell it
81
87
  * points out of. A colour the terminal supplies (`EffectCell.terminal`) is
@@ -199,46 +205,46 @@ export declare function shimmer(curve: Curve, span: number, width: number, glow:
199
205
  */
200
206
  export declare function sparkle(curve: Curve, span: number, glow: ColorRgba, z: number): Loop;
201
207
  /**
202
- * How much of a cell shows at `t`: 1 is the cell as drawn, 0 is the cell gone
203
- * to the terminal's ground. Read per cell, so cells can arrive or leave apart. One
204
- * outside [0, 1] is held to it.
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.
205
211
  */
206
- export type Visibility = (cell: EffectCell, t: number) => number;
212
+ export type Visibility = (t: number) => (cell: EffectCell) => number;
207
213
  /**
208
- * A cell mixed toward the terminal's `ground` by how hidden it is: its fill
209
- * toward the ground, and its ink toward that fill, so an element with fills
210
- * of its own — a powerline strip — arrives and leaves whole, fill and words
211
- * together, never as bare coloured blocks. Invisible is ink and fill equal
212
- * to the ground. `swing` scales the hiding — 1 reaches fully invisible. A
213
- * cell wholly shown is returned as it was.
214
+ * A cell mixed toward the ground of the terminal the frame is drawn on
215
+ * (`Drawn.theme`) by how hidden it is: its fill toward the ground, and its ink
216
+ * toward that fill, so an element with fills of its own — a powerline strip —
217
+ * arrives and leaves whole, fill and words together, never as bare coloured
218
+ * blocks. Invisible is ink and fill equal to the ground. `swing` scales the
219
+ * hiding — 1 reaches fully invisible. A cell wholly shown is returned as it was.
214
220
  */
215
- export declare function veiled(visibility: Visibility, swing: number, ground: ColorRgba): Effect;
221
+ export declare function veiled(visibility: Visibility, swing: number): Effect;
216
222
  /**
217
- * An effect that runs once: a fade-in or a dissolve-out from its start time
218
- * over its curve's `seconds`. `done(t)` is true from the moment every cell
219
- * has arrived or gone, so the caller knows when to stop drawing an element
220
- * that is leaving, or to drop the effect from one that has arrived.
223
+ * An effect that runs once: a fade-in or a dissolve-out over its curve's
224
+ * `seconds` from `t = 0`, its own clock as every effect's is — the caller
225
+ * draws it at the time since it began. `done(t)` is true from the moment
226
+ * every cell has arrived or gone, so the caller knows when to stop drawing an
227
+ * element that is leaving, or to drop the effect from one that has arrived.
221
228
  */
222
229
  export interface Transition {
223
230
  readonly effect: Effect;
224
231
  done(t: number): boolean;
225
232
  }
226
233
  /**
227
- * Fade-in starting at `start`, as ink blooming in water: patches of the
228
- * element surface first and the rest follows outward from them, each cell
229
- * rising smoothly over its own part of the duration. Whole at
230
- * `start + seconds`.
234
+ * Fade-in, as ink blooming in water: patches of the element surface first
235
+ * and the rest follows outward from them, each cell rising smoothly over its
236
+ * own part of the duration. Whole at `seconds`.
231
237
  */
232
- export declare function fadeIn(curve: Curve, start: number, z: number, ground: ColorRgba): Transition;
238
+ export declare function fadeIn(curve: Curve, z: number): Transition;
233
239
  /**
234
- * Dissolve-out starting at `start`, as mist lifting: the element thins in
235
- * drifting patches, each cell fading smoothly over its own part of the
236
- * duration, until nothing is left at `start + seconds`. Mist does not lift
240
+ * Dissolve-out, as mist lifting: the element thins in drifting patches, each
241
+ * cell fading smoothly over its own part of the duration, until nothing is
242
+ * left at `seconds`. Mist does not lift
237
243
  * in one breath: towards the end, patches of what is left — the later a cell
238
244
  * goes, the likelier — rise back part way, as the last of it eddies, before
239
245
  * they thin away for good.
240
246
  */
241
- export declare function dissolveOut(curve: Curve, start: number, z: number, ground: ColorRgba): Transition;
247
+ export declare function dissolveOut(curve: Curve, z: number): Transition;
242
248
  /**
243
249
  * Wheel, as the colour of daylight going round: every hue turning the whole
244
250
  * way round once a period — far too slowly to be seen moving, so a glance
@@ -258,3 +264,32 @@ export declare function dissolveOut(curve: Curve, start: number, z: number, grou
258
264
  * terminal's.
259
265
  */
260
266
  export declare function wheel(curve: Curve, z: number): Effect;
267
+ /**
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.
272
+ */
273
+ export interface EffectTarget {
274
+ readonly span: number;
275
+ readonly z: number;
276
+ }
277
+ /** The names of the effects that loop. */
278
+ export type LoopName = "shimmer" | "pulse" | "sparkle" | "wheel";
279
+ /** The names of the effects that run once. */
280
+ export type TransitionName = "fade" | "dissolve";
281
+ /**
282
+ * Every effect in the catalogue by its name in `EFFECT_CURVES`, each made the
283
+ * one way: for an element, at its signed-off curve unless handed another,
284
+ * with the light and the width it was signed off with. A loop comes lit on
285
+ * the element's own colours (`onColors`); a transition comes as itself, to
286
+ * be told when it is done. [LAW:types-are-the-program] Keyed by the two
287
+ * name types, which `EFFECT_CURVES` is held to as well: a curve signed off
288
+ * does not compile until it is named a loop or a transition, and then not
289
+ * until it has an entry here making one. A picker walking a section's names
290
+ * offers an effect added here with no change of its own.
291
+ */
292
+ export declare const EFFECT_CATALOGUE: {
293
+ readonly loops: Readonly<Record<LoopName, (element: EffectTarget, curve?: Curve) => Effect>>;
294
+ readonly transitions: Readonly<Record<TransitionName, (element: EffectTarget, curve?: Curve) => Transition>>;
295
+ };
@@ -3,7 +3,8 @@
3
3
  * long as they are drawn, and fade-in and dissolve-out, which run once and
4
4
  * are done. Each is an `Effect` for `Effected` (`./effect.ts`); the defaults
5
5
  * in `EFFECT_CURVES` are the ones signed off by eye in the effects-feel demo
6
- * (`npm run effects-feel`), at 30 fps and at one frame a second.
6
+ * (`npm run effects-feel`), at 30 fps and at one frame a second, and
7
+ * `EFFECT_CATALOGUE` makes every one of them at those, by name, one way.
7
8
  *
8
9
  * Each effect is something a first-time observer already knows from life —
9
10
  * a slow glow, sunlight moving on water, the colours of daylight, fireflies, ink
@@ -41,11 +42,12 @@
41
42
  * cards runs this file's own declarations as the program a visitor edits
42
43
  * (`examples/effects-playground/programs.ts`), on the published library.
43
44
  */
44
- import { ColorRgba, ColorSpec, contrastRatio } from "../core/color.js";
45
+ import { ColorDepth, ColorRgba, contrastRatio } from "../core/color.js";
45
46
  import { EASES, Phase } from "../core/easing.js";
46
47
  import { fbm, hash, noise, smoothstep } from "../core/noise.js";
47
48
  import { Memo } from "../core/memo.js";
48
49
  import { IDENTITY, Oklch } from "../core/oklch.js";
50
+ import { Effected } from "./effect.js";
49
51
  /** The contrast no touch takes a cell below, unless it rested below it: WCAG AA for body text. */
50
52
  const LEGIBLE = 4.5;
51
53
  /**
@@ -59,11 +61,13 @@ const LEVELS = 48;
59
61
  const STRENGTHS = Array.from({ length: LEVELS }, (_, i) => (i + 1) / LEVELS);
60
62
  /** What 8-bit rounding of an OKLCH round trip can cost a contrast ratio. */
61
63
  const ROUNDING = 0.01;
64
+ const SIDES = ["fg", "bg"];
62
65
  /**
63
66
  * How much of `touch` each of `colors` (hex) can take: the largest share in
64
- * [0, 1] at which every pair in `pairs` that shows it, touched at the same
67
+ * [0, 1] at which every pair `drawn` shows it in, touched at the same
65
68
  * strength, still reads at its resting contrast or `LEGIBLE`, whichever is
66
- * lower. A colour in a pair but not in `colors` takes none, and so does one
69
+ * lower, in the colours `Effected` writes it in at `drawn`'s depth and theme.
70
+ * A colour in a pair but not in `colors` takes none, and so does one
67
71
  * the terminal supplies, whatever its hex, as in `onColors`. One of `colors`
68
72
  * no pair writes has nothing to be measured against, and takes it all.
69
73
  *
@@ -74,28 +78,38 @@ const ROUNDING = 0.01;
74
78
  * the fill room to glow — and a darker colour left as it is always holds,
75
79
  * because its lighter partner was settled against exactly that.
76
80
  */
77
- export function shares(pairs, colors, touch) {
81
+ export function shares({ pairs, depth, theme }, colors, touch) {
78
82
  // [LAW:one-source-of-truth] A slot shows a subject colour, by hex, only
79
83
  // where the element writes it: the reading `onColors` lights by.
80
- const sides = pairs.map(({ fg, bg, terminal }) => [
81
- { color: fg, own: terminal.fg ? undefined : fg.hex },
82
- { color: bg, own: terminal.bg ? undefined : bg.hex },
83
- ]);
84
- const rgba = new Map(sides.flat().flatMap(({ color, own }) => (own === undefined ? [] : [[own, color]])));
84
+ const own = (pair, side) => (pair.terminal[side] ? undefined : pair[side].hex);
85
+ const rgba = new Map(pairs.flatMap((pair) => SIDES.flatMap((side) => {
86
+ const hex = own(pair, side);
87
+ return hex === undefined ? [] : [[hex, pair[side]]];
88
+ })));
89
+ // Below truecolor every colour written is one the depth holds exactly, so only truecolor's rounding has a cost to forgive.
90
+ const slack = depth === ColorDepth.TRUECOLOR ? ROUNDING : 0;
85
91
  // A colour no pair writes keeps no word legible, so nothing bounds its share:
86
92
  // a frame drawing a lit colour only under blanks and seams, or not at all,
87
93
  // is a frame like any other.
88
94
  const settled = new Map([...colors].filter((hex) => !rgba.has(hex)).map((hex) => [hex, 1]));
89
95
  const lightness = (hex) => Oklch.fromRgba(rgba.get(hex)).l;
90
96
  for (const hex of [...colors].filter((hex) => rgba.has(hex)).sort((a, b) => lightness(b) - lightness(a))) {
91
- const partners = sides.flatMap(([fg, bg]) => {
92
- const partner = fg.own === hex ? bg : bg.own === hex ? fg : undefined;
93
- return partner === undefined ? [] : [[partner.color, partner.own === undefined ? 0 : (settled.get(partner.own) ?? 0)]];
97
+ // Each pair showing the colour, with the side it shows it on and the share its partner takes.
98
+ const facing = pairs.flatMap((pair) => {
99
+ const mine = SIDES.find((side) => own(pair, side) === hex);
100
+ if (mine === undefined)
101
+ return [];
102
+ const partner = own(pair, mine === "fg" ? "bg" : "fg");
103
+ return [[pair, mine, partner === undefined ? 0 : (settled.get(partner) ?? 0)]];
94
104
  });
95
- const color = rgba.get(hex);
96
- const holds = (share) => partners.every(([partner, theirs]) => {
97
- const floor = Math.min(contrastRatio(color, partner), LEGIBLE);
98
- return STRENGTHS.every((w) => contrastRatio(touchedAt(touch, color, share * w), touchedAt(touch, partner, theirs * w)) >= floor - ROUNDING);
105
+ // [LAW:one-source-of-truth] A pair reads in the colours `Effected` writes it in, touched at each side's share.
106
+ const holds = (share) => facing.every(([pair, mine, theirs]) => {
107
+ const floor = Math.min(contrastRatio(pair.fg, pair.bg), LEGIBLE) - slack;
108
+ const at = (side, w) => touchedAt(touch, pair[side], (side === mine ? share : theirs) * w);
109
+ return STRENGTHS.every((w) => {
110
+ const shown = Effected.shows(pair, { fg: at("fg", w), bg: at("bg", w) }, depth, theme);
111
+ return contrastRatio(shown.fg, shown.bg) >= floor;
112
+ });
99
113
  });
100
114
  let [lo, hi] = holds(1) ? [1, 1] : [0, 1];
101
115
  for (let i = 0; i < 20 && hi - lo > 1e-3; i++) {
@@ -110,9 +124,9 @@ export function shares(pairs, colors, touch) {
110
124
  * A loop as an `Effect` on the element's own colours, by hex, wherever a cell
111
125
  * shows them, each touched at its share of the cell's strength: the share
112
126
  * `shares` settles for it against the pairs the element draws its words in
113
- * (`Drawn`), touched as the frame's depth writes them — at 256 colours a
114
- * touched colour lands on the cube, and the contrast it spends is the cube
115
- * colour's. `colors` are the ones lit, by default every colour the element
127
+ * (`Drawn`), touched as `Effected` writes them at the frame's depth — below
128
+ * truecolor a touched colour lands on a colour the slot can write, and the
129
+ * contrast it spends is that colour's. `colors` are the ones lit, by default every colour the element
116
130
  * sets itself. A powerline strip's fill is the ground of its cells and the
117
131
  * ink of the seam glyphs between them, so the arrow moves with the cell it
118
132
  * points out of. A colour the terminal supplies (`EffectCell.terminal`) is
@@ -126,11 +140,10 @@ export function onColors(loop, colors) {
126
140
  const settled = new WeakMap();
127
141
  const settle = (drawn) => {
128
142
  const lit = colors ?? drawn.colors;
129
- const key = [drawn.depth, ...drawn.pairs.map(({ fg, bg, terminal }) => `${fg.hex}/${bg.hex}/${terminal.fg}/${terminal.bg}`), "|", ...lit].join(" ");
143
+ const key = [drawn.depth, ...drawn.pairs.map(({ fg, bg, terminal, reverse }) => `${fg.hex}/${bg.hex}/${terminal.fg}/${terminal.bg}/${reverse}`), "|", ...lit].join(" ");
130
144
  const memo = settled.get(drawn.theme) ?? new Memo();
131
145
  settled.set(drawn.theme, memo);
132
- const asWritten = (color, w) => ColorSpec.fromRgba(loop.touch(color, w)).downgrade(drawn.depth).getTruecolor(drawn.theme);
133
- return memo.get(key, () => shares(drawn.pairs, lit, asWritten));
146
+ return memo.get(key, () => shares(drawn, lit, loop.touch));
134
147
  };
135
148
  return atShares(loop, settle);
136
149
  }
@@ -140,14 +153,14 @@ export function onColors(loop, colors) {
140
153
  * says; a colour it gives none, and one the terminal supplies, stays as it is.
141
154
  */
142
155
  export function atShares(loop, share) {
143
- // Every cell of a frame is handed the same `Drawn`, so its shares are looked up once a frame.
144
- let frame = { drawn: undefined, share: new Map() };
145
- return ({ fg, bg }, cell, t, drawn) => {
146
- if (frame.drawn !== drawn)
147
- frame = { drawn, share: share(drawn) };
148
- const w = Math.round(clamp01(loop.field(cell, t)) * LEVELS) / LEVELS;
149
- const moved = (color, terminal) => touchedAt(loop.touch, color, (terminal ? 0 : (frame.share.get(color.hex) ?? 0)) * w);
150
- return { fg: moved(fg, cell.terminal.fg), bg: moved(bg, cell.terminal.bg) };
156
+ return (t, drawn) => {
157
+ const shared = share(drawn);
158
+ const field = loop.field(t);
159
+ return ({ fg, bg }, cell) => {
160
+ const w = Math.round(clamp01(field(cell)) * LEVELS) / LEVELS;
161
+ const moved = (color, terminal) => touchedAt(loop.touch, color, (terminal ? 0 : (shared.get(color.hex) ?? 0)) * w);
162
+ return { fg: moved(fg, cell.terminal.fg), bg: moved(bg, cell.terminal.bg) };
163
+ };
151
164
  };
152
165
  }
153
166
  /**
@@ -276,14 +289,15 @@ export function pulse(curve, glow, z) {
276
289
  const WANDER = 0.1;
277
290
  const wander = (t) => WANDER * noise(t / (3 * curve.seconds), 0.5, 0.5 + z);
278
291
  const start = wander(0);
279
- const field = (_cell, t) => {
292
+ const field = (t) => {
280
293
  const beats = (rate * t) / curve.seconds + wander(t) - start;
281
294
  const n = Math.floor(beats);
282
295
  // Each beat's own depth, 70% to the whole of the swing; it changes where
283
296
  // the beat is at rest, so nothing jumps.
284
297
  const depth = 0.7 + 0.3 * smoothstep(-0.5, 0.5, noise(n * 0.37, 2.1, z));
285
298
  const beat = 0.5 - 0.5 * Math.cos(2 * Math.PI * (beats - n));
286
- return curve.swing * curve.ease(depth * beat);
299
+ const level = curve.swing * curve.ease(depth * beat);
300
+ return () => level;
287
301
  };
288
302
  return { touch: light(glow), field };
289
303
  }
@@ -343,35 +357,31 @@ export function shimmer(curve, span, width, glow, z) {
343
357
  const centre = q * across - width + 0.06 * across * Math.sin(Math.PI * q) * noise(q, 2.7, j + z);
344
358
  return { strength, centre };
345
359
  };
346
- // The passes on the row at one moment: a frame asks for the same moment
347
- // once per cell, so they are worked out once per moment.
348
- let passing = { t: Number.NaN, passes: [] };
349
- // Where two passes overlap the brighter wins, so light never doubles up;
350
- // the first slot is 0, so before it nothing is lit.
351
- const band = (col, t) => {
352
- if (passing.t !== t) {
353
- const last = Math.floor(t / SLOT);
354
- const passes = [];
355
- for (let j = Math.max(0, Math.ceil(last - UP)); j <= last; j++)
356
- passes.push(pass(j, t));
357
- passing = { t, passes };
358
- }
359
- let lit = 0;
360
- for (const { strength, centre } of passing.passes)
361
- lit = Math.max(lit, strength * bump((col - centre) / width));
362
- return lit;
360
+ // The passes on the row at `t`; the first slot is 0, so before it there are none.
361
+ const passing = (t) => {
362
+ const last = Math.floor(t / SLOT);
363
+ const passes = [];
364
+ for (let j = Math.max(0, Math.ceil(last - UP)); j <= last; j++)
365
+ passes.push(pass(j, t));
366
+ return passes;
363
367
  };
364
368
  // A ripple's crest: a Gaussian ridge, 1 where the field crosses zero and in
365
369
  // (0, 1] for any noise, smooth throughout, so a crest sliding through a cell
366
370
  // lights it smoothly.
367
371
  const ripple = (a, b, c) => Math.exp(-((noise(a, b, c) / 0.8) ** 2));
368
- const field = (cell, t) => {
369
- const lit = band(cell.col, t);
370
- const row = cell.row + z;
371
- const current = 0.8 * noise(cell.col * 0.05, row * 0.3 + 1.3, t * 0.02);
372
- const caustic = (ripple(cell.col * 0.21 + current, row * 0.9, t * 0.022) * ripple(cell.col * 0.33 + 9.1 - current, row * 0.7, t * 0.018 + 4.2)) **
373
- 2;
374
- return curve.swing * curve.ease(lit * (0.1 + 0.6 * caustic));
372
+ const field = (t) => {
373
+ const passes = passing(t);
374
+ return (cell) => {
375
+ // Where two passes overlap the brighter wins, so light never doubles up.
376
+ let lit = 0;
377
+ for (const { strength, centre } of passes)
378
+ lit = Math.max(lit, strength * bump((cell.col - centre) / width));
379
+ const row = cell.row + z;
380
+ const current = 0.8 * noise(cell.col * 0.05, row * 0.3 + 1.3, t * 0.02);
381
+ const caustic = (ripple(cell.col * 0.21 + current, row * 0.9, t * 0.022) * ripple(cell.col * 0.33 + 9.1 - current, row * 0.7, t * 0.018 + 4.2)) **
382
+ 2;
383
+ return curve.swing * curve.ease(lit * (0.1 + 0.6 * caustic));
384
+ };
375
385
  };
376
386
  return { touch: light(glow), field };
377
387
  }
@@ -403,67 +413,64 @@ export function sparkle(curve, span, glow, z) {
403
413
  // Out of step with every other firefly from the first moment.
404
414
  offset: hash(i, 0.7 + z) * TURN,
405
415
  }));
406
- // Where every firefly is and how bright at one moment: a frame asks for
407
- // the same moment once per cell, so it is worked out once per moment.
408
- let flown = { t: Number.NaN, flies: [] };
409
- const fliesAt = (t) => {
410
- if (flown.t !== t) {
411
- flown = {
412
- t,
413
- flies: homes.map((fly) => {
414
- // Flash `n` is one event: whether it comes, how bright, when in
415
- // its turn and where are its own. Between flashes the firefly
416
- // is dark, so where it moves to is never seen moving.
417
- const u = t + fly.offset;
418
- const n = Math.floor(u / TURN);
419
- // A dark turn is a flash with no light: it keeps its length, so
420
- // every flash is timed alike.
421
- const lit = hash(n, fly.z + 1) < 0.15 ? 0 : 1;
422
- const k = 0.5 + 0.5 * hash(n, fly.z + 2);
423
- const at = hash(n, fly.z + 3) * (1 - lasts(FLASH, k));
424
- const s = u / TURN - n - at;
425
- // While it glows it drifts a few cells one way or the other, as
426
- // fast as it is bright: quickest at its height, slowing as it
427
- // fades — the lit stroke a firefly draws on the dusk. A brighter
428
- // flash lasts longer and draws a longer stroke at the same pace.
429
- return {
430
- x: fly.home +
431
- HOP * (2 * hash(n, fly.z + 4) - 1) +
432
- GLIDE * k * (2 * hash(n, fly.z + 5) - 1) * given(FLASH, s, k) +
433
- 1.5 * noise(fly.z, 0.1, t * 0.035),
434
- y: 0.6 * noise(fly.z, 2.2, t * 0.035),
435
- glow: lit * swell(FLASH, s, k),
436
- };
437
- }),
438
- };
439
- }
440
- return flown.flies;
441
- };
442
- const field = (cell, t) => {
443
- // Where two halos overlap the brighter wins, so light never doubles up.
444
- let shine = 0;
445
- for (const fly of fliesAt(t))
446
- shine = Math.max(shine, fly.glow * bump(Math.hypot(cell.col - fly.x, 2 * (cell.row - fly.y)) / HALO));
447
- return curve.swing * curve.ease(shine);
416
+ // Where every firefly is at `t`, and how bright.
417
+ const fliesAt = (t) => homes.map((fly) => {
418
+ // Flash `n` is one event: whether it comes, how bright, when in
419
+ // its turn and where are its own. Between flashes the firefly
420
+ // is dark, so where it moves to is never seen moving.
421
+ const u = t + fly.offset;
422
+ const n = Math.floor(u / TURN);
423
+ // A dark turn is a flash with no light: it keeps its length, so
424
+ // every flash is timed alike.
425
+ const lit = hash(n, fly.z + 1) < 0.15 ? 0 : 1;
426
+ const k = 0.5 + 0.5 * hash(n, fly.z + 2);
427
+ const at = hash(n, fly.z + 3) * (1 - lasts(FLASH, k));
428
+ const s = u / TURN - n - at;
429
+ // While it glows it drifts a few cells one way or the other, as
430
+ // fast as it is bright: quickest at its height, slowing as it
431
+ // fades — the lit stroke a firefly draws on the dusk. A brighter
432
+ // flash lasts longer and draws a longer stroke at the same pace.
433
+ return {
434
+ x: fly.home +
435
+ HOP * (2 * hash(n, fly.z + 4) - 1) +
436
+ GLIDE * k * (2 * hash(n, fly.z + 5) - 1) * given(FLASH, s, k) +
437
+ 1.5 * noise(fly.z, 0.1, t * 0.035),
438
+ y: 0.6 * noise(fly.z, 2.2, t * 0.035),
439
+ glow: lit * swell(FLASH, s, k),
440
+ };
441
+ });
442
+ const field = (t) => {
443
+ const flies = fliesAt(t);
444
+ return (cell) => {
445
+ // Where two halos overlap the brighter wins, so light never doubles up.
446
+ let shine = 0;
447
+ for (const fly of flies)
448
+ shine = Math.max(shine, fly.glow * bump(Math.hypot(cell.col - fly.x, 2 * (cell.row - fly.y)) / HALO));
449
+ return curve.swing * curve.ease(shine);
450
+ };
448
451
  };
449
452
  return { touch: light(glow), field };
450
453
  }
451
454
  /**
452
- * A cell mixed toward the terminal's `ground` by how hidden it is: its fill
453
- * toward the ground, and its ink toward that fill, so an element with fills
454
- * of its own — a powerline strip — arrives and leaves whole, fill and words
455
- * together, never as bare coloured blocks. Invisible is ink and fill equal
456
- * to the ground. `swing` scales the hiding — 1 reaches fully invisible. A
457
- * cell wholly shown is returned as it was.
455
+ * A cell mixed toward the ground of the terminal the frame is drawn on
456
+ * (`Drawn.theme`) by how hidden it is: its fill toward the ground, and its ink
457
+ * toward that fill, so an element with fills of its own — a powerline strip —
458
+ * arrives and leaves whole, fill and words together, never as bare coloured
459
+ * blocks. Invisible is ink and fill equal to the ground. `swing` scales the
460
+ * hiding — 1 reaches fully invisible. A cell wholly shown is returned as it was.
458
461
  */
459
- export function veiled(visibility, swing, ground) {
460
- const to = Oklch.fromRgba(ground);
461
- return (colors, cell, t) => {
462
- const hidden = swing * (1 - clamp01(visibility(cell, t)));
463
- if (hidden === 0)
464
- return colors;
465
- const bg = blend(Oklch.fromRgba(colors.bg), to, hidden);
466
- return { fg: blend(Oklch.fromRgba(colors.fg), bg, hidden).toRgba(), bg: bg.toRgba() };
462
+ export function veiled(visibility, swing) {
463
+ return (t, { theme }) => {
464
+ const shows = visibility(t);
465
+ // [LAW:one-source-of-truth] The ground is the terminal `Effected` draws on, never a second one handed here.
466
+ const ground = Oklch.fromRgba(theme.backgroundColor);
467
+ return (colors, cell) => {
468
+ const hidden = swing * (1 - clamp01(shows(cell)));
469
+ if (hidden === 0)
470
+ return colors;
471
+ const bg = blend(Oklch.fromRgba(colors.bg), ground, hidden);
472
+ return { fg: blend(Oklch.fromRgba(colors.fg), bg, hidden).toRgba(), bg: bg.toRgba() };
473
+ };
467
474
  };
468
475
  }
469
476
  /**
@@ -484,9 +491,8 @@ function order(z) {
484
491
  return place;
485
492
  };
486
493
  }
487
- function transition(curve, start, effect) {
488
- const end = start + curve.seconds;
489
- return { effect, done: (t) => t >= end };
494
+ function transition(curve, effect) {
495
+ return { effect, done: (t) => t >= curve.seconds };
490
496
  }
491
497
  /**
492
498
  * Of a transition's `seconds`, how long each cell's own change takes. Under
@@ -494,26 +500,28 @@ function transition(curve, start, effect) {
494
500
  */
495
501
  const OWN = 0.45;
496
502
  /**
497
- * Each cell's progress through its own change in a transition starting at
498
- * `start`, in [0, 1]: a cell `place` along the order starts that share of the
499
- * way through the time the others leave it, and takes `OWN` of `seconds`.
503
+ * Each cell's progress through its own change in a transition, in [0, 1]: a
504
+ * cell `place` along the order starts that share of the way through the time
505
+ * the others leave it, and takes `OWN` of `seconds`.
500
506
  */
501
- function ownChange(curve, start, place) {
507
+ function ownChange(curve, place) {
502
508
  const own = curve.seconds * OWN;
503
509
  // One phase, shifted per cell, so no cell builds its own each frame.
504
- const change = Phase.once(start, own);
505
- return (cell, t) => change(t - place(cell) * (curve.seconds - own));
510
+ const change = Phase.once(0, own);
511
+ return (t) => (cell) => change(t - place(cell) * (curve.seconds - own));
506
512
  }
507
513
  /**
508
- * Fade-in starting at `start`, as ink blooming in water: patches of the
509
- * element surface first and the rest follows outward from them, each cell
510
- * rising smoothly over its own part of the duration. Whole at
511
- * `start + seconds`.
514
+ * Fade-in, as ink blooming in water: patches of the element surface first
515
+ * and the rest follows outward from them, each cell rising smoothly over its
516
+ * own part of the duration. Whole at `seconds`.
512
517
  */
513
- export function fadeIn(curve, start, z, ground) {
518
+ export function fadeIn(curve, z) {
514
519
  requireCurve(curve);
515
- const risen = ownChange(curve, start, order(1.7 + z));
516
- return transition(curve, start, veiled((cell, t) => curve.ease(risen(cell, t)), curve.swing, ground));
520
+ const risen = ownChange(curve, order(1.7 + z));
521
+ return transition(curve, veiled((t) => {
522
+ const at = risen(t);
523
+ return (cell) => curve.ease(at(cell));
524
+ }, curve.swing));
517
525
  }
518
526
  /**
519
527
  * The rebound of a dissolve, every constant that makes it feel as it does —
@@ -526,25 +534,28 @@ export function fadeIn(curve, start, z, ground) {
526
534
  */
527
535
  const DISSOLVE_SHAPE = { depth: 0.4, from: 0.5, to: 0.95, maskFrom: 0.35, maskTo: 0.75, late: 0.6 };
528
536
  /**
529
- * Dissolve-out starting at `start`, as mist lifting: the element thins in
530
- * drifting patches, each cell fading smoothly over its own part of the
531
- * duration, until nothing is left at `start + seconds`. Mist does not lift
537
+ * Dissolve-out, as mist lifting: the element thins in drifting patches, each
538
+ * cell fading smoothly over its own part of the duration, until nothing is
539
+ * left at `seconds`. Mist does not lift
532
540
  * in one breath: towards the end, patches of what is left — the later a cell
533
541
  * goes, the likelier — rise back part way, as the last of it eddies, before
534
542
  * they thin away for good.
535
543
  */
536
- export function dissolveOut(curve, start, z, ground) {
544
+ export function dissolveOut(curve, z) {
537
545
  requireCurve(curve);
538
546
  const place = order(4.2 + z);
539
547
  const eddy = order(9.3 + z);
540
- const faded = ownChange(curve, start, place);
548
+ const faded = ownChange(curve, place);
541
549
  const { depth, from, to, maskFrom, maskTo, late } = DISSOLVE_SHAPE;
542
- return transition(curve, start, veiled((cell, t) => {
543
- const gone = curve.ease(faded(cell, t));
544
- const q = clamp01((gone - from) / (to - from));
545
- const back = depth * smoothstep(maskFrom, maskTo, eddy(cell)) * (1 - late + late * place(cell)) * Math.sin(Math.PI * q) ** 2;
546
- return 1 - gone + back;
547
- }, curve.swing, ground));
550
+ return transition(curve, veiled((t) => {
551
+ const at = faded(t);
552
+ return (cell) => {
553
+ const gone = curve.ease(at(cell));
554
+ const q = clamp01((gone - from) / (to - from));
555
+ const back = depth * smoothstep(maskFrom, maskTo, eddy(cell)) * (1 - late + late * place(cell)) * Math.sin(Math.PI * q) ** 2;
556
+ return 1 - gone + back;
557
+ };
558
+ }, curve.swing));
548
559
  }
549
560
  /**
550
561
  * The periods, in seconds, of the two noises a segment's stray from the
@@ -586,7 +597,7 @@ export function wheel(curve, z) {
586
597
  const s = seed(hex);
587
598
  return (360 * t) / P + curve.swing * STRAY * ((stray(s, t) - stray(s, 0)) / 1.5);
588
599
  };
589
- return (shown, cell, t, { colors, fills }) => {
600
+ return (t, { colors, fills }) => (shown, cell) => {
590
601
  // Whether a slot shows one of the element's colours: never the terminal's, whatever its hex.
591
602
  const own = { fg: !cell.terminal.fg && colors.has(shown.fg.hex), bg: !cell.terminal.bg && colors.has(shown.bg.hex) };
592
603
  // An own colour turns with its segment: itself if a fill or beside no own colour, else the one beside it.
@@ -594,3 +605,26 @@ export function wheel(curve, z) {
594
605
  return { fg: turn(shown.fg, own.fg, shown.bg, own.bg), bg: turn(shown.bg, own.bg, shown.fg, own.fg) };
595
606
  };
596
607
  }
608
+ /**
609
+ * Every effect in the catalogue by its name in `EFFECT_CURVES`, each made the
610
+ * one way: for an element, at its signed-off curve unless handed another,
611
+ * with the light and the width it was signed off with. A loop comes lit on
612
+ * the element's own colours (`onColors`); a transition comes as itself, to
613
+ * be told when it is done. [LAW:types-are-the-program] Keyed by the two
614
+ * name types, which `EFFECT_CURVES` is held to as well: a curve signed off
615
+ * does not compile until it is named a loop or a transition, and then not
616
+ * until it has an entry here making one. A picker walking a section's names
617
+ * offers an effect added here with no change of its own.
618
+ */
619
+ export const EFFECT_CATALOGUE = {
620
+ loops: {
621
+ shimmer: (element, curve = EFFECT_CURVES.shimmer) => onColors(shimmer(curve, element.span, SHIMMER_WIDTH, EFFECT_LIGHTS.sun, element.z)),
622
+ pulse: (element, curve = EFFECT_CURVES.pulse) => onColors(pulse(curve, EFFECT_LIGHTS.sun, element.z)),
623
+ sparkle: (element, curve = EFFECT_CURVES.sparkle) => onColors(sparkle(curve, element.span, EFFECT_LIGHTS.firefly, element.z)),
624
+ wheel: (element, curve = EFFECT_CURVES.wheel) => wheel(curve, element.z),
625
+ },
626
+ transitions: {
627
+ fade: (element, curve = EFFECT_CURVES.fade) => fadeIn(curve, element.z),
628
+ dissolve: (element, curve = EFFECT_CURVES.dissolve) => dissolveOut(curve, element.z),
629
+ },
630
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/rich-js",
3
- "version": "0.21.0",
3
+ "version": "0.23.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",