@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
@@ -2,11 +2,18 @@
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, and a
6
- * time in, the cell's new colours out. Shimmer, pulse, fade and dissolve are
7
- * all this one shape, differing by what the function does with its inputs
8
- * [LAW:one-type-per-behavior]; two effects stack by nesting one wrapper in
9
- * another, which is the same as calling one after the other.
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.
11
+ *
12
+ * 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
15
+ * settled against (`onColors`, `./effects.ts`), so no caller derives it a
16
+ * second time from a render of its own.
10
17
  *
11
18
  * [LAW:no-ambient-temporal-coupling] `t` is a constructor argument, not a
12
19
  * clock read. Whoever draws frames builds the view for each frame's `t`, so
@@ -22,8 +29,9 @@
22
29
  * cell (`Style.shiftedBy`), so hit testing still finds the owner.
23
30
  * - A cell whose colours come back unchanged keeps its original `ColorSpec`,
24
31
  * and a segment whose every cell is unchanged is yielded as it was, so the
25
- * identity effect is byte-identical. Neighbouring cells given equal colours
26
- * stay one segment: a segment is cut only where the colours change.
32
+ * identity effect is byte-identical. Neighbouring cells the output depth
33
+ * writes alike stay one segment: a segment is cut only where the written
34
+ * colours change, so two colours that land on one index are one run.
27
35
  * - New colours are built with `ColorSpec.fromRgba`, never parsed from a
28
36
  * string, so no frame adds an entry to a parse cache.
29
37
  * - With no colour output (`colorSystem: null`) there is nothing for an
@@ -32,7 +40,8 @@
32
40
  * Positions are the child's own: row and column count from the top-left of
33
41
  * what the child drew, so a sweep crosses the element, not the screen.
34
42
  */
35
- import { type ColorRgba, type TerminalTheme } from "../core/color.js";
43
+ import { type Anchor } from "../core/anchor.js";
44
+ import { ColorDepth, type ColorRgba, type TerminalTheme } from "../core/color.js";
36
45
  import type { Measurable, Renderable, RenderOptions } from "../core/protocol.js";
37
46
  import { Segment } from "../core/segment.js";
38
47
  /**
@@ -45,17 +54,63 @@ export interface CellColors {
45
54
  readonly bg: ColorRgba;
46
55
  }
47
56
  /**
48
- * Where a cell is in the output of the renderable an effect wraps, and its
49
- * seed: a number in [0, 1) that is the same for that cell on every frame, for
50
- * an effect that varies cell by cell (a per-cell offset in time, a sparkle).
57
+ * Where a cell is in the output of the renderable an effect wraps, its seed:
58
+ * a number in [0, 1) that is the same for that cell on every frame, for an
59
+ * effect that varies cell by cell (a per-cell offset in time, a sparkle) —
60
+ * and which of its on-screen colours are the terminal's.
51
61
  */
52
62
  export interface EffectCell {
53
63
  readonly row: number;
54
64
  readonly col: number;
55
65
  readonly seed: number;
66
+ /**
67
+ * Whether the glyph's colour and the colour behind it, as the screen shows
68
+ * them, are the terminal's own: a slot the element's style leaves unset or
69
+ * sets to `default`. Such a colour is resolved through the theme, so its
70
+ * hex can equal one the element wrote; this is what tells them apart.
71
+ */
72
+ readonly terminal: {
73
+ readonly fg: boolean;
74
+ readonly bg: boolean;
75
+ };
76
+ /**
77
+ * Who drew the cell, and where it sits in their output, as
78
+ * `Segment.anchorAt` reads it off a frame: what tells one owner's cells
79
+ * from another's when their colours are alike (`confined`).
80
+ */
81
+ readonly anchor: Anchor | undefined;
56
82
  }
57
- /** A cell's colours at time `t`, in seconds. Return `colors` to leave it be. */
58
- export type Effect = (colors: CellColors, cell: EffectCell, t: number) => CellColors;
83
+ /**
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.
86
+ */
87
+ export interface Pair extends CellColors {
88
+ readonly terminal: EffectCell["terminal"];
89
+ }
90
+ /**
91
+ * The colours the element an effect wraps draws its words in, in one frame,
92
+ * as the screen shows them: what an effect that must keep the words legible
93
+ * is measured against. A word cell is one whose glyph is not blank and that a
94
+ * strip's joiner did not draw (`JOIN`): a seam's ink is the fill beside it,
95
+ * not words.
96
+ */
97
+ export interface Drawn {
98
+ /** Every distinct ink and ground of a word cell. */
99
+ readonly pairs: readonly Pair[];
100
+ /** The colours the element sets itself in its word cells, by hex: never the terminal's, whatever its hex. */
101
+ readonly colors: ReadonlySet<string>;
102
+ /** Of `colors`, the ones a word cell has for its ground. */
103
+ readonly fills: ReadonlySet<string>;
104
+ /** The depth the frame is written at: below truecolor a moved colour lands on a palette colour. */
105
+ readonly depth: ColorDepth;
106
+ /** The terminal the frame is drawn on. */
107
+ readonly theme: TerminalTheme;
108
+ }
109
+ /**
110
+ * A cell's colours at time `t`, in seconds, in a frame whose element draws
111
+ * `drawn`. Return `colors` to leave it be.
112
+ */
113
+ export type Effect = (colors: CellColors, cell: EffectCell, t: number, drawn: Drawn) => CellColors;
59
114
  export interface EffectedOptions {
60
115
  /** The moment to draw, in seconds. */
61
116
  readonly t: number;
@@ -84,15 +139,33 @@ export declare class Effected implements Renderable, Measurable {
84
139
  maximum: number;
85
140
  };
86
141
  /**
87
- * The style that draws a run's slot colours `to` where the segment drew
142
+ * The specs that draw a cell's slot colours `to` where the segment drew
88
143
  * `from` with `wire`: a slot the effect left alone keeps its written spec,
89
144
  * and a moved one is laid on what is beneath it — the ground on
90
145
  * `SURFACE_BLACK`, the glyph on that ground, as the writer lays any
91
146
  * translucent colour (`Style.drawnColors`) — then drawn in the colour nearest it at `depth`
92
- * that the terminal shows (`ColorSpec.matchOn`), so a colour moved a little
93
- * off a theme slot or the default colour is still drawn as that one.
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.
94
152
  */
95
153
  private respec;
96
154
  /** A cell's seed: FNV-1a of the key and the position, scaled into [0, 1). */
97
155
  private seed;
98
156
  }
157
+ /**
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`).
164
+ */
165
+ export declare function confined(effect: Effect, owner: object): Effect;
166
+ /**
167
+ * What `segments`, drawn at `depth` on `theme`, draw their words in, measured
168
+ * as `Effected` measures its child for every frame. Measure an element's
169
+ * output under an effect to read the contrast its words are left at.
170
+ */
171
+ export declare function drawnOf(segments: Iterable<Segment>, depth: ColorDepth, theme: TerminalTheme): Drawn;
@@ -2,11 +2,18 @@
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, and a
6
- * time in, the cell's new colours out. Shimmer, pulse, fade and dissolve are
7
- * all this one shape, differing by what the function does with its inputs
8
- * [LAW:one-type-per-behavior]; two effects stack by nesting one wrapper in
9
- * another, which is the same as calling one after the other.
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.
11
+ *
12
+ * 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
15
+ * settled against (`onColors`, `./effects.ts`), so no caller derives it a
16
+ * second time from a render of its own.
10
17
  *
11
18
  * [LAW:no-ambient-temporal-coupling] `t` is a constructor argument, not a
12
19
  * clock read. Whoever draws frames builds the view for each frame's `t`, so
@@ -22,8 +29,9 @@
22
29
  * cell (`Style.shiftedBy`), so hit testing still finds the owner.
23
30
  * - A cell whose colours come back unchanged keeps its original `ColorSpec`,
24
31
  * and a segment whose every cell is unchanged is yielded as it was, so the
25
- * identity effect is byte-identical. Neighbouring cells given equal colours
26
- * stay one segment: a segment is cut only where the colours change.
32
+ * identity effect is byte-identical. Neighbouring cells the output depth
33
+ * writes alike stay one segment: a segment is cut only where the written
34
+ * colours change, so two colours that land on one index are one run.
27
35
  * - New colours are built with `ColorSpec.fromRgba`, never parsed from a
28
36
  * string, so no frame adds an entry to a parse cache.
29
37
  * - With no colour output (`colorSystem: null`) there is nothing for an
@@ -32,11 +40,13 @@
32
40
  * Positions are the child's own: row and column count from the top-left of
33
41
  * what the child drew, so a sweep crosses the element, not the screen.
34
42
  */
43
+ import { drawnBy, shiftAnchor } from "../core/anchor.js";
35
44
  import { cellLen, graphemes } from "../core/cells.js";
36
- import { ColorDepth, ColorSpec, SURFACE_BLACK } from "../core/color.js";
45
+ import { ColorDepth, ColorSpec, SURFACE_BLACK, rgbDistance } from "../core/color.js";
37
46
  import { fnv1a } from "../core/fnv1a.js";
38
47
  import { Measurement } from "../core/measure.js";
39
48
  import { Segment } from "../core/segment.js";
49
+ import { JOIN, colorsOwnedBy } from "../core/strip.js";
40
50
  import { Style } from "../core/style.js";
41
51
  export class Effected {
42
52
  renderable;
@@ -62,56 +72,59 @@ export class Effected {
62
72
  }
63
73
  // No depth named is no downgrade, as `drawnColors` reads it.
64
74
  const depth = options.colorSystem ?? ColorDepth.TRUECOLOR;
75
+ // [LAW:one-source-of-truth] The frame is measured before any cell is
76
+ // handed to the effect, off the same looks its cells are drawn from.
77
+ const looked = looks(segments, depth, this.theme);
78
+ const drawn = measured(looked, depth, this.theme);
65
79
  let row = 0;
66
80
  let col = 0;
67
- for (const segment of segments) {
68
- if (segment.isControl) {
81
+ for (const { segment, look } of looked) {
82
+ if (look === undefined) {
69
83
  yield segment;
70
84
  continue;
71
85
  }
72
- const base = segment.style ?? Style.null();
73
- // [LAW:single-enforcer] `drawnColors` is what the wire writes at this
74
- // depth — alpha flattened, downgraded — and the theme resolves what it
75
- // leaves to the terminal. A slot the effect leaves alone keeps the
76
- // written spec, so it stays what the screen showed whatever moves
77
- // beside it.
78
- const wire = base.drawnColors(options.colorSystem ?? undefined);
79
- const ink = (wire.color ?? DEFAULT).getTruecolor(this.theme, true);
80
- const paper = (wire.bgcolor ?? DEFAULT).getTruecolor(this.theme, false);
81
- const from = onScreen({ fg: ink, bg: paper }, base);
82
- // Each grapheme's colours, merged into runs of equal colours as they
83
- // come; `undefined` is a run left as the segment drew it.
86
+ const { style: base, wire, slots, pair } = look;
87
+ const { terminal } = pair;
88
+ const from = { fg: pair.fg, bg: pair.bg };
89
+ // [LAW:one-source-of-truth] A run is cells the wire writes alike: each
90
+ // cell is drawn at the output depth first and merged on the colours that
91
+ // writes, so two truecolors landing on one index stay one run. A cell
92
+ // the effect gave its neighbour's colours is drawn as that neighbour was.
84
93
  const runs = [];
94
+ // Cells into the segment, as `base.shiftedBy` counts them below.
95
+ let into = 0;
85
96
  for (const glyph of graphemes(segment.text)) {
86
- let colors;
97
+ let colors = slots;
87
98
  if (glyph === "\n") {
88
99
  row++;
89
100
  col = 0;
90
101
  }
91
102
  else {
92
- const to = this.effect(from, { row, col, seed: this.seed(row, col) }, this.t);
93
- colors = sameColors(to, from) ? undefined : to;
103
+ const anchor = base.anchor && shiftAnchor(base.anchor, into);
104
+ // 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);
94
106
  col += cellLen(glyph);
95
107
  }
108
+ into += cellLen(glyph);
96
109
  const last = runs[runs.length - 1];
97
- if (last !== undefined && sameRun(last.colors, colors)) {
110
+ const writes = last !== undefined && sameColors(last.colors, colors) ? last.drawn : this.respec(wire, slots, colors, depth);
111
+ if (last !== undefined && writesAlike(last.drawn, writes)) {
98
112
  last.text += glyph;
99
113
  last.cells += cellLen(glyph);
114
+ last.colors = colors;
100
115
  }
101
116
  else {
102
- runs.push({ text: glyph, cells: cellLen(glyph), colors });
117
+ runs.push({ text: glyph, cells: cellLen(glyph), colors, drawn: writes });
103
118
  }
104
119
  }
105
- if (runs.every((run) => run.colors === undefined)) {
120
+ if (runs.every((run) => writesAlike(run.drawn, wire))) {
106
121
  yield segment;
107
122
  continue;
108
123
  }
109
124
  let offset = 0;
110
125
  for (const run of runs) {
111
126
  const shifted = base.shiftedBy(offset);
112
- // Back from the screen to the style's slots: the same swap undoes itself.
113
- const slots = run.colors === undefined ? undefined : onScreen(run.colors, base);
114
- const style = slots === undefined ? shifted : shifted.add(this.respec(wire, { fg: ink, bg: paper }, slots, depth));
127
+ const style = writesAlike(run.drawn, wire) ? shifted : shifted.add(Style.fromColor(run.drawn.color, run.drawn.bgcolor));
115
128
  yield new Segment(run.text, style);
116
129
  offset += run.cells;
117
130
  }
@@ -121,20 +134,26 @@ export class Effected {
121
134
  return Measurement.get(options, this.renderable);
122
135
  }
123
136
  /**
124
- * The style that draws a run's slot colours `to` where the segment drew
137
+ * The specs that draw a cell's slot colours `to` where the segment drew
125
138
  * `from` with `wire`: a slot the effect left alone keeps its written spec,
126
139
  * and a moved one is laid on what is beneath it — the ground on
127
140
  * `SURFACE_BLACK`, the glyph on that ground, as the writer lays any
128
141
  * translucent colour (`Style.drawnColors`) — then drawn in the colour nearest it at `depth`
129
- * that the terminal shows (`ColorSpec.matchOn`), so a colour moved a little
130
- * off a theme slot or the default colour is still drawn as that one.
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.
131
147
  */
132
148
  respec(wire, from, to, depth) {
133
149
  // [LAW:one-source-of-truth] The writer's surface, so one translucent colour draws one way in a frame.
134
150
  const ground = to.bg.compositeOver(SURFACE_BLACK);
135
151
  const glyph = to.fg.compositeOver(ground);
136
- const drawn = (spec, was, now, foreground) => sameColor(was, now) ? spec : ColorSpec.matchOn(now, depth, this.theme, foreground);
137
- return Style.fromColor(drawn(wire.color, from.fg, glyph, true), drawn(wire.bgcolor, from.bg, ground, false));
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) };
138
157
  }
139
158
  /** A cell's seed: FNV-1a of the key and the position, scaled into [0, 1). */
140
159
  seed(row, col) {
@@ -142,19 +161,73 @@ export class Effected {
142
161
  }
143
162
  }
144
163
  const DEFAULT = ColorSpec.default();
164
+ /** Each of `segments` with how it is drawn at `depth` on `theme`; a control segment draws nothing. */
165
+ function looks(segments, depth, theme) {
166
+ return [...segments].map((segment) => {
167
+ if (segment.isControl)
168
+ return { segment, look: undefined };
169
+ const style = segment.style ?? Style.null();
170
+ const wire = style.drawnColors(depth);
171
+ 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 } } };
174
+ });
175
+ }
176
+ /** What `looked` draws its words in: `Drawn`'s measure, taken once per frame. */
177
+ function measured(looked, depth, theme) {
178
+ 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()];
180
+ // [LAW:one-source-of-truth] A slot the terminal fills is no colour of the element's, whatever its hex.
181
+ const own = (slot) => pairs.flatMap((pair) => (pair.terminal[slot] ? [] : [pair[slot].hex]));
182
+ return { pairs, colors: new Set([...own("fg"), ...own("bg")]), fills: new Set(own("bg")), depth, theme };
183
+ }
184
+ /**
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`).
191
+ */
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 };
197
+ };
198
+ }
199
+ /**
200
+ * What `segments`, drawn at `depth` on `theme`, draw their words in, measured
201
+ * as `Effected` measures its child for every frame. Measure an element's
202
+ * output under an effect to read the contrast its words are left at.
203
+ */
204
+ export function drawnOf(segments, depth, theme) {
205
+ return measured(looks(segments, depth, theme), depth, theme);
206
+ }
145
207
  /**
146
208
  * A style's colour slots as the screen shows them, or the reverse: `reverse`
147
209
  * swaps glyph and ground, and swapping twice is where you started.
148
210
  */
149
- function onScreen(colors, style) {
150
- return style.reverse === true ? { fg: colors.bg, bg: colors.fg } : colors;
211
+ function onScreen(slots, style) {
212
+ return style.reverse === true ? { fg: slots.bg, bg: slots.fg } : slots;
151
213
  }
152
- function sameColor(a, b) {
153
- return a.red === b.red && a.green === b.green && a.blue === b.blue && a.alpha === b.alpha;
214
+ /**
215
+ * Whether two cells' colours are written alike. A slot with no colour is not
216
+ * the default colour: it writes nothing, so a style a container lays beneath
217
+ * (`Segment.applyStyle`) still shows through it, where `39`/`49` would not.
218
+ */
219
+ function writesAlike(a, b) {
220
+ return slotWritesAlike(a.color, b.color, true) && slotWritesAlike(a.bgcolor, b.bgcolor, false);
221
+ }
222
+ function slotWritesAlike(a, b, foreground) {
223
+ return a === b || written(a, foreground) === written(b, foreground);
224
+ }
225
+ function written(spec, foreground) {
226
+ return spec?.getAnsiCodes(foreground).join(";") ?? "";
154
227
  }
155
228
  function sameColors(a, b) {
156
229
  return sameColor(a.fg, b.fg) && sameColor(a.bg, b.bg);
157
230
  }
158
- function sameRun(a, b) {
159
- return a === undefined || b === undefined ? a === b : sameColors(a, b);
231
+ function sameColor(a, b) {
232
+ return a.red === b.red && a.green === b.green && a.blue === b.blue && a.alpha === b.alpha;
160
233
  }
@@ -0,0 +1,260 @@
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 } from "../core/color.js";
45
+ import { type Ease } from "../core/easing.js";
46
+ import type { Drawn, Effect, EffectCell, Pair } from "./effect.js";
47
+ /** What a loop does to one colour at strength `w` in [0, 1]: at 0, nothing. */
48
+ 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;
51
+ /** A looping effect: what it does to a colour, and where and when, how strongly. */
52
+ export interface Loop {
53
+ readonly touch: Touch;
54
+ readonly field: Field;
55
+ }
56
+ /**
57
+ * 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
59
+ * 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
61
+ * the terminal supplies, whatever its hex, as in `onColors`. One of `colors`
62
+ * no pair writes has nothing to be measured against, and takes it all.
63
+ *
64
+ * Colours are settled lightest first, each against every partner: one
65
+ * already settled at its share, one not yet settled as it is. So the lighter
66
+ * colour of a cell takes the light first and its darker partner spends
67
+ * whatever contrast is left — pale lettering lifting off a dark fill gives
68
+ * the fill room to glow — and a darker colour left as it is always holds,
69
+ * because its lighter partner was settled against exactly that.
70
+ */
71
+ export declare function shares(pairs: readonly Pair[], colors: ReadonlySet<string>, touch: Touch): Map<string, number>;
72
+ /**
73
+ * A loop as an `Effect` on the element's own colours, by hex, wherever a cell
74
+ * shows them, each touched at its share of the cell's strength: the share
75
+ * `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
79
+ * sets itself. A powerline strip's fill is the ground of its cells and the
80
+ * ink of the seam glyphs between them, so the arrow moves with the cell it
81
+ * points out of. A colour the terminal supplies (`EffectCell.terminal`) is
82
+ * not the element's, whatever its hex, and takes no share.
83
+ *
84
+ * Settling shares is costly, so it is done once for each set of colours an
85
+ * element draws: make the effect once and draw it in every frame, and a frame
86
+ * drawing what an earlier one drew costs no settling.
87
+ */
88
+ export declare function onColors(loop: Loop, colors?: ReadonlySet<string>): Effect;
89
+ /**
90
+ * A loop as an `Effect` on colours by hex, each touched at the share
91
+ * `share` gives it of the cell's strength in a frame drawing what `Drawn`
92
+ * says; a colour it gives none, and one the terminal supplies, stays as it is.
93
+ */
94
+ export declare function atShares(loop: Loop, share: (drawn: Drawn) => ReadonlyMap<string, number>): Effect;
95
+ /** How the curves below are tuned: one period (or duration), ease and swing. */
96
+ export interface Curve {
97
+ /** Seconds: a looping effect's period, a transition's duration. */
98
+ readonly seconds: number;
99
+ /** Intensity in [0, 1] to the fraction of `swing` applied. One that overshoots, as a CSS `cubic-bezier` may, saturates: an effect never acts past its swing. */
100
+ readonly ease: Ease;
101
+ /** How far the move goes at full intensity, in [0, 1]; what 1 means is the effect's. */
102
+ readonly swing: number;
103
+ }
104
+ /**
105
+ * Each effect's curve as it was signed off. Every loop's period is a prime
106
+ * number of seconds, so no two loops come back into step within a sitting.
107
+ * The periods are long on purpose: at one frame a second a loop's colour
108
+ * moves by no more than a just-noticeable step from one frame to the next.
109
+ */
110
+ export declare const EFFECT_CURVES: {
111
+ readonly shimmer: {
112
+ readonly seconds: 109;
113
+ readonly ease: Ease;
114
+ readonly swing: 0.9;
115
+ };
116
+ readonly pulse: {
117
+ readonly seconds: 43;
118
+ readonly ease: Ease;
119
+ readonly swing: 0.85;
120
+ };
121
+ readonly sparkle: {
122
+ readonly seconds: 139;
123
+ readonly ease: Ease;
124
+ readonly swing: 0.95;
125
+ };
126
+ readonly wheel: {
127
+ readonly seconds: 907;
128
+ readonly ease: Ease;
129
+ readonly swing: 1;
130
+ };
131
+ readonly fade: {
132
+ readonly seconds: 20;
133
+ readonly ease: Ease;
134
+ readonly swing: 1;
135
+ };
136
+ readonly dissolve: {
137
+ readonly seconds: 30;
138
+ readonly ease: Ease;
139
+ readonly swing: 1;
140
+ };
141
+ };
142
+ /** The lights the loops were signed off casting, warm as sunlight and fireflies are. */
143
+ export declare const EFFECT_LIGHTS: {
144
+ readonly sun: ColorRgba;
145
+ readonly firefly: ColorRgba;
146
+ };
147
+ /** How far a shimmer's light reaches either side of its centre, in columns, as signed off. */
148
+ export declare const SHIMMER_WIDTH = 24;
149
+ /**
150
+ * A colour under `glow`: the one way every light here falls. It moves `w` of
151
+ * the way toward the light's colour, and never below its own lightness —
152
+ * light can warm a colour and lift it, never darken it. A colour already
153
+ * lighter than the light only warms, and white, which has no warmer shade at
154
+ * its lightness, stays white.
155
+ */
156
+ export declare function light(glow: ColorRgba): Touch;
157
+ /**
158
+ * Pulse, as a gentle glow on one element of a screen: the colours handed to
159
+ * it, an element's own, warm into `light` and settle back, over and over, the
160
+ * way an indicator breathes while something is waiting. Swing: how far into
161
+ * the light it goes at the top of a beat, 0–1. The element glows as one —
162
+ * every cell it is drawn in at once — and the colours not handed to it, the
163
+ * rest of the screen, stay as they are, so what pulses is what was chosen.
164
+ * Each element keeps a time of its own, a little quicker or slower than
165
+ * another's by `z`, and no beat is quite the last: the rhythm wanders ahead
166
+ * and behind its metronome and each beat goes a little deeper or shallower
167
+ * than the one before. A beat starts and ends in rest, so `t = 0` draws the
168
+ * element untouched.
169
+ */
170
+ export declare function pulse(curve: Curve, glow: ColorRgba, z: number): Loop;
171
+ /**
172
+ * Shimmer, as sunlight moving across water. Swing: how far into `light` the
173
+ * brightest glint goes, 0–1. A soft band of light, `width` columns either
174
+ * side of its centre, crosses columns 0 to `span`, entering and leaving
175
+ * fully off the row, `STRIDE` columns in about three quarters of a period. Its passes
176
+ * come the way light comes and goes on water as clouds move: while the sun
177
+ * is out they are bright and follow close on one another, one sometimes
178
+ * catching the last; under cloud they thin to faint ones with the row still
179
+ * for a while between, and the light is never gone for long. Each crosses at its own unhurried pace, drifting a little ahead
180
+ * and behind as it goes. Inside a band the light is a faint glow broken
181
+ * into a glitter path — bright filaments where two ripples cross — carried
182
+ * on a slow current and re-forming as they go, so the glints dance while
183
+ * the band glides. Before the first pass enters, the row is untouched.
184
+ */
185
+ export declare function shimmer(curve: Curve, span: number, width: number, glow: ColorRgba, z: number): Loop;
186
+ /**
187
+ * Sparkle, as fireflies. Swing: how far into the firefly's `light` a glow
188
+ * goes at its brightest, 0–1. About one firefly to every eight columns of
189
+ * `span`, each keeping near a home of its own. A firefly flashes — kindles,
190
+ * drifts a few cells glowing, slowing as it fades — then flies on in the
191
+ * dark and flashes again somewhere a few cells off, the way fireflies at
192
+ * dusk are seen: a short lit stroke, then gone, each time a little
193
+ * elsewhere. Each keeps its own irregular time, now and then letting a turn
194
+ * pass dark, so most moments a few are lit, at different brightnesses. Its
195
+ * light is a soft halo several cells wide, brightest where it is, so as it
196
+ * drifts the glow slides between cells rather than hopping. `seconds` is a firefly's
197
+ * turn: its brightest flash, rise and fall, takes `FLASH` of it, and a
198
+ * dimmer one less.
199
+ */
200
+ export declare function sparkle(curve: Curve, span: number, glow: ColorRgba, z: number): Loop;
201
+ /**
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.
205
+ */
206
+ export type Visibility = (cell: EffectCell, t: number) => number;
207
+ /**
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
+ */
215
+ export declare function veiled(visibility: Visibility, swing: number, ground: ColorRgba): Effect;
216
+ /**
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.
221
+ */
222
+ export interface Transition {
223
+ readonly effect: Effect;
224
+ done(t: number): boolean;
225
+ }
226
+ /**
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`.
231
+ */
232
+ export declare function fadeIn(curve: Curve, start: number, z: number, ground: ColorRgba): Transition;
233
+ /**
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
237
+ * in one breath: towards the end, patches of what is left — the later a cell
238
+ * goes, the likelier — rise back part way, as the last of it eddies, before
239
+ * they thin away for good.
240
+ */
241
+ export declare function dissolveOut(curve: Curve, start: number, z: number, ground: ColorRgba): Transition;
242
+ /**
243
+ * Wheel, as the colour of daylight going round: every hue turning the whole
244
+ * way round once a period — far too slowly to be seen moving, so a glance
245
+ * sees a steady palette and an hour's absence a different one — each
246
+ * segment of the element at its own pace. Swing: how far the segments stray
247
+ * from one another, 0–1, 1 being half a turn apart. A segment is a fill of
248
+ * the element's own — one of `fills`, the colours it draws as a ground — and
249
+ * it turns at its own pace wherever it is drawn: as the ground of its cells,
250
+ * and as the ink of the seam glyph pointing out of it into the next, so a
251
+ * powerline cap keeps the colour of the cell it caps. An ink that is no fill
252
+ * turns with the ground it sits on, so a cell's words and fill turn
253
+ * together; on the terminal's ground, a run of text's ink is a segment of
254
+ * its own. A segment's stray is slow noise of its own, at `STRAY_PERIODS`,
255
+ * so no two keep step, and the row never shows one spread of hues twice.
256
+ * Lightness and chroma are kept, so the words read as they did; the turn
257
+ * only happens to the element's own colours (`Drawn.colors`), never the
258
+ * terminal's.
259
+ */
260
+ export declare function wheel(curve: Curve, z: number): Effect;