@promptctl/rich-js 0.20.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.
@@ -16,8 +16,9 @@
16
16
  *
17
17
  * Lone-ESC handling: a bare `\x1b` is ambiguous (escape key vs. start of a
18
18
  * CSI sequence). When the buffer is drained but ends with a lone ESC, the
19
- * parser defers emission via setTimeout(0). The next chunk cancels the timer
20
- * if it extends the sequence. `flush()` drains it synchronously.
19
+ * parser defers emission via setTimeout(0). A next chunk opening with `[` or
20
+ * `O` extends the sequence; any other next chunk emits the ESC as Escape
21
+ * first. `flush()` drains it synchronously.
21
22
  */
22
23
  /**
23
24
  * The one name a key goes by — what a keymap is keyed by and what
@@ -164,12 +165,26 @@ export class InputParser {
164
165
  /** Feed a chunk of bytes (or a string of them) into the parser. */
165
166
  feed(chunk) {
166
167
  const next = typeof chunk === "string" ? UTF8_ENCODER.encode(chunk) : chunk;
167
- this.buffer = concatBytes(this.buffer, next);
168
+ // A held lone ESC waits only for the rest of a split CSI/SS3 introducer.
169
+ // Any other next chunk means Escape was its own key, even when the hold
170
+ // timer has not run yet: a busy worker can queue the next key ahead of it,
171
+ // and joining them would read Escape then G as Alt+G (rich-host-input-0c4f).
172
+ const escaped = this.holdingEscape() && next.length > 0 && next[0] !== LBRACKET && next[0] !== O_BYTE;
168
173
  this.cancelEscape();
169
- this.drain();
174
+ // The buffer is settled before the sink hears Escape, so a sink that
175
+ // resets the parser on it (an app stopping) leaves nothing to drain.
176
+ this.buffer = concatBytes(escaped ? EMPTY_BYTES : this.buffer, next);
177
+ try {
178
+ if (escaped)
179
+ this.sink.key(ESCAPE);
180
+ }
181
+ finally {
182
+ this.drain();
183
+ }
170
184
  // [LAW:dataflow-not-control-flow] If a lone ESC remains, defer emission
171
- // until the next macrotask so a follow-up chunk can extend it.
172
- if (this.buffer.length === 1 && this.buffer[0] === ESC) {
185
+ // until the next macrotask so a follow-up chunk can extend it. Only a lone
186
+ // one: a sequence split after `ESC [` waits for its rest, however late.
187
+ if (this.holdingEscape()) {
173
188
  this.escTimer = setTimeout(() => {
174
189
  this.escTimer = undefined;
175
190
  this.flush();
@@ -179,9 +194,9 @@ export class InputParser {
179
194
  /** Force any pending lone ESC out of the buffer. */
180
195
  flush() {
181
196
  this.cancelEscape();
182
- if (this.buffer.length === 1 && this.buffer[0] === ESC) {
197
+ if (this.holdingEscape()) {
183
198
  this.buffer = EMPTY_BYTES;
184
- this.sink.key(({ key: "escape", character: "", shift: false, ctrl: false, meta: false }));
199
+ this.sink.key(ESCAPE);
185
200
  }
186
201
  }
187
202
  /** Drop what is buffered and any pending lone ESC: the parser as constructed. */
@@ -189,6 +204,10 @@ export class InputParser {
189
204
  this.cancelEscape();
190
205
  this.buffer = EMPTY_BYTES;
191
206
  }
207
+ /** The parser's one hold: a lone ESC that may yet open a sequence. */
208
+ holdingEscape() {
209
+ return this.buffer.length === 1 && this.buffer[0] === ESC;
210
+ }
192
211
  cancelEscape() {
193
212
  clearTimeout(this.escTimer);
194
213
  this.escTimer = undefined;
@@ -294,7 +313,7 @@ export class InputParser {
294
313
  return {
295
314
  kind: "key",
296
315
  bytes: 2,
297
- event: ({ key: "escape", character: "", shift: false, ctrl: false, meta: false }),
316
+ event: ESCAPE,
298
317
  };
299
318
  }
300
319
  // ESC <named> / ESC <printable> → Alt-modified key.
@@ -347,7 +366,7 @@ export class InputParser {
347
366
  return {
348
367
  kind: "key",
349
368
  bytes: 1,
350
- event: ({ key: "escape", character: "", shift: false, ctrl: false, meta: false }),
369
+ event: ESCAPE,
351
370
  };
352
371
  }
353
372
  consumeSS3(buf) {
package/dist/index.d.ts CHANGED
@@ -33,8 +33,8 @@ export type { Unsubscribe } from "./core/subscription.js";
33
33
  export { Measurement, measureRenderables } from "./core/measure.js";
34
34
  export { Span, RichText } from "./core/text.js";
35
35
  export type { RichTextOptions } from "./core/text.js";
36
- export { Strip, CellJoiner, SEAM_MIN_DELTA_E, POWERLINE_JOINER, CAPSULE_JOINER, PLAIN_JOINER, gradientJoiner, } from "./core/strip.js";
37
- export type { StyledRenderable, Joiner, JoinShape, JoinCell, CellColor, Paint } from "./core/strip.js";
36
+ export { Strip, CellJoiner, SEAM_MIN_DELTA_E, JOINERS, joinerNamed, gradientJoiner, } from "./core/strip.js";
37
+ export type { StyledRenderable, Joiner, JoinContext, JoinShape, JoinCell, CellColor, Paint } from "./core/strip.js";
38
38
  export { renderToString, segmentToString, segmentsToString, } from "./core/render.js";
39
39
  export type { RenderToStringOptions } from "./core/render.js";
40
40
  export { OSC8, osc8Sequences } from "./core/osc8.js";
@@ -55,10 +55,10 @@ export { Console } from "./core/console.js";
55
55
  export type { ConsoleOptions, ConsoleSink, ConsoleStream, ConsoleEnvironment, LiveRegion, PrintOptions, } from "./core/console.js";
56
56
  export type { Env } from "./core/env.js";
57
57
  export { Constrain } from "./renderables/constrain.js";
58
- export { Effected } from "./renderables/effect.js";
59
- export type { CellColors, Effect, EffectCell, EffectedOptions } from "./renderables/effect.js";
60
- export { EFFECT_CURVES, EFFECT_LIGHTS, SHIMMER_WIDTH, dissolveOut, fadeIn, onColors, pulse, shares, shimmer, sparkle, wheel, } from "./renderables/effects.js";
61
- export type { Curve, Field, Loop, Pair, Touch, Transition } from "./renderables/effects.js";
58
+ export { Effected, confined, drawnOf } from "./renderables/effect.js";
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";
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
@@ -44,7 +44,7 @@ export { Measurement, measureRenderables } from "./core/measure.js";
44
44
  // Text
45
45
  export { Span, RichText } from "./core/text.js";
46
46
  // Strip + Joiner
47
- export { Strip, CellJoiner, SEAM_MIN_DELTA_E, POWERLINE_JOINER, CAPSULE_JOINER, PLAIN_JOINER, gradientJoiner, } from "./core/strip.js";
47
+ export { Strip, CellJoiner, SEAM_MIN_DELTA_E, JOINERS, joinerNamed, gradientJoiner, } from "./core/strip.js";
48
48
  // renderToString — stateless one-shot emission
49
49
  export { renderToString, segmentToString, segmentsToString, } from "./core/render.js";
50
50
  // OSC 8 hyperlink wire grammar — for consumers that read rendered bytes back
@@ -69,8 +69,8 @@ export { SPINNERS, DEFAULT_SPINNER } from "./core/spinnerData.js";
69
69
  export { Console } from "./core/console.js";
70
70
  // Renderables
71
71
  export { Constrain } from "./renderables/constrain.js";
72
- export { Effected } from "./renderables/effect.js";
73
- export { EFFECT_CURVES, EFFECT_LIGHTS, SHIMMER_WIDTH, dissolveOut, fadeIn, onColors, pulse, shares, shimmer, sparkle, wheel, } from "./renderables/effects.js";
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";
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,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
@@ -33,7 +40,8 @@
33
40
  * Positions are the child's own: row and column count from the top-left of
34
41
  * what the child drew, so a sweep crosses the element, not the screen.
35
42
  */
36
- 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";
37
45
  import type { Measurable, Renderable, RenderOptions } from "../core/protocol.js";
38
46
  import { Segment } from "../core/segment.js";
39
47
  /**
@@ -65,9 +73,44 @@ export interface EffectCell {
65
73
  readonly fg: boolean;
66
74
  readonly bg: boolean;
67
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;
82
+ }
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;
68
108
  }
69
- /** A cell's colours at time `t`, in seconds. Return `colors` to leave it be. */
70
- export type Effect = (colors: CellColors, cell: EffectCell, t: number) => CellColors;
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;
71
114
  export interface EffectedOptions {
72
115
  /** The moment to draw, in seconds. */
73
116
  readonly t: number;
@@ -111,3 +154,18 @@ export declare class Effected implements Renderable, Measurable {
111
154
  /** A cell's seed: FNV-1a of the key and the position, scaled into [0, 1). */
112
155
  private seed;
113
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
@@ -33,11 +40,13 @@
33
40
  * Positions are the child's own: row and column count from the top-left of
34
41
  * what the child drew, so a sweep crosses the element, not the screen.
35
42
  */
43
+ import { drawnBy, shiftAnchor } from "../core/anchor.js";
36
44
  import { cellLen, graphemes } from "../core/cells.js";
37
45
  import { ColorDepth, ColorSpec, SURFACE_BLACK, rgbDistance } from "../core/color.js";
38
46
  import { fnv1a } from "../core/fnv1a.js";
39
47
  import { Measurement } from "../core/measure.js";
40
48
  import { Segment } from "../core/segment.js";
49
+ import { JOIN, colorsOwnedBy } from "../core/strip.js";
41
50
  import { Style } from "../core/style.js";
42
51
  export class Effected {
43
52
  renderable;
@@ -63,30 +72,27 @@ export class Effected {
63
72
  }
64
73
  // No depth named is no downgrade, as `drawnColors` reads it.
65
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);
66
79
  let row = 0;
67
80
  let col = 0;
68
- for (const segment of segments) {
69
- if (segment.isControl) {
81
+ for (const { segment, look } of looked) {
82
+ if (look === undefined) {
70
83
  yield segment;
71
84
  continue;
72
85
  }
73
- const base = segment.style ?? Style.null();
74
- // [LAW:single-enforcer] `drawnColors` is what the wire writes at this
75
- // depth — alpha flattened, downgraded — and the theme resolves what it
76
- // leaves to the terminal. A slot the effect leaves alone keeps the
77
- // written spec, so it stays what the screen showed whatever moves
78
- // beside it.
79
- const wire = base.drawnColors(options.colorSystem ?? undefined);
80
- const ink = (wire.color ?? DEFAULT).getTruecolor(this.theme, true);
81
- const paper = (wire.bgcolor ?? DEFAULT).getTruecolor(this.theme, false);
82
- const slots = { fg: ink, bg: paper };
83
- const from = onScreen(slots, base);
84
- const terminal = onScreen({ fg: wire.color?.isDefault ?? true, bg: wire.bgcolor?.isDefault ?? true }, base);
86
+ const { style: base, wire, slots, pair } = look;
87
+ const { terminal } = pair;
88
+ const from = { fg: pair.fg, bg: pair.bg };
85
89
  // [LAW:one-source-of-truth] A run is cells the wire writes alike: each
86
90
  // cell is drawn at the output depth first and merged on the colours that
87
91
  // writes, so two truecolors landing on one index stay one run. A cell
88
92
  // the effect gave its neighbour's colours is drawn as that neighbour was.
89
93
  const runs = [];
94
+ // Cells into the segment, as `base.shiftedBy` counts them below.
95
+ let into = 0;
90
96
  for (const glyph of graphemes(segment.text)) {
91
97
  let colors = slots;
92
98
  if (glyph === "\n") {
@@ -94,19 +100,21 @@ export class Effected {
94
100
  col = 0;
95
101
  }
96
102
  else {
103
+ const anchor = base.anchor && shiftAnchor(base.anchor, into);
97
104
  // Back from the screen to the style's slots: the same swap undoes itself.
98
- colors = onScreen(this.effect(from, { row, col, seed: this.seed(row, col), terminal }, this.t), base);
105
+ colors = onScreen(this.effect(from, { row, col, seed: this.seed(row, col), terminal, anchor }, this.t, drawn), base);
99
106
  col += cellLen(glyph);
100
107
  }
108
+ into += cellLen(glyph);
101
109
  const last = runs[runs.length - 1];
102
- const drawn = last !== undefined && sameColors(last.colors, colors) ? last.drawn : this.respec(wire, slots, colors, depth);
103
- if (last !== undefined && writesAlike(last.drawn, drawn)) {
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)) {
104
112
  last.text += glyph;
105
113
  last.cells += cellLen(glyph);
106
114
  last.colors = colors;
107
115
  }
108
116
  else {
109
- runs.push({ text: glyph, cells: cellLen(glyph), colors, drawn });
117
+ runs.push({ text: glyph, cells: cellLen(glyph), colors, drawn: writes });
110
118
  }
111
119
  }
112
120
  if (runs.every((run) => writesAlike(run.drawn, wire))) {
@@ -153,6 +161,49 @@ export class Effected {
153
161
  }
154
162
  }
155
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
+ }
156
207
  /**
157
208
  * A style's colour slots as the screen shows them, or the reverse: `reverse`
158
209
  * swaps glyph and ground, and swapping twice is where you started.
@@ -36,14 +36,14 @@
36
36
  * [LAW:no-ambient-temporal-coupling] No curve reads a clock; `t` is the
37
37
  * effect's argument, so the frame owner decides when to sample.
38
38
  *
39
- * Every import below names something the package exports, but for `noise`,
40
- * which the docs' effects playground page supplies itself: each of its
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
41
  * cards runs this file's own declarations as the program a visitor edits
42
42
  * (`examples/effects-playground/programs.ts`), on the published library.
43
43
  */
44
44
  import { ColorRgba } from "../core/color.js";
45
45
  import { type Ease } from "../core/easing.js";
46
- import type { CellColors, Effect, EffectCell } from "./effect.js";
46
+ import type { Drawn, Effect, EffectCell, Pair } from "./effect.js";
47
47
  /** What a loop does to one colour at strength `w` in [0, 1]: at 0, nothing. */
48
48
  export type Touch = (color: ColorRgba, w: number) => ColorRgba;
49
49
  /** How strongly a loop acts on a cell at a moment, in [0, 1]; `onColors` holds a field outside it to it. */
@@ -53,20 +53,13 @@ export interface Loop {
53
53
  readonly touch: Touch;
54
54
  readonly field: Field;
55
55
  }
56
- /**
57
- * A cell's ink and ground as the screen shows them, and which of the two are
58
- * the terminal's: what an `Effect` is handed for that cell.
59
- */
60
- export interface Pair extends CellColors {
61
- readonly terminal: EffectCell["terminal"];
62
- }
63
56
  /**
64
57
  * How much of `touch` each of `colors` (hex) can take: the largest share in
65
58
  * [0, 1] at which every pair in `pairs` that shows it, touched at the same
66
59
  * strength, still reads at its resting contrast or `LEGIBLE`, whichever is
67
60
  * lower. A colour in a pair but not in `colors` takes none, and so does one
68
61
  * the terminal supplies, whatever its hex, as in `onColors`. One of `colors`
69
- * no pair writes has nothing to be measured against, and throws.
62
+ * no pair writes has nothing to be measured against, and takes it all.
70
63
  *
71
64
  * Colours are settled lightest first, each against every partner: one
72
65
  * already settled at its share, one not yet settled as it is. So the lighter
@@ -77,14 +70,28 @@ export interface Pair extends CellColors {
77
70
  */
78
71
  export declare function shares(pairs: readonly Pair[], colors: ReadonlySet<string>, touch: Touch): Map<string, number>;
79
72
  /**
80
- * A loop as an `Effect` on a subject's own colours, by hex, wherever a cell
81
- * shows them, each touched at its share of the cell's strength. A powerline
82
- * strip's fill is the ground of its cells and the ink of the seam glyphs
83
- * between them, so the arrow moves with the cell it points out of. A colour
84
- * the terminal supplies (`EffectCell.terminal`) is not the subject's, whatever
85
- * its hex, and takes no share.
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.
86
93
  */
87
- export declare function onColors(share: ReadonlyMap<string, number>, loop: Loop): Effect;
94
+ export declare function atShares(loop: Loop, share: (drawn: Drawn) => ReadonlyMap<string, number>): Effect;
88
95
  /** How the curves below are tuned: one period (or duration), ease and swing. */
89
96
  export interface Curve {
90
97
  /** Seconds: a looping effect's period, a transition's duration. */
@@ -247,6 +254,7 @@ export declare function dissolveOut(curve: Curve, start: number, z: number, grou
247
254
  * its own. A segment's stray is slow noise of its own, at `STRAY_PERIODS`,
248
255
  * so no two keep step, and the row never shows one spread of hues twice.
249
256
  * Lightness and chroma are kept, so the words read as they did; the turn
250
- * only happens to `colors`, the element's own, never the terminal's.
257
+ * only happens to the element's own colours (`Drawn.colors`), never the
258
+ * terminal's.
251
259
  */
252
- export declare function wheel(curve: Curve, colors: ReadonlySet<string>, fills: ReadonlySet<string>, z: number): Effect;
260
+ export declare function wheel(curve: Curve, z: number): Effect;
@@ -36,14 +36,15 @@
36
36
  * [LAW:no-ambient-temporal-coupling] No curve reads a clock; `t` is the
37
37
  * effect's argument, so the frame owner decides when to sample.
38
38
  *
39
- * Every import below names something the package exports, but for `noise`,
40
- * which the docs' effects playground page supplies itself: each of its
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
41
  * cards runs this file's own declarations as the program a visitor edits
42
42
  * (`examples/effects-playground/programs.ts`), on the published library.
43
43
  */
44
- import { ColorRgba, contrastRatio } from "../core/color.js";
44
+ import { ColorRgba, ColorSpec, contrastRatio } from "../core/color.js";
45
45
  import { EASES, Phase } from "../core/easing.js";
46
46
  import { fbm, hash, noise, smoothstep } from "../core/noise.js";
47
+ import { Memo } from "../core/memo.js";
47
48
  import { IDENTITY, Oklch } from "../core/oklch.js";
48
49
  /** The contrast no touch takes a cell below, unless it rested below it: WCAG AA for body text. */
49
50
  const LEGIBLE = 4.5;
@@ -64,7 +65,7 @@ const ROUNDING = 0.01;
64
65
  * strength, still reads at its resting contrast or `LEGIBLE`, whichever is
65
66
  * lower. A colour in a pair but not in `colors` takes none, and so does one
66
67
  * the terminal supplies, whatever its hex, as in `onColors`. One of `colors`
67
- * no pair writes has nothing to be measured against, and throws.
68
+ * no pair writes has nothing to be measured against, and takes it all.
68
69
  *
69
70
  * Colours are settled lightest first, each against every partner: one
70
71
  * already settled at its share, one not yet settled as it is. So the lighter
@@ -81,12 +82,12 @@ export function shares(pairs, colors, touch) {
81
82
  { color: bg, own: terminal.bg ? undefined : bg.hex },
82
83
  ]);
83
84
  const rgba = new Map(sides.flat().flatMap(({ color, own }) => (own === undefined ? [] : [[own, color]])));
84
- const unpaired = [...colors].filter((hex) => !rgba.has(hex));
85
- if (unpaired.length > 0)
86
- throw new RangeError(`shares: ${unpaired.join(", ")} written in no pair, so nothing to keep legible against`);
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]));
87
89
  const lightness = (hex) => Oklch.fromRgba(rgba.get(hex)).l;
88
- const settled = new Map();
89
- for (const hex of [...colors].sort((a, b) => lightness(b) - lightness(a))) {
90
+ for (const hex of [...colors].filter((hex) => rgba.has(hex)).sort((a, b) => lightness(b) - lightness(a))) {
90
91
  const partners = sides.flatMap(([fg, bg]) => {
91
92
  const partner = fg.own === hex ? bg : bg.own === hex ? fg : undefined;
92
93
  return partner === undefined ? [] : [[partner.color, partner.own === undefined ? 0 : (settled.get(partner.own) ?? 0)]];
@@ -106,17 +107,46 @@ export function shares(pairs, colors, touch) {
106
107
  return settled;
107
108
  }
108
109
  /**
109
- * A loop as an `Effect` on a subject's own colours, by hex, wherever a cell
110
- * shows them, each touched at its share of the cell's strength. A powerline
111
- * strip's fill is the ground of its cells and the ink of the seam glyphs
112
- * between them, so the arrow moves with the cell it points out of. A colour
113
- * the terminal supplies (`EffectCell.terminal`) is not the subject's, whatever
114
- * its hex, and takes no share.
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.
115
141
  */
116
- export function onColors(share, loop) {
117
- return ({ fg, bg }, cell, t) => {
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) };
118
148
  const w = Math.round(clamp01(loop.field(cell, t)) * LEVELS) / LEVELS;
119
- const moved = (color, terminal) => touchedAt(loop.touch, color, (terminal ? 0 : (share.get(color.hex) ?? 0)) * w);
149
+ const moved = (color, terminal) => touchedAt(loop.touch, color, (terminal ? 0 : (frame.share.get(color.hex) ?? 0)) * w);
120
150
  return { fg: moved(fg, cell.terminal.fg), bg: moved(bg, cell.terminal.bg) };
121
151
  };
122
152
  }
@@ -541,9 +571,10 @@ const STRAY = 180;
541
571
  * its own. A segment's stray is slow noise of its own, at `STRAY_PERIODS`,
542
572
  * so no two keep step, and the row never shows one spread of hues twice.
543
573
  * Lightness and chroma are kept, so the words read as they did; the turn
544
- * only happens to `colors`, the element's own, never the terminal's.
574
+ * only happens to the element's own colours (`Drawn.colors`), never the
575
+ * terminal's.
545
576
  */
546
- export function wheel(curve, colors, fills, z) {
577
+ export function wheel(curve, z) {
547
578
  requireCurve(curve);
548
579
  const P = curve.seconds;
549
580
  // A segment's seed, from the colour it is.
@@ -555,11 +586,11 @@ export function wheel(curve, colors, fills, z) {
555
586
  const s = seed(hex);
556
587
  return (360 * t) / P + curve.swing * STRAY * ((stray(s, t) - stray(s, 0)) / 1.5);
557
588
  };
558
- return (drawn, cell, t) => {
589
+ return (shown, cell, t, { colors, fills }) => {
559
590
  // Whether a slot shows one of the element's colours: never the terminal's, whatever its hex.
560
- const own = { fg: !cell.terminal.fg && colors.has(drawn.fg.hex), bg: !cell.terminal.bg && colors.has(drawn.bg.hex) };
591
+ const own = { fg: !cell.terminal.fg && colors.has(shown.fg.hex), bg: !cell.terminal.bg && colors.has(shown.bg.hex) };
561
592
  // An own colour turns with its segment: itself if a fill or beside no own colour, else the one beside it.
562
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();
563
- return { fg: turn(drawn.fg, own.fg, drawn.bg, own.bg), bg: turn(drawn.bg, own.bg, drawn.fg, own.fg) };
594
+ return { fg: turn(shown.fg, own.fg, shown.bg, own.bg), bg: turn(shown.bg, own.bg, shown.fg, own.fg) };
564
595
  };
565
596
  }
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { Segment } from "../core/segment.js";
19
19
  import type { Renderable, Measurable, RenderOptions } from "../core/protocol.js";
20
- import type { Joiner, StyledRenderable } from "../core/strip.js";
20
+ import { type Joiner, type StyledRenderable } from "../core/strip.js";
21
21
  export type FlexAlign = "left" | "center" | "right" | "justify";
22
22
  export interface FlexStripOptions<T extends StyledRenderable> {
23
23
  /** Joiner used between items within a line, and at line endpoints. */