@promptctl/rich-js 0.20.0 → 0.22.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_CATALOGUE, EFFECT_CURVES, EFFECT_LIGHTS, dissolveOut, fadeIn, onColors, pulse, shimmer, sparkle, wheel, } from "./renderables/effects.js";
61
+ export type { Curve, EffectTarget, Field, Loop, LoopName, Touch, Transition, TransitionName } from "./renderables/effects.js";
62
62
  export { Align } from "./renderables/align.js";
63
63
  export type { Alignment } from "./core/place.js";
64
64
  export { Padding } from "./renderables/padding.js";
package/dist/index.js CHANGED
@@ -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_CATALOGUE, EFFECT_CURVES, EFFECT_LIGHTS, dissolveOut, fadeIn, onColors, pulse, shimmer, sparkle, wheel, } from "./renderables/effects.js";
74
74
  export { Align } from "./renderables/align.js";
75
75
  export { Padding } from "./renderables/padding.js";
76
76
  export { Viewport, ViewportPerKey, SCROLLBAR } from "./renderables/viewport.js";
@@ -2,11 +2,21 @@
2
2
  * Effected — a renderable whose finished cells' colours pass through an
3
3
  * effect at a moment `t`.
4
4
  *
5
- * An effect is one pure function: a cell's colours, where the cell is, 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 in two stages: a time and what the element
6
+ * draws in that frame in, a function of the frame out; and into that, a
7
+ * cell's colours and where the cell is, the cell's new colours out. Whatever
8
+ * holds for the whole frame is worked out in the first stage, once, and every
9
+ * cell of the frame is handed to the second. Shimmer, pulse, fade and
10
+ * dissolve are all this one shape, differing by what the function does with
11
+ * its inputs [LAW:one-type-per-behavior]; two effects stack by nesting one
12
+ * wrapper in another, which is the same as calling one after the other but
13
+ * for what the outer one measures: the inner one's output.
14
+ *
15
+ * What the element draws (`Drawn`) is measured here, in the render, off the
16
+ * same reading of each segment its cells are drawn from, and handed to the
17
+ * effect's first stage, once a frame. It is what an effect keeping words legible is
18
+ * settled against (`onColors`, `./effects.ts`), so no caller derives it a
19
+ * second time from a render of its own.
10
20
  *
11
21
  * [LAW:no-ambient-temporal-coupling] `t` is a constructor argument, not a
12
22
  * clock read. Whoever draws frames builds the view for each frame's `t`, so
@@ -33,7 +43,8 @@
33
43
  * Positions are the child's own: row and column count from the top-left of
34
44
  * what the child drew, so a sweep crosses the element, not the screen.
35
45
  */
36
- import { type ColorRgba, type TerminalTheme } from "../core/color.js";
46
+ import { type Anchor } from "../core/anchor.js";
47
+ import { ColorDepth, type ColorRgba, type TerminalTheme } from "../core/color.js";
37
48
  import type { Measurable, Renderable, RenderOptions } from "../core/protocol.js";
38
49
  import { Segment } from "../core/segment.js";
39
50
  /**
@@ -65,9 +76,55 @@ export interface EffectCell {
65
76
  readonly fg: boolean;
66
77
  readonly bg: boolean;
67
78
  };
79
+ /**
80
+ * Who drew the cell, and where it sits in their output, as
81
+ * `Segment.anchorAt` reads it off a frame: what tells one owner's cells
82
+ * from another's when their colours are alike (`confined`).
83
+ */
84
+ readonly anchor: Anchor | undefined;
85
+ }
86
+ /**
87
+ * A cell's ink and ground as the screen shows them, which of the two are the
88
+ * terminal's, and whether its style swaps them (`reverse`): what an `Effect`
89
+ * is handed for that cell.
90
+ */
91
+ export interface Pair extends CellColors {
92
+ readonly terminal: EffectCell["terminal"];
93
+ /**
94
+ * Whether the ink is written in the style's ground slot and the ground in
95
+ * its ink slot. Below truecolor the slot decides what a moved colour can
96
+ * land on: a slot set to `default` draws the terminal's foreground in one
97
+ * and its background in the other.
98
+ */
99
+ readonly reverse: boolean;
100
+ }
101
+ /**
102
+ * The colours the element an effect wraps draws its words in, in one frame,
103
+ * as the screen shows them: what an effect that must keep the words legible
104
+ * is measured against. A word cell is one whose glyph is not blank and that a
105
+ * strip's joiner did not draw (`JOIN`): a seam's ink is the fill beside it,
106
+ * not words.
107
+ */
108
+ export interface Drawn {
109
+ /** Every distinct ink and ground of a word cell. */
110
+ readonly pairs: readonly Pair[];
111
+ /** The colours the element sets itself in its word cells, by hex: never the terminal's, whatever its hex. */
112
+ readonly colors: ReadonlySet<string>;
113
+ /** Of `colors`, the ones a word cell has for its ground. */
114
+ readonly fills: ReadonlySet<string>;
115
+ /** The depth the frame is written at: below truecolor a moved colour lands on a palette colour. */
116
+ readonly depth: ColorDepth;
117
+ /** The terminal the frame is drawn on. */
118
+ readonly theme: TerminalTheme;
68
119
  }
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;
120
+ /**
121
+ * The frame at time `t`, in seconds, whose element draws `drawn`: each cell's
122
+ * colours in that frame. Return `colors` to leave a cell be.
123
+ * [LAW:types-are-the-program] Per-frame work is the outer stage, so it runs
124
+ * once a frame by the shape of the call, never by a cache asking whether this
125
+ * cell's frame is the last one's.
126
+ */
127
+ export type Effect = (t: number, drawn: Drawn) => (colors: CellColors, cell: EffectCell) => CellColors;
71
128
  export interface EffectedOptions {
72
129
  /** The moment to draw, in seconds. */
73
130
  readonly t: number;
@@ -95,19 +152,35 @@ export declare class Effected implements Renderable, Measurable {
95
152
  minimum: number;
96
153
  maximum: number;
97
154
  };
155
+ /**
156
+ * The colours the screen shows for `pair` once it is drawn moved to `to`,
157
+ * both as the screen shows them, at `depth` on `theme`: what an effect
158
+ * moving a pair's colours leaves its words reading in.
159
+ * [LAW:one-source-of-truth] Read through `movedSpecs`, the one writer a
160
+ * moved slot is written with.
161
+ */
162
+ static shows(pair: Pair, to: CellColors, depth: ColorDepth, theme: TerminalTheme): CellColors;
98
163
  /**
99
164
  * The specs that draw a cell's slot colours `to` where the segment drew
100
- * `from` with `wire`: a slot the effect left alone keeps its written spec,
101
- * and a moved one is laid on what is beneath it — the ground on
102
- * `SURFACE_BLACK`, the glyph on that ground, as the writer lays any
103
- * translucent colour (`Style.drawnColors`) — then drawn in the colour nearest it at `depth`
104
- * that the terminal shows (`ColorSpec.matchOn`). A move the depth cannot
105
- * show is no move: where the written spec is as near the moved colour as
106
- * that match, the slot keeps it, so below truecolor a colour moved a little
107
- * off any colour it can write — a theme slot, the default colour, the cube —
108
- * is still drawn as that one.
165
+ * `from` with `wire`: each moved slot in the spec `movedSpecs` writes it
166
+ * in, the rest as `wire` wrote them.
109
167
  */
110
168
  private respec;
111
169
  /** A cell's seed: FNV-1a of the key and the position, scaled into [0, 1). */
112
170
  private seed;
113
171
  }
172
+ /**
173
+ * `effect` on the colours `owner` drew, itself or through an owner nested in
174
+ * its output, and every other colour left as it is: one item of a strip whose
175
+ * items share a fill, one widget of a screen. A seam cell shows its two
176
+ * neighbours side by side, a powerline arrow in the fill it points out of
177
+ * over the fill it points into, so each side moves with its own item
178
+ * (`colorsOwnedBy`, `../core/strip.ts`).
179
+ */
180
+ export declare function confined(effect: Effect, owner: object): Effect;
181
+ /**
182
+ * What `segments`, drawn at `depth` on `theme`, draw their words in, measured
183
+ * as `Effected` measures its child for every frame. Measure an element's
184
+ * output under an effect to read the contrast its words are left at.
185
+ */
186
+ export declare function drawnOf(segments: Iterable<Segment>, depth: ColorDepth, theme: TerminalTheme): Drawn;
@@ -2,11 +2,21 @@
2
2
  * Effected — a renderable whose finished cells' colours pass through an
3
3
  * effect at a moment `t`.
4
4
  *
5
- * An effect is one pure function: a cell's colours, where the cell is, 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 in two stages: a time and what the element
6
+ * draws in that frame in, a function of the frame out; and into that, a
7
+ * cell's colours and where the cell is, the cell's new colours out. Whatever
8
+ * holds for the whole frame is worked out in the first stage, once, and every
9
+ * cell of the frame is handed to the second. Shimmer, pulse, fade and
10
+ * dissolve are all this one shape, differing by what the function does with
11
+ * its inputs [LAW:one-type-per-behavior]; two effects stack by nesting one
12
+ * wrapper in another, which is the same as calling one after the other but
13
+ * for what the outer one measures: the inner one's output.
14
+ *
15
+ * What the element draws (`Drawn`) is measured here, in the render, off the
16
+ * same reading of each segment its cells are drawn from, and handed to the
17
+ * effect's first stage, once a frame. It is what an effect keeping words legible is
18
+ * settled against (`onColors`, `./effects.ts`), so no caller derives it a
19
+ * second time from a render of its own.
10
20
  *
11
21
  * [LAW:no-ambient-temporal-coupling] `t` is a constructor argument, not a
12
22
  * clock read. Whoever draws frames builds the view for each frame's `t`, so
@@ -33,11 +43,13 @@
33
43
  * Positions are the child's own: row and column count from the top-left of
34
44
  * what the child drew, so a sweep crosses the element, not the screen.
35
45
  */
46
+ import { drawnBy, shiftAnchor } from "../core/anchor.js";
36
47
  import { cellLen, graphemes } from "../core/cells.js";
37
- import { ColorDepth, ColorSpec, SURFACE_BLACK, rgbDistance } from "../core/color.js";
48
+ import { ColorDepth, ColorSpec, SURFACE_BLACK } from "../core/color.js";
38
49
  import { fnv1a } from "../core/fnv1a.js";
39
50
  import { Measurement } from "../core/measure.js";
40
51
  import { Segment } from "../core/segment.js";
52
+ import { JOIN, colorsOwnedBy } from "../core/strip.js";
41
53
  import { Style } from "../core/style.js";
42
54
  export class Effected {
43
55
  renderable;
@@ -63,30 +75,27 @@ export class Effected {
63
75
  }
64
76
  // No depth named is no downgrade, as `drawnColors` reads it.
65
77
  const depth = options.colorSystem ?? ColorDepth.TRUECOLOR;
78
+ // [LAW:one-source-of-truth] The frame is measured before any cell is
79
+ // handed to the effect, off the same looks its cells are drawn from.
80
+ const looked = looks(segments, depth, this.theme);
81
+ const frame = this.effect(this.t, measured(looked, depth, this.theme));
66
82
  let row = 0;
67
83
  let col = 0;
68
- for (const segment of segments) {
69
- if (segment.isControl) {
84
+ for (const { segment, look } of looked) {
85
+ if (look === undefined) {
70
86
  yield segment;
71
87
  continue;
72
88
  }
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);
89
+ const { style: base, wire, slots, pair } = look;
90
+ const { terminal } = pair;
91
+ const from = { fg: pair.fg, bg: pair.bg };
85
92
  // [LAW:one-source-of-truth] A run is cells the wire writes alike: each
86
93
  // cell is drawn at the output depth first and merged on the colours that
87
94
  // writes, so two truecolors landing on one index stay one run. A cell
88
95
  // the effect gave its neighbour's colours is drawn as that neighbour was.
89
96
  const runs = [];
97
+ // Cells into the segment, as `base.shiftedBy` counts them below.
98
+ let into = 0;
90
99
  for (const glyph of graphemes(segment.text)) {
91
100
  let colors = slots;
92
101
  if (glyph === "\n") {
@@ -94,19 +103,21 @@ export class Effected {
94
103
  col = 0;
95
104
  }
96
105
  else {
106
+ const anchor = base.anchor && shiftAnchor(base.anchor, into);
97
107
  // 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);
108
+ colors = onScreen(frame(from, { row, col, seed: this.seed(row, col), terminal, anchor }), pair.reverse);
99
109
  col += cellLen(glyph);
100
110
  }
111
+ into += cellLen(glyph);
101
112
  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)) {
113
+ const writes = last !== undefined && sameColors(last.colors, colors) ? last.drawn : this.respec(wire, slots, colors, depth);
114
+ if (last !== undefined && writesAlike(last.drawn, writes)) {
104
115
  last.text += glyph;
105
116
  last.cells += cellLen(glyph);
106
117
  last.colors = colors;
107
118
  }
108
119
  else {
109
- runs.push({ text: glyph, cells: cellLen(glyph), colors, drawn });
120
+ runs.push({ text: glyph, cells: cellLen(glyph), colors, drawn: writes });
110
121
  }
111
122
  }
112
123
  if (runs.every((run) => writesAlike(run.drawn, wire))) {
@@ -125,27 +136,26 @@ export class Effected {
125
136
  measure(options) {
126
137
  return Measurement.get(options, this.renderable);
127
138
  }
139
+ /**
140
+ * The colours the screen shows for `pair` once it is drawn moved to `to`,
141
+ * both as the screen shows them, at `depth` on `theme`: what an effect
142
+ * moving a pair's colours leaves its words reading in.
143
+ * [LAW:one-source-of-truth] Read through `movedSpecs`, the one writer a
144
+ * moved slot is written with.
145
+ */
146
+ static shows(pair, to, depth, theme) {
147
+ const from = onScreen(pair, pair.reverse);
148
+ const moved = movedSpecs(from, onScreen(to, pair.reverse), depth, theme);
149
+ return onScreen({ fg: moved.fg?.getTruecolor(theme, true) ?? from.fg, bg: moved.bg?.getTruecolor(theme, false) ?? from.bg }, pair.reverse);
150
+ }
128
151
  /**
129
152
  * The specs that draw a cell's slot colours `to` where the segment drew
130
- * `from` with `wire`: a slot the effect left alone keeps its written spec,
131
- * and a moved one is laid on what is beneath it — the ground on
132
- * `SURFACE_BLACK`, the glyph on that ground, as the writer lays any
133
- * translucent colour (`Style.drawnColors`) — then drawn in the colour nearest it at `depth`
134
- * that the terminal shows (`ColorSpec.matchOn`). A move the depth cannot
135
- * show is no move: where the written spec is as near the moved colour as
136
- * that match, the slot keeps it, so below truecolor a colour moved a little
137
- * off any colour it can write — a theme slot, the default colour, the cube —
138
- * is still drawn as that one.
153
+ * `from` with `wire`: each moved slot in the spec `movedSpecs` writes it
154
+ * in, the rest as `wire` wrote them.
139
155
  */
140
156
  respec(wire, from, to, depth) {
141
- // [LAW:one-source-of-truth] The writer's surface, so one translucent colour draws one way in a frame.
142
- const ground = to.bg.compositeOver(SURFACE_BLACK);
143
- const glyph = to.fg.compositeOver(ground);
144
- const drawn = (spec, was, now, foreground) => {
145
- const match = ColorSpec.matchOn(now, depth, this.theme, foreground);
146
- return rgbDistance(was, now) <= rgbDistance(match.getTruecolor(this.theme, foreground), now) ? spec : match;
147
- };
148
- return { color: drawn(wire.color, from.fg, glyph, true), bgcolor: drawn(wire.bgcolor, from.bg, ground, false) };
157
+ const moved = movedSpecs(from, to, depth, this.theme);
158
+ return { color: moved.fg ?? wire.color, bgcolor: moved.bg ?? wire.bgcolor };
149
159
  }
150
160
  /** A cell's seed: FNV-1a of the key and the position, scaled into [0, 1). */
151
161
  seed(row, col) {
@@ -153,12 +163,73 @@ export class Effected {
153
163
  }
154
164
  }
155
165
  const DEFAULT = ColorSpec.default();
166
+ /** Each of `segments` with how it is drawn at `depth` on `theme`; a control segment draws nothing. */
167
+ function looks(segments, depth, theme) {
168
+ return [...segments].map((segment) => {
169
+ if (segment.isControl)
170
+ return { segment, look: undefined };
171
+ const style = segment.style ?? Style.null();
172
+ const wire = style.drawnColors(depth);
173
+ const slots = { fg: (wire.color ?? DEFAULT).getTruecolor(theme, true), bg: (wire.bgcolor ?? DEFAULT).getTruecolor(theme, false) };
174
+ const reverse = style.reverse === true;
175
+ const terminal = onScreen({ fg: wire.color?.isDefault ?? true, bg: wire.bgcolor?.isDefault ?? true }, reverse);
176
+ return { segment, look: { style, wire, slots, pair: { ...onScreen(slots, reverse), terminal, reverse } } };
177
+ });
178
+ }
179
+ /** What `looked` draws its words in: `Drawn`'s measure, taken once per frame. */
180
+ function measured(looked, depth, theme) {
181
+ const words = looked.flatMap(({ segment, look }) => (look !== undefined && segment.text.trim() !== "" && !drawnBy(look.style.anchor, JOIN) ? [look.pair] : []));
182
+ const pairs = [...new Map(words.map((pair) => [`${pair.fg.hex}/${pair.bg.hex}/${pair.terminal.fg}/${pair.terminal.bg}/${pair.reverse}`, pair])).values()];
183
+ // [LAW:one-source-of-truth] A slot the terminal fills is no colour of the element's, whatever its hex.
184
+ const own = (slot) => pairs.flatMap((pair) => (pair.terminal[slot] ? [] : [pair[slot].hex]));
185
+ return { pairs, colors: new Set([...own("fg"), ...own("bg")]), fills: new Set(own("bg")), depth, theme };
186
+ }
187
+ /**
188
+ * `effect` on the colours `owner` drew, itself or through an owner nested in
189
+ * its output, and every other colour left as it is: one item of a strip whose
190
+ * items share a fill, one widget of a screen. A seam cell shows its two
191
+ * neighbours side by side, a powerline arrow in the fill it points out of
192
+ * over the fill it points into, so each side moves with its own item
193
+ * (`colorsOwnedBy`, `../core/strip.ts`).
194
+ */
195
+ export function confined(effect, owner) {
196
+ return (t, drawn) => {
197
+ const frame = effect(t, drawn);
198
+ return (colors, cell) => {
199
+ const owned = colorsOwnedBy(cell.anchor, owner);
200
+ const moved = frame(colors, cell);
201
+ return { fg: owned.fg ? moved.fg : colors.fg, bg: owned.bg ? moved.bg : colors.bg };
202
+ };
203
+ };
204
+ }
205
+ /**
206
+ * What `segments`, drawn at `depth` on `theme`, draw their words in, measured
207
+ * as `Effected` measures its child for every frame. Measure an element's
208
+ * output under an effect to read the contrast its words are left at.
209
+ */
210
+ export function drawnOf(segments, depth, theme) {
211
+ return measured(looks(segments, depth, theme), depth, theme);
212
+ }
156
213
  /**
157
214
  * A style's colour slots as the screen shows them, or the reverse: `reverse`
158
215
  * swaps glyph and ground, and swapping twice is where you started.
159
216
  */
160
- function onScreen(slots, style) {
161
- return style.reverse === true ? { fg: slots.bg, bg: slots.fg } : slots;
217
+ function onScreen(slots, reverse) {
218
+ return reverse ? { fg: slots.bg, bg: slots.fg } : slots;
219
+ }
220
+ /**
221
+ * The specs a cell's slots are written in once their colours move from
222
+ * `from`, what the written specs draw, to `to`: each laid on what is beneath
223
+ * it — the ground on `SURFACE_BLACK`, the glyph on that ground, as the writer
224
+ * lays any translucent colour (`Style.drawnColors`) — then matched at `depth`
225
+ * (`ColorSpec.matchMove`). A slot the depth shows no move in has none, and
226
+ * keeps the spec it was written in.
227
+ */
228
+ function movedSpecs(from, to, depth, theme) {
229
+ // [LAW:one-source-of-truth] The writer's surface, so one translucent colour draws one way in a frame.
230
+ const ground = to.bg.compositeOver(SURFACE_BLACK);
231
+ const glyph = to.fg.compositeOver(ground);
232
+ return { fg: ColorSpec.matchMove(from.fg, glyph, depth, theme, true), bg: ColorSpec.matchMove(from.bg, ground, depth, theme, false) };
162
233
  }
163
234
  /**
164
235
  * Whether two cells' colours are written alike. A slot with no colour is not