@promptctl/rich-js 0.19.0 → 0.21.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 (58) hide show
  1. package/README.md +3 -2
  2. package/dist/core/anchor.d.ts +5 -0
  3. package/dist/core/anchor.js +10 -0
  4. package/dist/core/color.d.ts +1 -0
  5. package/dist/core/color.js +14 -3
  6. package/dist/core/noise.d.ts +31 -0
  7. package/dist/core/noise.js +94 -0
  8. package/dist/core/paint.js +13 -2
  9. package/dist/core/pretty.d.ts +23 -2
  10. package/dist/core/pretty.js +54 -18
  11. package/dist/core/protocol.d.ts +2 -0
  12. package/dist/core/protocol.js +3 -2
  13. package/dist/core/selection.d.ts +12 -0
  14. package/dist/core/selection.js +14 -0
  15. package/dist/core/strip.d.ts +159 -76
  16. package/dist/core/strip.js +367 -215
  17. package/dist/core/style.js +10 -7
  18. package/dist/host/app.d.ts +68 -2
  19. package/dist/host/app.js +125 -15
  20. package/dist/host/index.d.ts +3 -1
  21. package/dist/host/index.js +1 -0
  22. package/dist/host/input.d.ts +87 -0
  23. package/dist/host/input.js +517 -0
  24. package/dist/host/terminal-host.d.ts +12 -0
  25. package/dist/host/terminal-host.js +2 -0
  26. package/dist/index.d.ts +11 -6
  27. package/dist/index.js +7 -4
  28. package/dist/node/terminal-host.d.ts +7 -0
  29. package/dist/node/terminal-host.js +7 -0
  30. package/dist/renderables/effect.d.ts +89 -16
  31. package/dist/renderables/effect.js +115 -42
  32. package/dist/renderables/effects.d.ts +260 -0
  33. package/dist/renderables/effects.js +596 -0
  34. package/dist/renderables/embed.d.ts +11 -5
  35. package/dist/renderables/embed.js +13 -6
  36. package/dist/renderables/flexStrip.d.ts +1 -1
  37. package/dist/renderables/flexStrip.js +10 -9
  38. package/dist/renderables/list-view.d.ts +42 -0
  39. package/dist/renderables/list-view.js +78 -0
  40. package/dist/renderables/progress.d.ts +6 -0
  41. package/dist/renderables/progress.js +11 -4
  42. package/dist/renderables/progressBar.d.ts +22 -2
  43. package/dist/renderables/progressBar.js +52 -7
  44. package/dist/renderables/rule.d.ts +2 -2
  45. package/dist/renderables/rule.js +12 -10
  46. package/dist/renderables/table.js +5 -7
  47. package/dist/renderables/viewport.d.ts +30 -0
  48. package/dist/renderables/viewport.js +59 -3
  49. package/dist/widgets/dropdown.js +3 -3
  50. package/dist/widgets/event-router.d.ts +31 -40
  51. package/dist/widgets/event-router.js +52 -483
  52. package/dist/widgets/hit.js +2 -4
  53. package/dist/widgets/index.d.ts +2 -1
  54. package/dist/widgets/types.d.ts +8 -25
  55. package/dist/widgets/types.js +8 -0
  56. package/dist/widgets/widget-app.d.ts +22 -7
  57. package/dist/widgets/widget-app.js +22 -12
  58. package/package.json +26 -18
@@ -0,0 +1,596 @@
1
+ /**
2
+ * The effect catalogue: shimmer, pulse, sparkle and wheel, which loop for as
3
+ * long as they are drawn, and fade-in and dissolve-out, which run once and
4
+ * are done. Each is an `Effect` for `Effected` (`./effect.ts`); the defaults
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.
7
+ *
8
+ * Each effect is something a first-time observer already knows from life —
9
+ * a slow glow, sunlight moving on water, the colours of daylight, fireflies, ink
10
+ * blooming in water, mist lifting — because a motion the eye recognises
11
+ * reads as calm and alive, where a motion it has to learn reads as a widget.
12
+ * What makes those motions familiar is what every curve here keeps:
13
+ *
14
+ * - Nothing jumps. Every curve is continuous in time, built from smooth
15
+ * noise (`core/noise.ts`) and smooth waveforms, so at one frame a second a cell
16
+ * moves a little and at thirty it glides.
17
+ * - Nothing repeats exactly. Rhythms are perturbed by slow noise, and each
18
+ * breath, firefly's flash and pass of light is an event of its own, with
19
+ * its own strength and moment, the way no breath or gust is the last one
20
+ * again. A weaker event is a shorter one, so its strength costs nothing
21
+ * in smoothness.
22
+ * - Neighbours move together. Variation is spatially coherent — patches,
23
+ * filaments, fronts — never per-cell static.
24
+ * - Light only ever lights, and never takes the words away. Ink and the
25
+ * fill under it both move toward the light and never darken, each by its
26
+ * own share: the share a colour can take while every cell it is drawn in
27
+ * still reads at its resting contrast or WCAG AA, whichever is lower. A
28
+ * cell with contrast to spare glows brightly; one with none barely moves.
29
+ * - Elements are apart. Each sits at its own `z` in the noise, so two
30
+ * elements under one effect do not move in lockstep.
31
+ *
32
+ * Colour moves are in OKLCH, where equal steps look equal. A curve's `ease`
33
+ * maps its intensity in [0, 1] — how strongly the effect acts on a cell at a
34
+ * moment — onto its swing, for the loops and the transitions alike.
35
+ *
36
+ * [LAW:no-ambient-temporal-coupling] No curve reads a clock; `t` is the
37
+ * effect's argument, so the frame owner decides when to sample.
38
+ *
39
+ * Every import below names something the package exports, but for `noise`
40
+ * and `Memo`, which the docs' effects playground page supplies itself: each of its
41
+ * cards runs this file's own declarations as the program a visitor edits
42
+ * (`examples/effects-playground/programs.ts`), on the published library.
43
+ */
44
+ import { ColorRgba, ColorSpec, contrastRatio } from "../core/color.js";
45
+ import { EASES, Phase } from "../core/easing.js";
46
+ import { fbm, hash, noise, smoothstep } from "../core/noise.js";
47
+ import { Memo } from "../core/memo.js";
48
+ import { IDENTITY, Oklch } from "../core/oklch.js";
49
+ /** The contrast no touch takes a cell below, unless it rested below it: WCAG AA for body text. */
50
+ const LEGIBLE = 4.5;
51
+ /**
52
+ * The strengths a cell is drawn at: a field is snapped to one of these, and
53
+ * a share is checked at every one, so every strength drawn was checked. A
54
+ * 256-colour cube colour changes in jumps between strengths, and no check of
55
+ * a continuous strength between two of them could see every jump. A step is
56
+ * a 48th of a swing, well under a just-noticeable difference.
57
+ */
58
+ const LEVELS = 48;
59
+ const STRENGTHS = Array.from({ length: LEVELS }, (_, i) => (i + 1) / LEVELS);
60
+ /** What 8-bit rounding of an OKLCH round trip can cost a contrast ratio. */
61
+ const ROUNDING = 0.01;
62
+ /**
63
+ * 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
65
+ * 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
67
+ * the terminal supplies, whatever its hex, as in `onColors`. One of `colors`
68
+ * no pair writes has nothing to be measured against, and takes it all.
69
+ *
70
+ * Colours are settled lightest first, each against every partner: one
71
+ * already settled at its share, one not yet settled as it is. So the lighter
72
+ * colour of a cell takes the light first and its darker partner spends
73
+ * whatever contrast is left — pale lettering lifting off a dark fill gives
74
+ * the fill room to glow — and a darker colour left as it is always holds,
75
+ * because its lighter partner was settled against exactly that.
76
+ */
77
+ export function shares(pairs, colors, touch) {
78
+ // [LAW:one-source-of-truth] A slot shows a subject colour, by hex, only
79
+ // 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]])));
85
+ // A colour no pair writes keeps no word legible, so nothing bounds its share:
86
+ // a frame drawing a lit colour only under blanks and seams, or not at all,
87
+ // is a frame like any other.
88
+ const settled = new Map([...colors].filter((hex) => !rgba.has(hex)).map((hex) => [hex, 1]));
89
+ const lightness = (hex) => Oklch.fromRgba(rgba.get(hex)).l;
90
+ 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)]];
94
+ });
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);
99
+ });
100
+ let [lo, hi] = holds(1) ? [1, 1] : [0, 1];
101
+ for (let i = 0; i < 20 && hi - lo > 1e-3; i++) {
102
+ const mid = (lo + hi) / 2;
103
+ [lo, hi] = holds(mid) ? [mid, hi] : [lo, mid];
104
+ }
105
+ settled.set(hex, lo);
106
+ }
107
+ return settled;
108
+ }
109
+ /**
110
+ * A loop as an `Effect` on the element's own colours, by hex, wherever a cell
111
+ * shows them, each touched at its share of the cell's strength: the share
112
+ * `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
116
+ * sets itself. A powerline strip's fill is the ground of its cells and the
117
+ * ink of the seam glyphs between them, so the arrow moves with the cell it
118
+ * points out of. A colour the terminal supplies (`EffectCell.terminal`) is
119
+ * not the element's, whatever its hex, and takes no share.
120
+ *
121
+ * Settling shares is costly, so it is done once for each set of colours an
122
+ * element draws: make the effect once and draw it in every frame, and a frame
123
+ * drawing what an earlier one drew costs no settling.
124
+ */
125
+ export function onColors(loop, colors) {
126
+ const settled = new WeakMap();
127
+ const settle = (drawn) => {
128
+ 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(" ");
130
+ const memo = settled.get(drawn.theme) ?? new Memo();
131
+ 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));
134
+ };
135
+ return atShares(loop, settle);
136
+ }
137
+ /**
138
+ * A loop as an `Effect` on colours by hex, each touched at the share
139
+ * `share` gives it of the cell's strength in a frame drawing what `Drawn`
140
+ * says; a colour it gives none, and one the terminal supplies, stays as it is.
141
+ */
142
+ 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) };
151
+ };
152
+ }
153
+ /**
154
+ * `color` under `touch` at strength `w`, and at strength 0 the colour itself,
155
+ * exactly: an OKLCH round trip can land a step off, and at 16 colours a step
156
+ * off is another slot.
157
+ */
158
+ function touchedAt(touch, color, w) {
159
+ return w === 0 ? color : touch(color, w);
160
+ }
161
+ /** Throws, naming the field, unless `curve` has a positive, finite `seconds` and a `swing` in [0, 1]. */
162
+ function requireCurve(curve) {
163
+ if (!(Number.isFinite(curve.seconds) && curve.seconds > 0))
164
+ throw new RangeError(`curve: seconds must be a positive number of seconds, got ${curve.seconds}`);
165
+ if (!(curve.swing >= 0 && curve.swing <= 1))
166
+ throw new RangeError(`curve: swing must be in [0, 1], got ${curve.swing}`);
167
+ }
168
+ /**
169
+ * Each effect's curve as it was signed off. Every loop's period is a prime
170
+ * number of seconds, so no two loops come back into step within a sitting.
171
+ * The periods are long on purpose: at one frame a second a loop's colour
172
+ * moves by no more than a just-noticeable step from one frame to the next.
173
+ */
174
+ export const EFFECT_CURVES = {
175
+ shimmer: { seconds: 109, ease: EASES.linear, swing: 0.9 },
176
+ pulse: { seconds: 43, ease: EASES.linear, swing: 0.85 },
177
+ sparkle: { seconds: 139, ease: EASES.linear, swing: 0.95 },
178
+ wheel: { seconds: 907, ease: EASES.linear, swing: 1 },
179
+ fade: { seconds: 20, ease: EASES["ease-in-out"], swing: 1 },
180
+ dissolve: { seconds: 30, ease: EASES["ease-in-out"], swing: 1 },
181
+ };
182
+ /** The lights the loops were signed off casting, warm as sunlight and fireflies are. */
183
+ export const EFFECT_LIGHTS = {
184
+ sun: new ColorRgba(255, 228, 176),
185
+ firefly: new ColorRgba(222, 245, 140),
186
+ };
187
+ /** How far a shimmer's light reaches either side of its centre, in columns, as signed off. */
188
+ export const SHIMMER_WIDTH = 24;
189
+ const clamp01 = (x) => Math.min(1, Math.max(0, x));
190
+ /**
191
+ * `w` of the way from `from` to `to` along a straight line in OKLab. Light
192
+ * falling on a colour, or a colour thinning into its ground, moves straight
193
+ * toward the other colour; `Oklch.mix` goes round the hue circle instead, so
194
+ * blue lit by gold would pass through cyan on the way.
195
+ */
196
+ function blend(from, to, w) {
197
+ if (!(w >= 0 && w <= 1))
198
+ throw new RangeError(`blend: w must be in [0, 1]; got ${w}`);
199
+ const rad = Math.PI / 180;
200
+ const a = (1 - w) * from.c * Math.cos(from.h * rad) + w * to.c * Math.cos(to.h * rad);
201
+ const b = (1 - w) * from.c * Math.sin(from.h * rad) + w * to.c * Math.sin(to.h * rad);
202
+ return new Oklch((1 - w) * from.l + w * to.l, Math.hypot(a, b), Math.atan2(b, a) / rad, from.alpha);
203
+ }
204
+ /**
205
+ * A colour under `glow`: the one way every light here falls. It moves `w` of
206
+ * the way toward the light's colour, and never below its own lightness —
207
+ * light can warm a colour and lift it, never darken it. A colour already
208
+ * lighter than the light only warms, and white, which has no warmer shade at
209
+ * its lightness, stays white.
210
+ */
211
+ export function light(glow) {
212
+ const to = Oklch.fromRgba(glow);
213
+ return (color, w) => {
214
+ const from = Oklch.fromRgba(color);
215
+ return blend(from, new Oklch(Math.max(from.l, to.l), to.c, to.h, to.alpha), w).toRgba();
216
+ };
217
+ }
218
+ /** How long a swell `k` strong lasts, in turns. */
219
+ const lasts = (shape, k) => k * shape[shape.length - 1][0];
220
+ /**
221
+ * A swell — a breath, a firefly's glow — `k` strong, in [0, 1], `s` after it
222
+ * began: a raised cosine from each knot to the next, 0 before and after, zero
223
+ * slope at every knot. A weaker swell is a shorter one in proportion, so it
224
+ * climbs no more steeply than the strongest: however strong, its steepest
225
+ * moment is the same, and only the strongest need be checked against the
226
+ * step a frame may take. A swell of strength 0 is no swell at all: 0
227
+ * throughout, its own start included.
228
+ */
229
+ function swell(shape, s, k) {
230
+ const p = s / k;
231
+ const to = shape.findIndex(([turn]) => p < turn);
232
+ if (!(p > 0) || to < 0)
233
+ return 0;
234
+ const [t0, l0] = to === 0 ? [0, 0] : shape[to - 1];
235
+ const [t1, l1] = shape[to];
236
+ return k * (l0 + ((l1 - l0) * (1 - Math.cos((Math.PI * (p - t0)) / (t1 - t0)))) / 2);
237
+ }
238
+ /**
239
+ * How much of a swell's light has been given by `s`, in [0, 1]: `swell`'s own
240
+ * running integral over its whole, by the midpoint rule, so the two cannot
241
+ * disagree. Paced by it, a motion is quickest at the swell's height and still
242
+ * at both its ends. `k` is above 0.
243
+ */
244
+ function given(shape, s, k) {
245
+ const whole = lasts(shape, k);
246
+ const area = (to) => {
247
+ let sum = 0;
248
+ for (let i = 0; i < 24; i++)
249
+ sum += swell(shape, ((i + 0.5) / 24) * to, k);
250
+ return sum * to;
251
+ };
252
+ return area(Math.min(Math.max(s, 0), whole)) / area(whole);
253
+ }
254
+ /** A firefly's brightest flash, in turns: it kindles quicker than it fades, and the rest of its turn is dark. */
255
+ const FLASH = [[0.24, 1], [0.6, 0]];
256
+ /**
257
+ * Pulse, as a gentle glow on one element of a screen: the colours handed to
258
+ * it, an element's own, warm into `light` and settle back, over and over, the
259
+ * way an indicator breathes while something is waiting. Swing: how far into
260
+ * the light it goes at the top of a beat, 0–1. The element glows as one —
261
+ * every cell it is drawn in at once — and the colours not handed to it, the
262
+ * rest of the screen, stay as they are, so what pulses is what was chosen.
263
+ * Each element keeps a time of its own, a little quicker or slower than
264
+ * another's by `z`, and no beat is quite the last: the rhythm wanders ahead
265
+ * and behind its metronome and each beat goes a little deeper or shallower
266
+ * than the one before. A beat starts and ends in rest, so `t = 0` draws the
267
+ * element untouched.
268
+ */
269
+ export function pulse(curve, glow, z) {
270
+ requireCurve(curve);
271
+ // How much quicker or slower than `curve.seconds` this element beats.
272
+ const rate = 0.9 + 0.2 * hash(z, 3.1);
273
+ // How far, in beats, the rhythm wanders ahead and behind its metronome. Noise's
274
+ // slope stays within ±2.3 over its 3-beat length, so the wander never turns
275
+ // the beat back.
276
+ const WANDER = 0.1;
277
+ const wander = (t) => WANDER * noise(t / (3 * curve.seconds), 0.5, 0.5 + z);
278
+ const start = wander(0);
279
+ const field = (_cell, t) => {
280
+ const beats = (rate * t) / curve.seconds + wander(t) - start;
281
+ const n = Math.floor(beats);
282
+ // Each beat's own depth, 70% to the whole of the swing; it changes where
283
+ // the beat is at rest, so nothing jumps.
284
+ const depth = 0.7 + 0.3 * smoothstep(-0.5, 0.5, noise(n * 0.37, 2.1, z));
285
+ const beat = 0.5 - 0.5 * Math.cos(2 * Math.PI * (beats - n));
286
+ return curve.swing * curve.ease(depth * beat);
287
+ };
288
+ return { touch: light(glow), field };
289
+ }
290
+ /**
291
+ * How many columns a passing light crosses in its period: its pace.
292
+ * Sunlight moves at a speed, not in a time set by how long the row is,
293
+ * so a wider element is crossed for longer, never faster, and no cell's colour
294
+ * changes quicker on it from one frame to the next.
295
+ */
296
+ const STRIDE = 80;
297
+ /** A soft, compact bump: 1 at `d = 0`, 0 from `|d| ≥ 1`, smooth throughout. */
298
+ const bump = (d) => (Math.abs(d) >= 1 ? 0 : (1 - d * d) ** 3);
299
+ /**
300
+ * Shimmer, as sunlight moving across water. Swing: how far into `light` the
301
+ * brightest glint goes, 0–1. A soft band of light, `width` columns either
302
+ * side of its centre, crosses columns 0 to `span`, entering and leaving
303
+ * fully off the row, `STRIDE` columns in about three quarters of a period. Its passes
304
+ * come the way light comes and goes on water as clouds move: while the sun
305
+ * is out they are bright and follow close on one another, one sometimes
306
+ * catching the last; under cloud they thin to faint ones with the row still
307
+ * for a while between, and the light is never gone for long. Each crosses at its own unhurried pace, drifting a little ahead
308
+ * and behind as it goes. Inside a band the light is a faint glow broken
309
+ * into a glitter path — bright filaments where two ripples cross — carried
310
+ * on a slow current and re-forming as they go, so the glints dance while
311
+ * the band glides. Before the first pass enters, the row is untouched.
312
+ */
313
+ export function shimmer(curve, span, width, glow, z) {
314
+ requireCurve(curve);
315
+ const P = curve.seconds;
316
+ const across = span + 2 * width;
317
+ // Passes are events on slots of half a period. Whether slot `j` holds one,
318
+ // and how bright, is the sky's: slow noise over the slots, so sunny and
319
+ // cloudy spells each last a few periods, and a pass is likelier and
320
+ // brighter the clearer the sky.
321
+ const SLOT = P / 2;
322
+ const sky = (j) => smoothstep(-0.4, 0.4, noise(j / 7, 3.3, 5.5 + z));
323
+ // Pass `j` at `t`: how bright its band is and where its centre stands, off
324
+ // the row before it sets off and once it is done. It sets off somewhere in
325
+ // the first `SETS_OFF` of its slot and carries its centre `STRIDE` columns
326
+ // in `LEAST` to `LEAST + SPREAD` periods, give or take its drift, and every
327
+ // one is off the row `UP` slots after its own began.
328
+ const SETS_OFF = 0.7;
329
+ const LEAST = 0.51;
330
+ const SPREAD = 0.4;
331
+ const UP = SETS_OFF + ((LEAST + SPREAD) * P * across) / STRIDE / SLOT;
332
+ const pass = (j, t) => {
333
+ const clear = sky(j);
334
+ // Even under cloud about one slot in three holds a pass, a faint one, so a
335
+ // still spell lasts a period or so, the way a cloud's shadow does, and
336
+ // never long enough to read as the light gone out.
337
+ const present = hash(j, 7.5 + z) < 0.35 + 0.6 * clear ? 1 : 0;
338
+ const strength = present * (0.3 + 0.7 * clear * (0.6 + 0.4 * hash(j, 8.5 + z)));
339
+ const crossing = ((P * across) / STRIDE) * (LEAST + SPREAD * hash(j, 5.5 + z));
340
+ const q = clamp01((t - (j + SETS_OFF * hash(j, 6.5 + z)) * SLOT) / crossing);
341
+ // It drifts ahead and behind on slow noise, by nothing at either edge of
342
+ // the row and never enough to turn it back.
343
+ const centre = q * across - width + 0.06 * across * Math.sin(Math.PI * q) * noise(q, 2.7, j + z);
344
+ return { strength, centre };
345
+ };
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;
363
+ };
364
+ // A ripple's crest: a Gaussian ridge, 1 where the field crosses zero and in
365
+ // (0, 1] for any noise, smooth throughout, so a crest sliding through a cell
366
+ // lights it smoothly.
367
+ 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));
375
+ };
376
+ return { touch: light(glow), field };
377
+ }
378
+ /**
379
+ * Sparkle, as fireflies. Swing: how far into the firefly's `light` a glow
380
+ * goes at its brightest, 0–1. About one firefly to every eight columns of
381
+ * `span`, each keeping near a home of its own. A firefly flashes — kindles,
382
+ * drifts a few cells glowing, slowing as it fades — then flies on in the
383
+ * dark and flashes again somewhere a few cells off, the way fireflies at
384
+ * dusk are seen: a short lit stroke, then gone, each time a little
385
+ * elsewhere. Each keeps its own irregular time, now and then letting a turn
386
+ * pass dark, so most moments a few are lit, at different brightnesses. Its
387
+ * light is a soft halo several cells wide, brightest where it is, so as it
388
+ * drifts the glow slides between cells rather than hopping. `seconds` is a firefly's
389
+ * turn: its brightest flash, rise and fall, takes `FLASH` of it, and a
390
+ * dimmer one less.
391
+ */
392
+ export function sparkle(curve, span, glow, z) {
393
+ requireCurve(curve);
394
+ const count = Math.max(3, Math.round(span / 8));
395
+ const HALO = 5;
396
+ // How far from home a flash may begin, and how far the brightest drifts while lit.
397
+ const HOP = 5;
398
+ const GLIDE = 5;
399
+ const TURN = curve.seconds;
400
+ const homes = Array.from({ length: count }, (_, i) => ({
401
+ home: (i + 0.5 + 0.7 * noise(i * 1.7 + 0.2, 0.3, 0.5 + z)) * (span / count),
402
+ z: i * 4.9 + 0.3 + z,
403
+ // Out of step with every other firefly from the first moment.
404
+ offset: hash(i, 0.7 + z) * TURN,
405
+ }));
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);
448
+ };
449
+ return { touch: light(glow), field };
450
+ }
451
+ /**
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.
458
+ */
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() };
467
+ };
468
+ }
469
+ /**
470
+ * Where each cell falls in a transition's order, in [0, 1]: smooth noise over
471
+ * the element, so neighbouring cells arrive or leave together, in patches
472
+ * with soft edges, as ink spreads or mist thins. `z` places the element in
473
+ * the noise. A cell's place never changes, so it is worked out once.
474
+ */
475
+ function order(z) {
476
+ const places = new Map();
477
+ return (cell) => {
478
+ const at = `${cell.row}:${cell.col}`;
479
+ // The noise is read through slower noise, so the patches reach out in
480
+ // curling tendrils rather than sitting as round blots.
481
+ const place = places.get(at) ??
482
+ clamp01(0.5 + 1.1 * fbm(cell.col * 0.09 + 1.4 * noise(cell.col * 0.035, cell.row * 0.2 + 2.6, z + 0.5), cell.row * 0.45, z, 3));
483
+ places.set(at, place);
484
+ return place;
485
+ };
486
+ }
487
+ function transition(curve, start, effect) {
488
+ const end = start + curve.seconds;
489
+ return { effect, done: (t) => t >= end };
490
+ }
491
+ /**
492
+ * Of a transition's `seconds`, how long each cell's own change takes. Under
493
+ * 1, so the last cell to start is done by the transition's end.
494
+ */
495
+ const OWN = 0.45;
496
+ /**
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`.
500
+ */
501
+ function ownChange(curve, start, place) {
502
+ const own = curve.seconds * OWN;
503
+ // 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));
506
+ }
507
+ /**
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`.
512
+ */
513
+ export function fadeIn(curve, start, z, ground) {
514
+ 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));
517
+ }
518
+ /**
519
+ * The rebound of a dissolve, every constant that makes it feel as it does —
520
+ * patches of what is left rising back before they go: `depth` (the most, as a
521
+ * share of whole, a cell rises again), `from` and `to` (where in its own
522
+ * fade, as a share of it gone, the rising begins and is done), `maskFrom` and
523
+ * `maskTo` (which cells rebound: those whose eddy noise is past `maskFrom`,
524
+ * all of them past `maskTo`) and `late` (how much likelier the later a cell
525
+ * goes: 0 is no more likely, 1 is nothing for the first to go).
526
+ */
527
+ const DISSOLVE_SHAPE = { depth: 0.4, from: 0.5, to: 0.95, maskFrom: 0.35, maskTo: 0.75, late: 0.6 };
528
+ /**
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
532
+ * in one breath: towards the end, patches of what is left — the later a cell
533
+ * goes, the likelier — rise back part way, as the last of it eddies, before
534
+ * they thin away for good.
535
+ */
536
+ export function dissolveOut(curve, start, z, ground) {
537
+ requireCurve(curve);
538
+ const place = order(4.2 + z);
539
+ const eddy = order(9.3 + z);
540
+ const faded = ownChange(curve, start, place);
541
+ 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));
548
+ }
549
+ /**
550
+ * The periods, in seconds, of the two noises a segment's stray from the
551
+ * wheel's shared turn rides on. Primes, as every loop's default period is,
552
+ * and none a multiple of another's: a session's loops never come back round
553
+ * to a state they have shown, within any sitting — the first return of the
554
+ * wheel alone is after the product of its three periods.
555
+ */
556
+ const STRAY_PERIODS = [211, 337];
557
+ /** How far a segment may stray from the shared turn at swing 1, in degrees: half a turn — fully apart. */
558
+ const STRAY = 180;
559
+ /**
560
+ * Wheel, as the colour of daylight going round: every hue turning the whole
561
+ * way round once a period — far too slowly to be seen moving, so a glance
562
+ * sees a steady palette and an hour's absence a different one — each
563
+ * segment of the element at its own pace. Swing: how far the segments stray
564
+ * from one another, 0–1, 1 being half a turn apart. A segment is a fill of
565
+ * the element's own — one of `fills`, the colours it draws as a ground — and
566
+ * it turns at its own pace wherever it is drawn: as the ground of its cells,
567
+ * and as the ink of the seam glyph pointing out of it into the next, so a
568
+ * powerline cap keeps the colour of the cell it caps. An ink that is no fill
569
+ * turns with the ground it sits on, so a cell's words and fill turn
570
+ * together; on the terminal's ground, a run of text's ink is a segment of
571
+ * its own. A segment's stray is slow noise of its own, at `STRAY_PERIODS`,
572
+ * so no two keep step, and the row never shows one spread of hues twice.
573
+ * Lightness and chroma are kept, so the words read as they did; the turn
574
+ * only happens to the element's own colours (`Drawn.colors`), never the
575
+ * terminal's.
576
+ */
577
+ export function wheel(curve, z) {
578
+ requireCurve(curve);
579
+ const P = curve.seconds;
580
+ // A segment's seed, from the colour it is.
581
+ const seed = (hex) => hash(Number.parseInt(hex.slice(1), 16) * 1e-4, 17.3 + z);
582
+ const stray = (s, t) => noise(t / STRAY_PERIODS[0], 5.9 + 97 * s, z) + 0.5 * noise(t / STRAY_PERIODS[1], 2.3 + 89 * s, z + 7.1);
583
+ // Measured from where it stood at the start, as pulse's wander is, so the
584
+ // first frame turns nothing and the second has turned only a frame's worth.
585
+ const turned = (hex, t) => {
586
+ const s = seed(hex);
587
+ return (360 * t) / P + curve.swing * STRAY * ((stray(s, t) - stray(s, 0)) / 1.5);
588
+ };
589
+ return (shown, cell, t, { colors, fills }) => {
590
+ // Whether a slot shows one of the element's colours: never the terminal's, whatever its hex.
591
+ const own = { fg: !cell.terminal.fg && colors.has(shown.fg.hex), bg: !cell.terminal.bg && colors.has(shown.bg.hex) };
592
+ // An own colour turns with its segment: itself if a fill or beside no own colour, else the one beside it.
593
+ const turn = (color, mine, other, theirs) => !mine ? color : Oklch.fromRgba(color).applyKey({ ...IDENTITY, hueShift: turned(fills.has(color.hex) || !theirs ? color.hex : other.hex, t) }).toRgba();
594
+ return { fg: turn(shown.fg, own.fg, shown.bg, own.bg), bg: turn(shown.bg, own.bg, shown.fg, own.fg) };
595
+ };
596
+ }
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import type { Measurable, Renderable, RenderOptions } from "../core/protocol.js";
25
25
  import { Segment } from "../core/segment.js";
26
- import type { Style } from "../core/style.js";
26
+ import type { Style, TextStyle } from "../core/style.js";
27
27
  import { RichText } from "../core/text.js";
28
28
  /**
29
29
  * Caller content as text an embedding site lays out. A string is the only kind
@@ -39,9 +39,12 @@ export declare class EmbeddedText implements Renderable, Measurable {
39
39
  * The text this content draws under `options`. A string reads with `base` as
40
40
  * its style, as Rich's `render_str(style=)` gives one; text handed over
41
41
  * keeps its own, since the caller already said how it looks — so a table's
42
- * `Text` title draws without the title style, as Rich's does.
42
+ * `Text` title draws without the title style, as Rich's does. A highlighted
43
+ * string keeps only its spans: Rich's `render_str` highlights a fresh `Text`
44
+ * of the plain string and copies the spans onto it, so `base` is lost there,
45
+ * and a title Rich reads with highlighting on draws without it.
43
46
  */
44
- text(options: RenderOptions, base?: Style): RichText;
47
+ text(options: RenderOptions, base?: TextStyle): RichText;
45
48
  render(options: RenderOptions): Iterable<Segment>;
46
49
  /**
47
50
  * Measured unhighlighted, as Rich's `Measurement.get` reads a string with
@@ -73,8 +76,11 @@ export declare function embed(content: unknown): Renderable & Partial<Measurable
73
76
  export declare class InlineLabel {
74
77
  private readonly _content;
75
78
  constructor(content: string | RichText);
76
- /** The label drawn under `options`: one line, its own overflow kept for the caller's cut. */
77
- text(options: RenderOptions): RichText;
79
+ /**
80
+ * The label drawn under `options`: one line, its own overflow kept for the
81
+ * caller's cut. A string reads with `base` as its style, as `EmbeddedText.text` reads one.
82
+ */
83
+ text(options: RenderOptions, base?: TextStyle): RichText;
78
84
  }
79
85
  /**
80
86
  * A label cut to `width` cells as Rich's `Text.truncate` cuts one: its plain
@@ -52,7 +52,10 @@ export class EmbeddedText {
52
52
  * The text this content draws under `options`. A string reads with `base` as
53
53
  * its style, as Rich's `render_str(style=)` gives one; text handed over
54
54
  * keeps its own, since the caller already said how it looks — so a table's
55
- * `Text` title draws without the title style, as Rich's does.
55
+ * `Text` title draws without the title style, as Rich's does. A highlighted
56
+ * string keeps only its spans: Rich's `render_str` highlights a fresh `Text`
57
+ * of the plain string and copies the spans onto it, so `base` is lost there,
58
+ * and a title Rich reads with highlighting on draws without it.
56
59
  */
57
60
  text(options, base) {
58
61
  if (typeof this._source !== "string")
@@ -61,9 +64,10 @@ export class EmbeddedText {
61
64
  const reading = this._readings.get(markup) ?? readStr(this._source, markup);
62
65
  this._readings.set(markup, reading);
63
66
  const text = reading.copy();
64
- if (base)
67
+ const highlighter = activeHighlighter(options);
68
+ if (base !== undefined && highlighter === undefined)
65
69
  text.style = base;
66
- activeHighlighter(options)?.highlight(text);
70
+ highlighter?.highlight(text);
67
71
  return text;
68
72
  }
69
73
  render(options) {
@@ -105,9 +109,12 @@ export class InlineLabel {
105
109
  constructor(content) {
106
110
  this._content = new EmbeddedText(content);
107
111
  }
108
- /** The label drawn under `options`: one line, its own overflow kept for the caller's cut. */
109
- text(options) {
110
- const text = this._content.text(options);
112
+ /**
113
+ * The label drawn under `options`: one line, its own overflow kept for the
114
+ * caller's cut. A string reads with `base` as its style, as `EmbeddedText.text` reads one.
115
+ */
116
+ text(options, base) {
117
+ const text = this._content.text(options, base);
111
118
  // A fragment of the line, so its own end is not drawn, as Rich's `_title` clears it.
112
119
  text.end = "";
113
120
  text.plain = text.plain.replaceAll("\n", " ");