@motionscript/code 0.1.0-alpha.0 → 0.1.0-alpha.10

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 (65) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/NOTICE +49 -0
  3. package/README.md +148 -50
  4. package/dist/browser/THIRD_PARTY_LICENSES.txt +431 -0
  5. package/dist/browser/index.js +10 -7
  6. package/dist/browser/index.js.map +3 -3
  7. package/dist/browser/manifest.json +85 -3
  8. package/dist/code-range.d.ts +7 -1
  9. package/dist/code-range.d.ts.map +1 -1
  10. package/dist/code-range.js +9 -1
  11. package/dist/code-range.js.map +1 -1
  12. package/dist/diff.d.ts +14 -0
  13. package/dist/diff.d.ts.map +1 -1
  14. package/dist/diff.js +134 -2
  15. package/dist/diff.js.map +1 -1
  16. package/dist/engine.d.ts +2 -2
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +4 -4
  19. package/dist/engine.js.map +1 -1
  20. package/dist/highlight.d.ts +10 -2
  21. package/dist/highlight.d.ts.map +1 -1
  22. package/dist/highlight.js +40 -6
  23. package/dist/highlight.js.map +1 -1
  24. package/dist/layout.d.ts +1 -1
  25. package/dist/layout.d.ts.map +1 -1
  26. package/dist/layout.js.map +1 -1
  27. package/dist/measure-cache.d.ts +2 -2
  28. package/dist/measure-cache.d.ts.map +1 -1
  29. package/dist/measure-cache.js +0 -0
  30. package/dist/measure-cache.js.map +1 -1
  31. package/dist/node.d.ts +275 -281
  32. package/dist/node.d.ts.map +1 -1
  33. package/dist/node.js +634 -700
  34. package/dist/node.js.map +1 -1
  35. package/dist/nodes.d.ts +6 -9
  36. package/dist/nodes.d.ts.map +1 -1
  37. package/dist/nodes.js +7 -9
  38. package/dist/nodes.js.map +1 -1
  39. package/dist/render.d.ts +10 -3
  40. package/dist/render.d.ts.map +1 -1
  41. package/dist/render.js +60 -1
  42. package/dist/render.js.map +1 -1
  43. package/dist/style.d.ts +3 -3
  44. package/dist/style.d.ts.map +1 -1
  45. package/dist/style.js +10 -6
  46. package/dist/style.js.map +1 -1
  47. package/dist/tokens.d.ts.map +1 -1
  48. package/dist/tokens.js +5 -1
  49. package/dist/tokens.js.map +1 -1
  50. package/dist/transitions.d.ts +19 -1
  51. package/dist/transitions.d.ts.map +1 -1
  52. package/dist/transitions.js.map +1 -1
  53. package/package.json +7 -4
  54. package/src/code-range.ts +10 -1
  55. package/src/diff.ts +137 -2
  56. package/src/engine.ts +5 -5
  57. package/src/highlight.ts +54 -5
  58. package/src/layout.ts +1 -1
  59. package/src/measure-cache.ts +0 -0
  60. package/src/node.ts +767 -747
  61. package/src/nodes.ts +8 -10
  62. package/src/render.ts +71 -3
  63. package/src/style.ts +11 -7
  64. package/src/tokens.ts +5 -1
  65. package/src/transitions.ts +21 -1
package/src/node.ts CHANGED
@@ -1,71 +1,61 @@
1
1
  import {
2
- node,
3
- RenderContext2D, RenderPass2D, Clip, EasingFunction, NodeConfig, Size2D, SizeConstraints,
4
- Node2D, Node2DProps, MeasureContext2D, InsetsResolved, InsetsProps,
5
- property, insetsOps, lerpNumber,
6
- command, driveCommand, type AssetScope, type Command, type CommandArgs, type Node, type TweenStepper, type Vector2,
7
- } from "@motionscript/core";
2
+ Graphics2D,
3
+ Paint,
4
+ Types,
5
+ component2d,
6
+ lerpNumber,
7
+ type InsetsResolved,
8
+ type MeasureContext2D,
9
+ type Measurer,
10
+ type NodeOf,
11
+ type NormalizedColor,
12
+ type Piece,
13
+ type RenderContext,
14
+ type Size2D,
15
+ // Named so the emitted declaration reaches these through the sdk, not core.
16
+ type Color as _Color,
17
+ type Node2D as _Node2D,
18
+ type Node2DProps as _Node2DProps,
19
+ } from "@motionscript/sdk";
8
20
  import {
9
- CodeRange,
10
- rangeToCharOffsets,
11
- charOffsetsToRange,
21
+ DefaultHighlightStyle,
12
22
  TokenAdvanceCache,
13
- IdLine,
14
- tokenizeCodeToIdLines,
23
+ canHighlight,
24
+ charOffsetsToRange,
15
25
  defaultCodeDiff,
16
- type CodeDiffStrategy,
17
- CodeLayout,
18
- CodeMetrics,
26
+ defaultCodePhases,
27
+ drawCode,
28
+ ensureHighlighter,
19
29
  layoutCode,
20
- metricsSignature,
21
- AnimToken,
22
- CodeTransition,
23
- StructuralTransition,
24
30
  makeAnim,
25
- defaultCodePhases,
26
- type CodePhaseStrategy,
27
- resolveTokenStates,
31
+ metricsSignature,
28
32
  moveProgress,
29
- canHighlight,
30
- ensureHighlighter,
31
- CodeTheme,
32
- DefaultHighlightStyle,
33
- drawCode,
33
+ normalizeSource,
34
+ rangeToCharOffsets,
35
+ resolveTokenStates,
36
+ tokenizeCodeToIdLines,
37
+ type AnimToken,
38
+ type CodeDiffStrategy,
39
+ type CodeLayout,
40
+ type CodeMetrics,
41
+ type CodePhaseStrategy,
42
+ type CodeRange,
43
+ type CodeTheme,
44
+ type DimTransition,
45
+ type IdLine,
46
+ type RowBand,
47
+ type RowsTransition,
48
+ type StructuralTransition,
34
49
  } from "./engine";
35
50
 
36
- /**
37
- * A listing and nothing else — no paint slots, and no box behind the tokens.
38
- *
39
- * It did carry a `fill`/`overlay`/`stroke`/`shadow` set and the corner radius
40
- * that shaped them, so a block could draw its own panel. That was one node doing
41
- * two jobs, and the second one is a `Rect`'s: a listing set on a card is a `Code`
42
- * inside a hugging `Rect`, which tracks the block's size for the same reason
43
- * every other hugging container tracks its child's. What is left here is the
44
- * listing — its source, how it is coloured, the face it is set in, and the space
45
- * it keeps around itself.
46
- */
47
- export interface CodeProps extends Node2DProps {
48
- code: string;
49
- language: string;
50
- fontSize: number;
51
- fontFamily: string;
52
- /** A built-in/registered theme name (e.g. `'github-dark'`), or a {@link CodeHighlightStyle} object. */
53
- theme: CodeTheme;
54
- lineHeight: number;
55
- letterSpacing: number;
56
- showLineNumbers: boolean;
57
- lineNumberGap: number;
58
- padding: InsetsProps;
59
- }
51
+ /** A row band's paint and corner radius when `highlightRows` is given neither. */
52
+ const ROW_BAND_COLOR = "rgba(255, 255, 255, 0.12)";
53
+ const ROW_BAND_RADIUS = 6;
60
54
 
61
55
  /**
62
- * Construction-time-only options: pluggable engine behavior, set once when
63
- * the node is created. Deliberately *not* part of {@link CodeProps} — every
64
- * `@property` field accepts either a value or a zero-arg reactive binding
65
- * (`value | (() => value)`, see `PropInputs`), and the node's own reactive
66
- * write path (`_writeProp`) treats any function value as the latter. A
67
- * strategy *is* a function, so it can't be told apart from a binding that
68
- * computes one — it has to stay outside the reactive prop system entirely.
56
+ * Which token became which across an edit, and when each part of it happens.
57
+ * Arguments of {@link defineCode}: a strategy is a function, which a prop would
58
+ * take for a binding, so it is part of the component's definition instead.
69
59
  */
70
60
  export interface CodeStrategies {
71
61
  /** Decides which token became which across an edit. Defaults to `defaultCodeDiff`. */
@@ -74,748 +64,623 @@ export interface CodeStrategies {
74
64
  phaseStrategy?: CodePhaseStrategy;
75
65
  }
76
66
 
77
- @node({
78
- key: "code",
79
- parentKey: "node",
80
- forkable: true,
81
- layout: {
82
- defaultWidthMode: "hug",
83
- defaultHeightMode: "hug",
84
- acceptsChildren: false,
85
- },
86
- })
87
- export class Code extends Node2D<CodeProps> {
88
-
89
- @property({ default: "" }) declare readonly code: string;
90
- @property({ default: "typescript" }) declare readonly language: string;
91
- @property({ default: "Fira Mono" }) declare readonly fontFamily: string;
92
- @property({ default: DefaultHighlightStyle.name }) declare readonly theme: CodeTheme;
93
- @property({ default: 16 }) declare readonly fontSize: number;
94
- @property({ default: 1.6 }) declare readonly lineHeight: number;
95
- // Extra horizontal space added after every glyph (in px), like CSS
96
- // letter-spacing. Folded into the advance a token is measured at, so
97
- // measurement and drawing stay in lockstep.
98
- @property({ default: 1.1 }) declare readonly letterSpacing: number;
99
- @property({ default: false }) declare readonly showLineNumbers: boolean;
100
- // Horizontal gap between the line-number column and the code text, expressed
101
- // in space-widths (so it scales with fontSize). Only applies when
102
- // showLineNumbers is on.
103
- @property({ default: 2 }) declare readonly lineNumberGap: number;
104
- @property({ default: 0, mapper: insetsOps.resolve, tween: insetsOps.lerp }) declare readonly padding: InsetsResolved;
105
-
106
- /** The settled structure. During an edit, the structure the edit lands on. */
107
- private tokenLines: IdLine[] = [];
108
- private tokenized: boolean = false;
109
-
110
- private transitions: CodeTransition[] = [];
111
-
112
- // Caches expensive scope.measureText() calls; cleared when the font
113
- // signature (fontSize|fontFamily) changes. See TokenAdvanceCache.
114
- private advanceCache = new TokenAdvanceCache();
115
-
116
- // Layout cache for *settled* frames. Bumped whenever tokenLines is
117
- // reassigned, so a cache hit only ever happens on identical content. Frames
118
- // inside a structural edit are served from that edit's own two cached
119
- // layouts instead (see StructuralTransition).
120
- private structureVersion = 0;
121
- private layoutCacheKey: string | null = null;
122
- private layoutCache: CodeLayout | null = null;
123
-
124
- // Persistent dim state set by highlight() — applied during render to all
125
- // tokens whose id is NOT in the highlight set. null means "not highlighting".
126
- private highlightDimOpacity: number | null = null;
127
- private highlightedIds: Set<number> = new Set();
128
-
129
- /**
130
- * Which token became which across an edit, and when each part of that
131
- * edit happens. Construction-time-only (see {@link CodeStrategies}) —
132
- * resolved once here rather than left as `?? defaultX` at every call site.
133
- */
134
- private readonly diffStrategy: CodeDiffStrategy;
135
- private readonly phaseStrategy: CodePhaseStrategy;
136
-
137
- constructor(props: NodeConfig<Code, CodeProps> & CodeStrategies) {
138
- super(props);
139
- this.diffStrategy = props.diffStrategy ?? defaultCodeDiff;
140
- this.phaseStrategy = props.phaseStrategy ?? defaultCodePhases;
141
- this.applyProp("width", props.width ?? "hug");
142
- this.applyProp("height", props.height ?? "hug");
143
-
144
- // Best-effort, for a highlighter constructed outside the asset pipeline
145
- // entirely (a bare `new Code(...)` in a test or a headless measure). On
146
- // the timeline the authoritative load is the one `declareAssets` declares,
147
- // which `AssetManager.loadAt` awaits *before* this node is laid out — so
148
- // there the tokens are right the first time rather than upgrading later.
149
- // (Theme is a synchronous color style; only the language parser loads.)
150
- ensureHighlighter(undefined, [this.language]).catch(() => { });
151
- this.tokenize();
152
- }
67
+ /** `code` part-way through a `to({ code })`: the source it leaves, the one it lands on, and how far along it is, eased. */
68
+ export interface CodeBlend {
69
+ readonly from: string;
70
+ readonly to: string;
71
+ readonly t: number;
72
+ }
153
73
 
154
- set(props: { [K in keyof CodeProps]?: CodeProps[K] | (() => CodeProps[K]) }): void {
155
- super.set(props);
156
- if (props.code !== undefined || props.language !== undefined || props.theme !== undefined) {
157
- this.tokenized = false;
158
- }
159
- }
74
+ /** What `code` holds: a source, or a {@link CodeBlend} while a `to({ code })` runs. */
75
+ export type CodeSource = string | CodeBlend;
160
76
 
161
- private tokenize(): void {
162
- this.setLines(tokenizeCodeToIdLines(this.code, this.language, this.theme));
163
- // Only consider ourselves tokenized once we actually highlighted; while
164
- // the language is still loading we keep retrying on each render.
165
- this.tokenized = canHighlight(this.language, this.theme);
166
- }
77
+ /** A range argument: one range, several, or a chain that went through JSON as `{ list }`. */
78
+ export type CodeTarget = CodeRange | Iterable<CodeRange> | { readonly list: CodeRange[] };
167
79
 
168
- /**
169
- * Swap in a new settled structure, invalidating the layout cache.
170
- *
171
- * The one place `tokenLines` is written, because the cache key is a version
172
- * counter rather than a hash of the content — a write that skipped the bump
173
- * would keep serving the previous frame's geometry for the new listing.
174
- */
175
- private setLines(lines: IdLine[]): void {
176
- if (this.tokenLines === lines) return;
177
- this.tokenLines = lines;
178
- this.structureVersion++;
179
- this.layoutCache = null;
180
- this.layoutCacheKey = null;
181
- }
80
+ /** What a structure was tokenized with: the grammar, the theme and whether the grammar had landed. */
81
+ export interface CodeTokenBasis {
82
+ readonly language: string;
83
+ readonly theme: CodeTheme;
84
+ readonly highlighted: boolean;
85
+ }
182
86
 
183
- /**
184
- * The monospaced face this block measures and draws with, and the syntax
185
- * grammar it tokenizes with.
186
- *
187
- * Both belong to *layout*: token x positions are measured against the face,
188
- * and how many tokens there are depends on the grammar. Declared here, the
189
- * grammar goes on the timeline as an ordinary asset: `AssetManager.loadAt`
190
- * waits for it before the frame is laid out, so the first measurement is the
191
- * right one. Nothing is freed on eviction — parsers are cheap to keep
192
- * resident. The loader touches no node: `renderContent` re-tokenizes once
193
- * the grammar is there.
194
- */
195
- override declareAssets(assets: AssetScope): void {
196
- super.declareAssets(assets);
197
- assets.font(this.fontFamily);
198
- assets.loader(`shiki:lang:${this.language}`, grammarLoader(this.language), { gate: "layout" });
199
- }
87
+ /** A listing a command left behind: its source, its tokens, and the basis they were made on. */
88
+ export interface CodeListing {
89
+ readonly source: string;
90
+ readonly lines: IdLine[];
91
+ readonly basis: CodeTokenBasis;
92
+ }
200
93
 
201
- // ── Geometry ────────────────────────────────────────────────────────────
202
-
203
- private metrics(): CodeMetrics {
204
- return {
205
- fontSize: this.fontSize,
206
- fontFamily: this.fontFamily,
207
- lineHeight: this.lineHeight,
208
- letterSpacing: this.letterSpacing,
209
- padding: this.padding,
210
- showLineNumbers: this.showLineNumbers,
211
- lineNumberGap: this.lineNumberGap,
212
- };
213
- }
94
+ export interface CodeEditFrame {
95
+ readonly transition: StructuralTransition;
96
+ readonly progress: number;
97
+ }
214
98
 
215
- /** The structural edit currently in flight, if any. */
216
- private activeEdit(): StructuralTransition | null {
217
- for (let i = this.transitions.length - 1; i >= 0; i--) {
218
- const tr = this.transitions[i];
219
- if (tr.kind === "structural") return tr;
220
- }
221
- return null;
222
- }
99
+ /** The dim `highlight` leaves: what stays bright, what the rest dims to, and the cross-fade in flight. */
100
+ export interface CodeFocus {
101
+ readonly dim: number | null;
102
+ readonly ids: Set<number>;
103
+ readonly fade: DimTransition | null;
104
+ }
223
105
 
224
- private settledLayout(m: CodeMetrics, scope: MeasureContext2D | RenderContext2D): CodeLayout {
225
- const key = `${this.structureVersion}|${metricsSignature(m)}|${this.advanceCache.signature(m.fontSize, m.fontFamily)}`;
226
- if (this.layoutCache && this.layoutCacheKey === key) return this.layoutCache;
227
- const layout = layoutCode(this.tokenLines, m, this.advanceCache, scope);
228
- this.layoutCache = layout;
229
- this.layoutCacheKey = key;
230
- return layout;
231
- }
106
+ export interface CodeBanding {
107
+ readonly bands: RowBand[];
108
+ readonly fade: RowsTransition | null;
109
+ }
232
110
 
233
- /**
234
- * The one or two layouts this frame is drawn from.
235
- *
236
- * A settled frame has one. A frame inside an edit has two — where every
237
- * token was, and where it is going — and the frame is a point between them.
238
- * That is the whole reason an insert no longer piles its glyphs at the left
239
- * margin: a token's destination is *computed*, not approximated by collapsing
240
- * the advance of everything around it.
241
- *
242
- * Both endpoints are fixed for the edit's duration, so they are built once
243
- * and cached on the transition rather than rebuilt per frame.
244
- */
245
- private frameLayout(scope: MeasureContext2D | RenderContext2D): {
246
- from: CodeLayout;
247
- to: CodeLayout;
248
- edit: StructuralTransition | null;
249
- } {
250
- const m = this.metrics();
251
- const edit = this.activeEdit();
252
- if (!edit) {
253
- const layout = this.settledLayout(m, scope);
254
- return { from: layout, to: layout, edit: null };
255
- }
256
- const key = `${metricsSignature(m)}|${this.advanceCache.signature(m.fontSize, m.fontFamily)}`;
257
- if (edit.layoutKey !== key || !edit.fromLayout || !edit.toLayout) {
258
- edit.fromLayout = layoutCode(edit.from, m, this.advanceCache, scope);
259
- edit.toLayout = layoutCode(edit.to, m, this.advanceCache, scope);
260
- edit.layoutKey = key;
261
- }
262
- return { from: edit.fromLayout, to: edit.toLayout, edit };
263
- }
111
+ /** Each `Code` component's strategies and edit cache, by the node class it generated. */
112
+ const kits = new WeakMap<object, Kit>();
264
113
 
265
- override measure(constraints: SizeConstraints, scope: MeasureContext2D): Partial<Size2D> {
266
- this.advanceCache.sync(this.advanceCache.signature(this.fontSize, this.fontFamily));
267
- const wm = this.width;
268
- const hm = this.height;
269
-
270
- const { from, to, edit } = this.frameLayout(scope);
271
- const t = moveProgress(edit);
272
- const innerW = edit ? lerpNumber(from.innerW, to.innerW, t) : to.innerW;
273
- const innerH = edit ? lerpNumber(from.innerH, to.innerH, t) : to.innerH;
274
-
275
- const resolvedW = typeof wm === "number"
276
- ? wm
277
- : wm === "hug"
278
- ? innerW + this.padding.left + this.padding.right
279
- : constraints.maxWidth ?? 0;
280
-
281
- const resolvedH = typeof hm === "number"
282
- ? hm
283
- : hm === "hug"
284
- ? innerH + this.padding.top + this.padding.bottom
285
- : constraints.maxHeight ?? 0;
286
-
287
- return { width: resolvedW, height: resolvedH };
288
- }
114
+ const NO_FOCUS: CodeFocus = { dim: null, ids: new Set(), fade: null };
115
+ const NO_BANDING: CodeBanding = { bands: [], fade: null };
289
116
 
290
- protected override renderContent(ctx: RenderPass2D): void {
291
- // Refuse the ambient `<DefaultTextStyle>` / theme typography defaults for
292
- // everything drawn inside this node.
293
- //
294
- // Those defaults describe the *document's* prose — a display face, a
295
- // heading weight, a paragraph line-height — and a code block is not prose.
296
- // Its own props are the vocabulary: `fontFamily` defaults to a monospaced
297
- // face because column alignment depends on it, `letterSpacing` and
298
- // `lineHeight` are tuned against that face, and every token is laid out at
299
- // an x measured from those exact values. A scene-wide serif family or a
300
- // 700 weight arriving through the draw scope would shape glyphs the
301
- // geometry was never measured for, and the block would come apart
302
- // column-by-column rather than merely look different.
303
- ctx.pushTextStyle(null);
304
- try {
305
- // Keep retrying until the language+theme have actually loaded, so a frame
306
- // that rendered as plain text upgrades to full highlighting the moment the
307
- // asset loader resolves. Never mid-edit: re-tokenizing mints fresh ids,
308
- // and an in-flight transition is keyed by the ones it captured.
309
- if (!this.tokenized && !this.activeEdit() && canHighlight(this.language, this.theme)) {
310
- this.tokenize();
311
- }
312
- super.renderContent(ctx);
313
- } finally {
314
- ctx.popTextStyle();
315
- }
316
- }
117
+ /** A private entry: commands write it, documents and calls never see it. */
118
+ function own<T>(initial: T) {
119
+ return Types.custom<T, T, { readonly private: true }>(initial, { private: true });
120
+ }
317
121
 
318
- protected override renderSelf(ctx: RenderContext2D): void {
319
- this.drawSelf(ctx);
320
- }
122
+ const TARGET = Types.custom<CodeTarget | undefined>(undefined, { kind: "selector" });
123
+ const TEXT = Types.text("", { multiline: true });
321
124
 
322
- /** A listing holds a listing — `append`/`insert`/`erase` are how it grows. */
323
- protected override acceptsChild(_child: Node): boolean {
324
- return false;
325
- }
125
+ /**
126
+ * A `Code` whose edits are paired and timed by `strategies`. {@link Code} is
127
+ * the one with the defaults; a project wanting another hands its own to the
128
+ * engine in its place (the key is the same).
129
+ *
130
+ * A listing and nothing else: no paint slots, and no box behind the tokens. A
131
+ * listing set on a card is a `Code` inside a hugging `Rect`.
132
+ */
133
+ export function defineCode(strategies: CodeStrategies = {}) {
134
+ const kit: Kit = {
135
+ diff: strategies.diffStrategy ?? defaultCodeDiff,
136
+ phases: strategies.phaseStrategy ?? defaultCodePhases,
137
+ edits: new WeakMap(),
138
+ };
139
+ const act = (timeline: { animate(frame: Frame): Piece; wait(seconds: number): Piece }, frame: Frame | null): Piece =>
140
+ (frame ? timeline.animate(frame) : timeline.wait(0));
141
+
142
+ const component = component2d({
143
+ key: "code",
144
+ state: {
145
+ code: Types.custom<string, CodeSource>("", { lerp: blendSource }),
146
+ language: "typescript",
147
+ fontFamily: "Fira Mono",
148
+ /** A built-in or registered theme name (e.g. `'github-dark'`), or a `CodeHighlightStyle` object. */
149
+ theme: Types.custom<CodeTheme>(DefaultHighlightStyle.name),
150
+ fontSize: 16,
151
+ lineHeight: 1.6,
152
+ // Extra space after every glyph, in px: folded into the advance a token is measured at.
153
+ letterSpacing: 1.1,
154
+ showLineNumbers: false,
155
+ // In space-widths, so it scales with fontSize.
156
+ lineNumberGap: 2,
157
+ listing: own<CodeListing | null>(null),
158
+ edit: own<CodeEditFrame | null>(null),
159
+ focus: own(NO_FOCUS),
160
+ banding: own(NO_BANDING),
161
+ },
162
+ defaults: { width: "hug", height: "hug" },
163
+ allowedChildren: [],
164
+ // The grammar gates layout: how many tokens there are, and so where each sits, depends on it.
165
+ load: (loader, state) => {
166
+ loader.font(state.fontFamily);
167
+ loader.task(`shiki:lang:${state.language}`, grammarLoader(state.language), { before: "layout" });
168
+ },
169
+ commands: {
170
+ /** Append `code` to the end of the listing. */
171
+ append: {
172
+ args: { code: TEXT },
173
+ run: (timeline, state) => {
174
+ const seen = current(state, kit);
175
+ return act(timeline, editFrame(kit, seen, seen.source + timeline.args.code, basisOf(state)));
176
+ },
177
+ },
178
+ /** Insert `code` above the listing. */
179
+ prepend: {
180
+ args: { code: TEXT },
181
+ run: (timeline, state) => {
182
+ const seen = current(state, kit);
183
+ return act(timeline, editFrame(kit, seen, timeline.args.code + seen.source, basisOf(state)));
184
+ },
185
+ },
186
+ /** Insert `code` before the 1-indexed `[line, col]`, clamped into the listing. */
187
+ insert: {
188
+ args: {
189
+ position: Types.custom<[number, number] | undefined>(undefined, {
190
+ kind: "point",
191
+ properties: {
192
+ line: { kind: "number", default: 1, min: 1, step: 1 },
193
+ col: { kind: "number", default: 1, min: 1, step: 1 },
194
+ },
195
+ }),
196
+ code: TEXT,
197
+ },
198
+ run: (timeline, state) => {
199
+ const seen = current(state, kit);
200
+ const offset = offsetAt(seen.lines, timeline.args.position as [number, number]);
201
+ const next = seen.source.slice(0, offset) + timeline.args.code + seen.source.slice(offset);
202
+ return act(timeline, editFrame(kit, seen, next, basisOf(state)));
203
+ },
204
+ },
205
+ /**
206
+ * Erase the code in `target`. A range of whole lines takes their line
207
+ * breaks with it, so the rows below close up.
208
+ */
209
+ remove: {
210
+ args: { target: TARGET },
211
+ run: (timeline, state) => act(timeline, eraseFrame(kit, state, timeline.args.target as CodeRange)),
212
+ },
213
+ replace: {
214
+ args: { target: TARGET, replacement: TEXT },
215
+ run: (timeline, state) => {
216
+ const seen = current(state, kit);
217
+ const { start, end } = rangeToCharOffsets(timeline.args.target as CodeRange, lineLengths(seen.lines));
218
+ const next = seen.source.slice(0, start) + timeline.args.replacement + seen.source.slice(end);
219
+ return act(timeline, editFrame(kit, seen, next, basisOf(state)));
220
+ },
221
+ },
222
+ /**
223
+ * Dim everything outside `target` (one range or several) to `dim`.
224
+ * Persistent: a second highlight cross-fades from this one.
225
+ */
226
+ highlight: {
227
+ args: {
228
+ target: TARGET,
229
+ dim: Types.number(0.4, { min: 0, max: 1, step: 0.01, scale: 100, unit: "%" }),
230
+ },
231
+ run: (timeline, state) => act(timeline, highlightFrame(kit, state, timeline.args.target, timeline.args.dim)),
232
+ },
233
+ /** Fade every dimmed token back to full and clear the highlight. */
234
+ resetHighlight: (timeline, state) => act(timeline, resetHighlightFrame(kit, state)),
235
+ /**
236
+ * Lay a band of `color` behind every line a range touches. A second
237
+ * call slides the bands onto their new rows, paired in order; a band
238
+ * keeps to its lines through an edit.
239
+ */
240
+ highlightRows: {
241
+ args: {
242
+ target: TARGET,
243
+ color: Types.color(ROW_BAND_COLOR),
244
+ radius: Types.number(ROW_BAND_RADIUS, { min: 0, step: 1 }),
245
+ },
246
+ run: (timeline, state) => {
247
+ const seen = current(state, kit);
248
+ const bands = rowBandsIn(seen.lines, normalizeCodeRanges(timeline.args.target), timeline.args.color, Math.max(0, timeline.args.radius ?? ROW_BAND_RADIUS));
249
+ return act(timeline, rowsFrame(seen, state.banding.bands, bands));
250
+ },
251
+ },
252
+ /** Fade every row band out and clear them. */
253
+ resetRowHighlight: (timeline, state) => act(timeline, rowsFrame(current(state, kit), state.banding.bands, [])),
254
+ },
255
+ measure: (measurer, state) => measureListing(measurer, state, kit),
256
+ // Refuses the ambient text style: every column is measured against this face, so an
257
+ // inherited family or weight would shape glyphs the geometry was never measured for.
258
+ render:(ctx, state) => ctx.textStyle(null, () => drawListing(ctx, state, kit)),
259
+ });
260
+ kits.set(component.node, kit);
261
+ return component;
262
+ }
326
263
 
327
- /**
328
- * The node's laid-out rect as a clip outline, so `clip` and any backdrop
329
- * effect are confined to the block rather than to nothing — a listing set on
330
- * a card is exactly where a backdrop blur is asked for.
331
- */
332
- protected override clipSelf(): Clip {
333
- return new Clip().rect({
334
- width: this.layoutBounds.width,
335
- height: this.layoutBounds.height,
336
- });
337
- }
264
+ /**
265
+ * A syntax-highlighted listing whose edits animate token by token: each edit
266
+ * is diffed against what is on screen, so a token that survives travels to
267
+ * its new column and only what changed fades.
268
+ */
269
+ export const Code = defineCode();
270
+ export type Code = NodeOf<typeof Code>;
271
+ export type CodeProps = NonNullable<Parameters<typeof Code>[0]>;
272
+
273
+ /** What a listing shows now: its source, its token rows, and the edit in flight, if any. */
274
+ export function listingOf(code: Code): { readonly source: string; readonly lines: readonly IdLine[]; readonly edit: StructuralTransition | null } {
275
+ const kit = kits.get(code.constructor);
276
+ if (!kit) throw new TypeError("listingOf takes a node a `Code` component built.");
277
+ const view = shown(code, kit);
278
+ return { source: view.source, lines: view.lines, edit: editOf(view) };
279
+ }
338
280
 
339
- /**
340
- * A listing is grabbed by its **text**, line by ragged line, rather than by
341
- * the rectangle the lines were laid out in.
342
- *
343
- * The default narrows a node's grab region to the outline it declares, and
344
- * this node's outline is a box it no longer paints anything into. Code is
345
- * mostly whitespace: a ten-line snippet with one long line is a rectangle
346
- * that is largely empty, and in an editor every one of those empty pixels
347
- * swallows a click meant for whatever sits behind or beneath it. Short of the
348
- * text itself there is nothing there to have clicked.
349
- *
350
- * So the region is the union of the lines' own extents — each a line-height
351
- * slot as wide as that line's ink — plus the gutter where line numbers are
352
- * showing, since those are drawn glyphs too.
353
- *
354
- * Answered from the **cached** layout, which is what the last frame drew
355
- * from, because a hit test arrives with no measurement scope and text cannot
356
- * be measured without one. Where there is no usable cache — before the first
357
- * frame, or mid-edit, when the geometry is an interpolation of two structures
358
- * held on the transition — the box is the honest answer rather than a
359
- * silhouette measured against stale metrics.
360
- */
361
- protected override hitTestSelf(local: Vector2, tolerance: number): boolean {
362
- const layout = this.hitLayout();
363
- if (!layout) return super.hitTestSelf(local, tolerance);
364
-
365
- // Never *wider* than the box: `hug` sizes to the ink, but a fixed width
366
- // narrower than the longest line would otherwise be hit outside itself.
367
- if (!super.hitTestSelf(local, tolerance)) return false;
368
-
369
- const half = (this.fontSize * this.lineHeight) / 2;
370
- // Line numbers sit in the gutter, to the left of every line's own start,
371
- // so a numbered blank line is still grabbable — by its number.
372
- const gutter = this.showLineNumbers ? layout.gutter : 0;
373
- const left = layout.startX - gutter - tolerance;
374
- for (let i = 0; i < layout.lineY.length; i++) {
375
- const width = (layout.lineW[i] ?? 0) + gutter;
376
- if (width <= 0) continue;
377
- if (Math.abs(local.y - layout.lineY[i]) > half + tolerance) continue;
378
- if (local.x >= left && local.x <= layout.startX + (layout.lineW[i] ?? 0) + tolerance) {
379
- return true;
380
- }
381
- }
382
- return false;
383
- }
281
+ /** Every range matching the literal `text` in `source`, in order and not overlapping. Multi-line matches are found too. */
282
+ export function findAllRanges(source: CodeSource, text: string): CodeRange[] {
283
+ const ranges: CodeRange[] = [];
284
+ if (!text) return ranges;
285
+ const joined = normalizeSource(sourceText(source));
286
+ const lineLens = joined.split("\n").map((line) => line.length);
287
+ let from = 0;
288
+ while (true) {
289
+ const idx = joined.indexOf(text, from);
290
+ if (idx === -1) break;
291
+ ranges.push(charOffsetsToRange(idx, idx + text.length, lineLens));
292
+ from = idx + Math.max(1, text.length);
293
+ }
294
+ return ranges;
295
+ }
384
296
 
385
- /**
386
- * The layout a hit test may be answered from: the settled cache, and only
387
- * while it still matches the node's current metrics.
388
- *
389
- * The key is the one {@link settledLayout} writes minus its scope-dependent
390
- * half — every input to it (`metricsSignature`, the advance cache's own
391
- * signature) is readable without a measurer, which is precisely what makes
392
- * the check possible here.
393
- */
394
- private hitLayout(): CodeLayout | null {
395
- if (!this.layoutCache || this.activeEdit()) return null;
396
- const m = this.metrics();
397
- const key = `${this.structureVersion}|${metricsSignature(m)}|${this.advanceCache.signature(m.fontSize, m.fontFamily)}`;
398
- return this.layoutCacheKey === key ? this.layoutCache : null;
399
- }
297
+ /** The `index`th range matching `text`, or `null`. */
298
+ export function findRangeAt(source: CodeSource, text: string, index: number): CodeRange | null {
299
+ return findAllRanges(source, text)[index] ?? null;
300
+ }
400
301
 
401
- // ── Editing commands ────────────────────────────────────────────────────
402
-
403
- /**
404
- * Append `code` to the end of the listing.
405
- *
406
- * Stated as *what the source becomes*; {@link editTo} works out the rest —
407
- * which is why appending a line that closes a block re-highlights the lines
408
- * above it, instead of colouring the new text as its own program.
409
- */
410
- @command({ args: [{ key: "code", kind: "text", multiline: true }] })
411
- append(args: CommandArgs<{ code: string }> & { duration: number }): Command<Record<string, never>> {
412
- const { code } = args.data as { code: string };
413
- return this.editTo(this.joinedSource() + code, args.duration, args.easing);
414
- }
302
+ /** The first range matching `text`, or `null`. */
303
+ export function findFirstRange(source: CodeSource, text: string): CodeRange | null {
304
+ return findRangeAt(source, text, 0);
305
+ }
415
306
 
416
- /** Insert `code` above the listing. */
417
- @command({ args: [{ key: "code", kind: "text", multiline: true }] })
418
- prepend(args: CommandArgs<{ code: string }> & { duration: number }): Command<Record<string, never>> {
419
- const { code } = args.data as { code: string };
420
- return this.editTo(code + this.joinedSource(), args.duration, args.easing);
421
- }
307
+ // ── What a listing shows ────────────────────────────────────────────────────
422
308
 
423
- /**
424
- * Insert `code` at the given (line, col). Both are 1-indexed; col is the
425
- * column BEFORE which the new content is inserted (col=1 means start of
426
- * line). If `code` contains newlines, new lines are created in the middle
427
- * of the existing line.
428
- */
429
- @command({
430
- args: [
431
- // Two controls, one argument: the method takes a `[line, col]`
432
- // tuple, so the pair a host draws is declared *inside* the value it
433
- // composes into rather than flattened beside it.
434
- {
435
- key: "position", kind: "point", properties: {
436
- line: { kind: "number", default: 1, min: 1, step: 1 },
437
- col: { kind: "number", default: 1, min: 1, step: 1 },
438
- },
439
- },
440
- { key: "code", kind: "text", default: "", multiline: true },
441
- ],
442
- })
443
- insert(
444
- args: CommandArgs<{ position: [number, number]; code: string }> & { duration: number },
445
- ): Command<Record<string, never>> {
446
- const { position, code } = args.data as { position: [number, number]; code: string };
447
- const source = this.joinedSource();
448
- const offset = this.offsetAt(position);
449
- return this.editTo(source.slice(0, offset) + code + source.slice(offset), args.duration, args.easing);
450
- }
309
+ interface Kit {
310
+ readonly diff: CodeDiffStrategy;
311
+ readonly phases: CodePhaseStrategy;
312
+ /** The edit from a listing to a source and basis, built once: a `to({ code })` asks for it every frame. */
313
+ readonly edits: WeakMap<IdLine[], Map<string, Map<string, Edited>>>;
314
+ }
451
315
 
452
- /**
453
- * Erase the code in `codeRange`.
454
- *
455
- * Named `erase` rather than `remove` because a `Code` is also a node, and
456
- * `Node.remove(child)` takes a child out of the tree. Two methods spelled
457
- * the same on one object, doing entirely different things, is worse than one
458
- * of them having a slightly less obvious name.
459
- *
460
- * A range that covers whole lines takes their line breaks with it, so the
461
- * rows below close up; a range inside a line takes only the characters, and
462
- * the rest of the line reflows around the hole.
463
- */
464
- @command({ key: "remove", args: [{ key: "target", kind: "selector" }] })
465
- erase(
466
- args: CommandArgs<{ target: CodeRange }> & { duration: number },
467
- ): Command<Record<string, never>> {
468
- const { target: codeRange } = args.data as { target: CodeRange };
469
- const { duration, easing } = args;
470
- const source = this.joinedSource();
471
- let { start, end } = rangeToCharOffsets(codeRange, this.lineLengths());
472
- // `lines(2)` resolves to the *characters* of line 2, not to the row —
473
- // taking those alone would leave an empty line behind where an editor
474
- // would have closed the gap. This is also what makes a blank row
475
- // removable at all: it has no characters, so its range is empty, and
476
- // "delete nothing" is not what `lines(2)` was asking for.
477
- const atLineStart = start === 0 || source[start - 1] === "\n";
478
- const atLineEnd = end === source.length || source[end] === "\n";
479
- if (atLineStart && atLineEnd) {
480
- if (end < source.length) end += 1;
481
- else if (start > 0) start -= 1;
482
- } else if (end <= start) {
483
- return driveCommand(duration, () => { });
484
- }
485
- if (end <= start) return driveCommand(duration, () => { });
486
- return this.editTo(source.slice(0, start) + source.slice(end), duration, easing);
487
- }
316
+ interface Edited {
317
+ readonly transition: StructuralTransition;
318
+ readonly listing: CodeListing;
319
+ }
488
320
 
489
- /** Replace the code in `codeRange` with `replacement`. */
490
- @command({ args: [{ key: "target", kind: "selector" }, { key: "replacement", kind: "text", default: "", multiline: true }] })
491
- replace(
492
- args: CommandArgs<{ target: CodeRange; replacement: string }> & { duration: number },
493
- ): Command<Record<string, never>> {
494
- const { target: codeRange, replacement } = args.data as { target: CodeRange; replacement: string };
495
- const source = this.joinedSource();
496
- const { start, end } = rangeToCharOffsets(codeRange, this.lineLengths());
497
- return this.editTo(source.slice(0, start) + replacement + source.slice(end), args.duration, args.easing);
498
- }
321
+ /** What the structure resolves from. A command's snapshot and a node's own state view both are one. */
322
+ interface ListingState {
323
+ readonly code: CodeSource;
324
+ readonly language: string;
325
+ readonly theme: CodeTheme;
326
+ readonly listing: CodeListing | null;
327
+ readonly edit: CodeEditFrame | null;
328
+ }
499
329
 
500
- /**
501
- * Highlight a range of code — or several at once (e.g.
502
- * `CodeRanges.lines(2).word(5, 1, 3)`) — highlighted as one set: tokens
503
- * within any of the ranges stay at opacity 1, tokens outside dim to
504
- * `dim`. Persistent — call resetHighlight() to undo, or call highlight()
505
- * again with a different range to cross-fade.
506
- */
507
- @command({ args: [{ key: "target", kind: "selector" }, { key: "dim", kind: "number", default: 0.4, min: 0, max: 1, step: 0.01, scale: 100, unit: "%" }] })
508
- highlight(
509
- args: CommandArgs<{ target: CodeRange | Iterable<CodeRange>; dim?: number }> & { duration: number },
510
- ): Command<Record<string, never>> {
511
- const { target: codeRange, dim } = args.data as { target: CodeRange | Iterable<CodeRange>; dim?: number };
512
- const { duration, easing } = args;
513
- const toDim = dim ?? 0.4;
514
- const matchIds = this.tokenIdsInRanges(normalizeCodeRanges(codeRange));
515
- if (matchIds.size === 0) return driveCommand(duration, () => { });
516
-
517
- const fromDim = this.highlightDimOpacity ?? 1;
518
- const hadPrevious = this.highlightedIds.size > 0;
519
- const previousIds = this.highlightedIds;
520
-
521
- const animTokens: AnimToken[] = [];
522
- for (const line of this.tokenLines) {
523
- for (const tok of line.tokens) {
524
- const wasHighlighted = !hadPrevious || previousIds.has(tok.id);
525
- const isHighlighted = matchIds.has(tok.id);
526
- const fromOp = wasHighlighted ? 1 : fromDim;
527
- const toOp = isHighlighted ? 1 : toDim;
528
- if (fromOp === toOp) continue;
529
- animTokens.push(makeAnim(tok.id, { keys: [0, 1], values: [fromOp, toOp] }));
530
- }
531
- }
330
+ interface LayoutState extends ListingState {
331
+ readonly fontSize: number;
332
+ readonly fontFamily: string;
333
+ readonly lineHeight: number;
334
+ readonly letterSpacing: number;
335
+ readonly padding: InsetsResolved;
336
+ readonly showLineNumbers: boolean;
337
+ readonly lineNumberGap: number;
338
+ }
532
339
 
533
- // Which tokens are dimmed is state the *next* highlight reads to know
534
- // what it is cross-fading from, so it is committed at the end and put
535
- // back below it — a `finally` could only ever have run forwards.
536
- return this.runDim(animTokens, duration, easing, (done) => {
537
- this.highlightDimOpacity = done ? toDim : (hadPrevious ? fromDim : null);
538
- this.highlightedIds = done ? matchIds : previousIds;
539
- });
540
- }
340
+ interface DrawState extends LayoutState {
341
+ readonly focus: CodeFocus;
342
+ readonly banding: CodeBanding;
343
+ }
541
344
 
542
- /**
543
- * Fade all dimmed tokens back to opacity 1 and clear the persistent
544
- * highlight state.
545
- */
546
- @command()
547
- resetHighlight(args: CommandArgs<Record<string, never>> & { duration: number }): Command<Record<string, never>> {
548
- const { duration, easing } = args;
549
- if (this.highlightDimOpacity === null) return driveCommand(duration, () => { });
550
-
551
- const fromDim = this.highlightDimOpacity;
552
- const previousIds = this.highlightedIds;
553
-
554
- const animTokens: AnimToken[] = [];
555
- for (const line of this.tokenLines) {
556
- for (const tok of line.tokens) {
557
- const fromOp = previousIds.has(tok.id) ? 1 : fromDim;
558
- if (fromOp === 1) continue;
559
- animTokens.push(makeAnim(tok.id, { keys: [0, 1], values: [fromOp, 1] }));
560
- }
561
- }
345
+ /** The structure a frame draws, and the edit it is part-way through. */
346
+ interface Shown {
347
+ readonly source: string;
348
+ readonly lines: IdLine[];
349
+ readonly transition: StructuralTransition | null;
350
+ readonly progress: number;
351
+ }
562
352
 
563
- return this.runDim(animTokens, duration, easing, (done) => {
564
- this.highlightDimOpacity = done ? null : fromDim;
565
- this.highlightedIds = done ? new Set() : previousIds;
566
- });
567
- }
353
+ /**
354
+ * A `to({ code })` mid-flight comes first: it wrote `code` after any edit
355
+ * command did. Then an edit command in flight, then the settled listing.
356
+ */
357
+ function shown(s: ListingState, kit: Kit): Shown {
358
+ const code = s.code;
359
+ if (typeof code !== "string") {
360
+ const { transition } = edited(settled(s, code.from, kit), code.to, basisOf(s), kit);
361
+ return { source: code.to, lines: transition.to, transition, progress: code.t };
362
+ }
363
+ if (s.edit) return { source: normalizeSource(code), lines: s.edit.transition.to, transition: s.edit.transition, progress: s.edit.progress };
364
+ const listing = settled(s, normalizeSource(code), kit);
365
+ return { source: listing.source, lines: listing.lines, transition: null, progress: 1 };
366
+ }
568
367
 
569
- /**
570
- * Animate the listing to a new source.
571
- *
572
- * The engine behind every editing command, and behind `to({ code })`. Every
573
- * edit is expressed as the source it produces, tokenized, and then *diffed*
574
- * against what is on screen — so a token that survived the edit keeps its
575
- * identity and simply travels to its new column, and only what genuinely
576
- * changed is faded.
577
- *
578
- * The result runs in three phases (see `phaseStrategy`): what is leaving
579
- * fades out, what stays reflows, and only then does what is new arrive.
580
- * Playing them together is what made a replacement read as a cross-dissolve
581
- * of two unrelated listings rather than as an edit being made.
582
- */
583
- private editTo(next: string, duration: number, easing?: EasingFunction): Command<Record<string, never>> {
584
- const from = this.tokenLines;
585
- const fromCode = this.joinedSource();
586
- if (next === fromCode) return driveCommand(duration, () => { });
587
-
588
- const edit = this.diffStrategy(from, tokenizeCodeToIdLines(next, this.language, this.theme));
589
-
590
- const transition: StructuralTransition = {
591
- kind: "structural",
592
- from,
593
- to: edit.lines,
594
- removedIds: edit.removedIds,
595
- addedIds: edit.addedIds,
596
- enteringLineIds: edit.newLineIds,
597
- fromColorById: edit.fromColorById,
598
- phases: this.phaseStrategy(edit.removedIds.size > 0, edit.addedIds.size > 0),
599
- progress: 0,
600
- layoutKey: null,
601
- fromLayout: null,
602
- toLayout: null,
603
- };
368
+ function editOf(view: Shown): StructuralTransition | null {
369
+ return view.transition ? { ...view.transition, progress: view.progress } : null;
370
+ }
604
371
 
605
- // A tokenize triggered by the grammar landing mid-edit would mint fresh
606
- // ids underneath this transition, and the transition is keyed by the ones
607
- // it captured — so the retry in `onRender` is gated on there being no edit
608
- // in flight, which means this flag has to be honest now rather than once
609
- // the edit settles.
610
- this.tokenized = canHighlight(this.language, this.theme);
611
-
612
- return driveCommand(duration, (t) => {
613
- const running = t > 0 && t < 1;
614
- const index = this.transitions.indexOf(transition);
615
- if (running && index < 0) this.transitions.push(transition);
616
- else if (!running && index >= 0) this.transitions.splice(index, 1);
617
-
618
- transition.progress = easing ? easing(t) : t;
619
-
620
- // The settle, in both directions: asked for a time before the edit
621
- // starts, the listing is what preceded it; at or past the end, the
622
- // result. A `finally` could only ever have run forwards.
623
- if (t <= 0) {
624
- this.setLines(from);
625
- this._writeProp("code", fromCode);
626
- } else {
627
- this.setLines(edit.lines);
628
- this._writeProp("code", next);
629
- }
630
- });
631
- }
372
+ /** The listing a command starts from. */
373
+ function current(s: ListingState, kit: Kit): CodeListing {
374
+ return settled(s, normalizeSource(sourceText(s.code)), kit);
375
+ }
632
376
 
633
- /**
634
- * Put a dim transition on the stack, drive its progress, take it off and
635
- * commit at the end.
636
- *
637
- * A {@link Command} rather than a generator, which is what makes a listing
638
- * scrubbable: membership is a function of `t` (on the stack for `0 < t < 1`,
639
- * off it outside), `settle(done)` replaces the `finally` and takes *which
640
- * way*, and `progress` is assigned from `t` rather than accumulated.
641
- */
642
- private runDim(
643
- tokens: AnimToken[],
644
- duration: number,
645
- easing?: EasingFunction,
646
- settle?: (done: boolean) => void,
647
- ): Command<Record<string, never>> {
648
- const transition = { kind: "dim" as const, tokens, progress: 0 };
649
-
650
- return driveCommand(duration, (t) => {
651
- const running = t > 0 && t < 1;
652
- const index = this.transitions.indexOf(transition);
653
- if (running && index < 0) this.transitions.push(transition);
654
- else if (!running && index >= 0) this.transitions.splice(index, 1);
655
-
656
- transition.progress = easing ? easing(t) : t;
657
- settle?.(t >= 1);
658
- });
659
- }
377
+ /**
378
+ * The structure `source` settles on. Until a listing command has acted, the
379
+ * props are all there is; after one, it is the listing that command left,
380
+ * recoloured keeping its ids when only the theme moved, and diffed onward
381
+ * when the source has (a `to({ code })` that landed, a binding).
382
+ */
383
+ function settled(s: ListingState, source: string, kit: Kit): CodeListing {
384
+ const basis = basisOf(s);
385
+ const pinned = s.listing;
386
+ if (!pinned) return derived(source, basis);
387
+ if (pinned.source !== source) return edited(pinned, source, basis, kit).listing;
388
+ if (sameBasis(pinned.basis, basis)) return pinned;
389
+ if (pinned.basis.language === basis.language && pinned.basis.highlighted === basis.highlighted) return recoloured(pinned, basis);
390
+ return derived(source, basis);
391
+ }
660
392
 
661
- /**
662
- * Route a tweened `code` through the diff engine.
663
- *
664
- * `to({ code })` is the form an author reaches for first — it is how every
665
- * other prop is animated — and a bare string prop would otherwise land in the
666
- * generic tween's discrete-snap bucket and simply cut at the end. Intercepted
667
- * here it becomes the same three-phase edit the named commands produce, while
668
- * the rest of the step's props tween alongside it untouched.
669
- */
670
- override _prepareStep(
671
- to: Partial<CodeProps>,
672
- duration: number,
673
- easing?: EasingFunction,
674
- ): TweenStepper {
675
- const next = to.code;
676
- if (typeof next !== "string" || next === this.joinedSource()) {
677
- return super._prepareStep(to, duration, easing);
678
- }
393
+ function basisOf(s: { readonly language: string; readonly theme: CodeTheme }): CodeTokenBasis {
394
+ return { language: s.language, theme: s.theme, highlighted: canHighlight(s.language, s.theme) };
395
+ }
679
396
 
680
- const rest: Partial<CodeProps> = { ...to };
681
- delete rest.code;
682
- // Prepared before the edit, so a step that also moves `fontSize` has
683
- // snapshotted its own start value against the listing that is still on
684
- // screen.
685
- const others = super._prepareStep(rest, duration, easing);
686
- const edit = this.editTo(next, duration, easing)._stepper();
687
-
688
- return {
689
- seek: (elapsed: number) => {
690
- others.seek(elapsed);
691
- edit.seek(elapsed);
692
- },
693
- advance: (dt: number): boolean => {
694
- const othersDone = others.advance(dt);
695
- const editDone = edit.advance(dt);
696
- return othersDone && editDone;
697
- },
698
- };
699
- }
397
+ function sameBasis(a: CodeTokenBasis, b: CodeTokenBasis): boolean {
398
+ return a.language === b.language && a.theme === b.theme && a.highlighted === b.highlighted;
399
+ }
700
400
 
701
- // ── Source queries ──────────────────────────────────────────────────────
702
-
703
- /**
704
- * Find every range matching the literal string `text` in the current
705
- * source. Multi-line matches are supported.
706
- */
707
- findAllRanges(text: string): CodeRange[] {
708
- const ranges: CodeRange[] = [];
709
- if (!text) return ranges;
710
- const source = this.joinedSource();
711
- const lineLens = this.lineLengths();
712
- let from = 0;
713
- while (true) {
714
- const idx = source.indexOf(text, from);
715
- if (idx === -1) break;
716
- ranges.push(charOffsetsToRange(idx, idx + text.length, lineLens));
717
- from = idx + Math.max(1, text.length);
718
- }
719
- return ranges;
720
- }
401
+ /** The source `code` names: where a `to({ code })` is heading, mid-tween. */
402
+ function sourceText(code: CodeSource): string {
403
+ return typeof code === "string" ? code : code.to;
404
+ }
721
405
 
722
- /** Find the `index`th range matching `text`. Returns null if not found. */
723
- findRangeAt(text: string, index: number): CodeRange | null {
724
- const all = this.findAllRanges(text);
725
- return all[index] ?? null;
726
- }
406
+ /**
407
+ * Mid-tween `code` holds both sources, so the frame can draw the edit between
408
+ * them; at either end it is a source again.
409
+ */
410
+ function blendSource(from: CodeSource, to: CodeSource, t: number): CodeSource {
411
+ const leaving = sourceText(from);
412
+ const arriving = sourceText(to);
413
+ const a = normalizeSource(leaving);
414
+ const b = normalizeSource(arriving);
415
+ // Nothing to edit: it snaps at the end, as any text does.
416
+ if (a === b) return t >= 1 ? arriving : leaving;
417
+ if (t <= 0) return a;
418
+ if (t >= 1) return b;
419
+ return { from: a, to: b, t };
420
+ }
727
421
 
728
- /** Find the first range matching `text`. Returns null if not found. */
729
- findFirstRange(text: string): CodeRange | null {
730
- return this.findRangeAt(text, 0);
422
+ const DERIVED = new Map<string, Map<string, CodeListing>>();
423
+ /** Sources kept tokenized per basis, oldest dropped first; a bound `code` can name any number of them. */
424
+ const DERIVED_LISTINGS = 256;
425
+
426
+ /** The props' own listing, the same object (and ids) each time it is asked for again. */
427
+ function derived(source: string, basis: CodeTokenBasis): CodeListing {
428
+ return recall(DERIVED, basisKey(basis), source, DERIVED_LISTINGS, () => ({ source, lines: tokenize(source, basis), basis }));
429
+ }
430
+
431
+ const RECOLOURED = new WeakMap<IdLine[], Map<string, Map<string, CodeListing>>>();
432
+
433
+ /** `pinned` in another theme: the same text split the same way keeps the ids a highlight or a band names. */
434
+ function recoloured(pinned: CodeListing, basis: CodeTokenBasis): CodeListing {
435
+ let shelf = RECOLOURED.get(pinned.lines);
436
+ if (!shelf) RECOLOURED.set(pinned.lines, shelf = new Map());
437
+ return recall(shelf, basisKey(basis), pinned.source, 4, () => {
438
+ const lines = tokenize(pinned.source, basis);
439
+ adoptIds(pinned.lines, lines);
440
+ return { source: pinned.source, lines, basis };
441
+ });
442
+ }
443
+
444
+ function edited(from: CodeListing, next: string, basis: CodeTokenBasis, kit: Kit): Edited {
445
+ let shelf = kit.edits.get(from.lines);
446
+ if (!shelf) kit.edits.set(from.lines, shelf = new Map());
447
+ return recall(shelf, basisKey(basis), next, 16, () => buildEdit(kit, from.lines, next, basis));
448
+ }
449
+
450
+ function buildEdit(kit: Kit, from: IdLine[], next: string, basis: CodeTokenBasis): Edited {
451
+ const edit = kit.diff(from, tokenize(next, basis));
452
+ const transition: StructuralTransition = {
453
+ kind: "structural",
454
+ from,
455
+ to: edit.lines,
456
+ removedIds: edit.removedIds,
457
+ addedIds: edit.addedIds,
458
+ enteringLineIds: edit.newLineIds,
459
+ fromColorById: edit.fromColorById,
460
+ phases: kit.phases(edit.removedIds.size > 0, edit.addedIds.size > 0),
461
+ progress: 0,
462
+ layoutKey: null,
463
+ fromLayout: null,
464
+ toLayout: null,
465
+ };
466
+ return { transition, listing: { source: next, lines: edit.lines, basis } };
467
+ }
468
+
469
+ function tokenize(source: string, basis: CodeTokenBasis): IdLine[] {
470
+ // Best effort outside the asset pipeline (a test, a headless measure): on the timeline `load` has the grammar in first.
471
+ if (!basis.highlighted) ensureHighlighter(undefined, [basis.language]).catch(() => { });
472
+ return tokenizeCodeToIdLines(source, basis.language, basis.theme);
473
+ }
474
+
475
+ const styleIds = new WeakMap<object, number>();
476
+ let nextStyleId = 1;
477
+
478
+ function basisKey(basis: CodeTokenBasis): string {
479
+ let theme: string;
480
+ if (typeof basis.theme === "string") theme = `name:${basis.theme}`;
481
+ else {
482
+ let id = styleIds.get(basis.theme);
483
+ if (id === undefined) styleIds.set(basis.theme, id = nextStyleId++);
484
+ theme = `style:${id}`;
731
485
  }
486
+ return `${basis.language}\n${basis.highlighted ? 1 : 0}\n${theme}`;
487
+ }
488
+
489
+ /** Memoized by basis, then by source; a basis keeps `limit` sources, least recently asked for dropped first. */
490
+ function recall<T>(shelf: Map<string, Map<string, T>>, basis: string, source: string, limit: number, make: () => T): T {
491
+ let row = shelf.get(basis);
492
+ if (!row) shelf.set(basis, row = new Map());
493
+ const hit = row.get(source);
494
+ if (hit !== undefined) {
495
+ row.delete(source);
496
+ row.set(source, hit);
497
+ return hit;
498
+ }
499
+ const made = make();
500
+ row.set(source, made);
501
+ if (row.size > limit) row.delete(row.keys().next().value as string);
502
+ return made;
503
+ }
732
504
 
733
- private joinedSource(): string {
734
- return this.tokenLines
735
- .map(line => line.tokens.map(t => t.content).join(''))
736
- .join('\n');
505
+ /** Carry `from`'s line and token ids onto `to` when the two spell the same tokens. */
506
+ function adoptIds(from: IdLine[], to: IdLine[]): void {
507
+ if (from.length !== to.length) return;
508
+ for (let i = 0; i < from.length; i++) {
509
+ const a = from[i].tokens;
510
+ const b = to[i].tokens;
511
+ if (a.length !== b.length) return;
512
+ for (let j = 0; j < a.length; j++) if (a[j].content !== b[j].content) return;
737
513
  }
514
+ for (let i = 0; i < from.length; i++) {
515
+ to[i].id = from[i].id;
516
+ for (let j = 0; j < from[i].tokens.length; j++) to[i].tokens[j].id = from[i].tokens[j].id;
517
+ }
518
+ }
519
+
520
+ // ── Commands ────────────────────────────────────────────────────────────────
738
521
 
739
- private lineLengths(): number[] {
740
- return this.tokenLines.map(line =>
741
- line.tokens.reduce((acc, t) => acc + t.content.length, 0),
742
- );
522
+ /** What a listing command writes at a point of its own eased time. */
523
+ interface ListingWrites {
524
+ readonly code?: string;
525
+ readonly listing?: CodeListing | null;
526
+ readonly edit?: CodeEditFrame | null;
527
+ readonly focus?: CodeFocus;
528
+ readonly banding?: CodeBanding;
529
+ }
530
+
531
+ type Frame = (t: number) => ListingWrites;
532
+
533
+ /**
534
+ * Each command writes the listing it acted on, at every time it is asked for,
535
+ * so the ids it captured are the ones drawn; before it starts the timeline has
536
+ * put back the props, and the listing is derived from them again.
537
+ */
538
+ function editFrame(kit: Kit, seen: CodeListing, source: string, basis: CodeTokenBasis): Frame | null {
539
+ const next = normalizeSource(source);
540
+ if (next === seen.source) return null;
541
+ const { transition, listing } = buildEdit(kit, seen.lines, next, basis);
542
+ // Three phases (see `phaseStrategy`): what leaves fades out, what stays reflows, then what is new arrives.
543
+ return (t) => (t <= 0 ? { code: seen.source, listing: seen, edit: null }
544
+ : t >= 1 ? { code: next, listing, edit: null }
545
+ : { code: next, listing, edit: { transition, progress: t } });
546
+ }
547
+
548
+ function eraseFrame(kit: Kit, state: ListingState, target: CodeRange): Frame | null {
549
+ const seen = current(state, kit);
550
+ const source = seen.source;
551
+ let { start, end } = rangeToCharOffsets(target, lineLengths(seen.lines));
552
+ // `lines(2)` resolves to the characters of line 2: the row goes too, as an editor would close the gap. That is also what makes a blank row removable.
553
+ const atLineStart = start === 0 || source[start - 1] === "\n";
554
+ const atLineEnd = end === source.length || source[end] === "\n";
555
+ if (atLineStart && atLineEnd) {
556
+ if (end < source.length) end += 1;
557
+ else if (start > 0) start -= 1;
558
+ } else if (end <= start) {
559
+ return null;
743
560
  }
561
+ if (end <= start) return null;
562
+ return editFrame(kit, seen, source.slice(0, start) + source.slice(end), basisOf(state));
563
+ }
744
564
 
745
- /** Character offset of a 1-indexed (line, col), clamped into the document. */
746
- private offsetAt(position: [number, number]): number {
747
- const lens = this.lineLengths();
748
- if (lens.length === 0) return 0;
749
- const [rawLine, rawCol] = position;
750
- const li = Math.max(0, Math.min(lens.length - 1, rawLine - 1));
751
- let offset = 0;
752
- for (let k = 0; k < li; k++) offset += lens[k] + 1;
753
- return offset + Math.max(0, Math.min(lens[li], rawCol - 1));
565
+ function highlightFrame(kit: Kit, state: ListingState & { readonly focus: CodeFocus }, target: CodeTarget | undefined, dim: number): Frame | null {
566
+ const seen = current(state, kit);
567
+ const toDim = dim ?? 0.4;
568
+ const matchIds = tokenIdsInRanges(seen.lines, normalizeCodeRanges(target));
569
+ if (matchIds.size === 0) return null;
570
+
571
+ const previous = state.focus;
572
+ const fromDim = previous.dim ?? 1;
573
+ const hadPrevious = previous.ids.size > 0;
574
+ const tokens: AnimToken[] = [];
575
+ for (const line of seen.lines) {
576
+ for (const tok of line.tokens) {
577
+ const fromOp = !hadPrevious || previous.ids.has(tok.id) ? 1 : fromDim;
578
+ const toOp = matchIds.has(tok.id) ? 1 : toDim;
579
+ if (fromOp === toOp) continue;
580
+ tokens.push(makeAnim(tok.id, { keys: [0, 1], values: [fromOp, toOp] }));
581
+ }
754
582
  }
583
+ // Which tokens are dimmed is what the next highlight cross-fades from, so it is committed at the end and put back below it.
584
+ const before: CodeFocus = { dim: hadPrevious ? fromDim : null, ids: previous.ids, fade: null };
585
+ const done: CodeFocus = { dim: toDim, ids: matchIds, fade: null };
586
+ return (t) => ({
587
+ listing: seen,
588
+ focus: t <= 0 ? before : t >= 1 ? done : { ...before, fade: { kind: "dim", tokens, progress: t } },
589
+ });
590
+ }
755
591
 
756
- /**
757
- * Resolve one or more CodeRanges to the set of token ids whose content
758
- * overlaps any of them. Tokens that partially overlap are included.
759
- */
760
- private tokenIdsInRanges(codeRanges: CodeRange[]): Set<number> {
761
- const result = new Set<number>();
762
- const lineLens = this.lineLengths();
763
- if (lineLens.length === 0) return result;
764
- const offsets = codeRanges
765
- .map(r => rangeToCharOffsets(r, lineLens))
766
- .filter(({ start, end }) => end > start);
767
- if (offsets.length === 0) return result;
768
-
769
- // Walk tokens with running offsets in the joined string, testing each
770
- // token against every requested range's offsets.
771
- let off = 0;
772
- for (let li = 0; li < this.tokenLines.length; li++) {
773
- const line = this.tokenLines[li];
774
- for (const tok of line.tokens) {
775
- const tStart = off;
776
- const tEnd = off + tok.content.length;
777
- if (offsets.some(({ start, end }) => tEnd > start && tStart < end)) result.add(tok.id);
778
- off = tEnd;
779
- }
780
- if (li < this.tokenLines.length - 1) off += 1; // newline
592
+ function resetHighlightFrame(kit: Kit, state: ListingState & { readonly focus: CodeFocus }): Frame | null {
593
+ const previous = state.focus;
594
+ if (previous.dim === null) return null;
595
+ const seen = current(state, kit);
596
+ const fromDim = previous.dim;
597
+ const tokens: AnimToken[] = [];
598
+ for (const line of seen.lines) {
599
+ for (const tok of line.tokens) {
600
+ const fromOp = previous.ids.has(tok.id) ? 1 : fromDim;
601
+ if (fromOp === 1) continue;
602
+ tokens.push(makeAnim(tok.id, { keys: [0, 1], values: [fromOp, 1] }));
781
603
  }
782
- return result;
783
604
  }
605
+ const before: CodeFocus = { dim: fromDim, ids: previous.ids, fade: null };
606
+ return (t) => ({
607
+ listing: seen,
608
+ focus: t >= 1 ? NO_FOCUS : t <= 0 ? before : { ...before, fade: { kind: "dim", tokens, progress: t } },
609
+ });
610
+ }
784
611
 
785
- // ── Drawing ─────────────────────────────────────────────────────────────
786
-
787
- /**
788
- * Thin delegator: everything about *how* a frame is painted (settled or
789
- * mid structural-edit, entry/exit motion, colour cross-fade) lives in
790
- * `drawCode` (the engine's `render.ts`) as a pure function over plain
791
- * state — this just gathers that state from the node's own instance
792
- * fields and caches.
793
- */
794
- protected drawSelf(draw: RenderContext2D): void {
795
- this.advanceCache.sync(this.advanceCache.signature(this.fontSize, this.fontFamily));
796
- drawCode(draw, {
797
- tokenLines: this.tokenLines,
798
- metrics: this.metrics(),
799
- advanceCache: this.advanceCache,
800
- ...this.frameLayout(draw),
801
- tokenStates: resolveTokenStates(this.tokenLines, this.transitions, this.highlightDimOpacity, this.highlightedIds),
802
- highlightActive: this.highlightDimOpacity !== null || this.transitions.some(tr => tr.kind === "dim"),
803
- });
612
+ function rowsFrame(seen: CodeListing, from: RowBand[], to: RowBand[]): Frame | null {
613
+ if (from.length === 0 && to.length === 0) return null;
614
+ return (t) => ({
615
+ listing: seen,
616
+ banding: t >= 1 ? { bands: to, fade: null }
617
+ : t <= 0 ? { bands: from, fade: null }
618
+ : { bands: from, fade: { kind: "rows", from, to, progress: t } },
619
+ });
620
+ }
621
+
622
+ function lineLengths(lines: IdLine[]): number[] {
623
+ return lines.map((line) => line.tokens.reduce((acc, t) => acc + t.content.length, 0));
624
+ }
625
+
626
+ /** Character offset of a 1-indexed (line, col), clamped into the document. */
627
+ function offsetAt(lines: IdLine[], position: [number, number]): number {
628
+ const lens = lineLengths(lines);
629
+ if (lens.length === 0) return 0;
630
+ const [rawLine, rawCol] = position;
631
+ const li = Math.max(0, Math.min(lens.length - 1, rawLine - 1));
632
+ let offset = 0;
633
+ for (let k = 0; k < li; k++) offset += lens[k] + 1;
634
+ return offset + Math.max(0, Math.min(lens[li], rawCol - 1));
635
+ }
636
+
637
+ /** The ids of every token any of `codeRanges` overlaps, partly or wholly. */
638
+ function tokenIdsInRanges(lines: IdLine[], codeRanges: CodeRange[]): Set<number> {
639
+ const result = new Set<number>();
640
+ const lineLens = lineLengths(lines);
641
+ if (lineLens.length === 0) return result;
642
+ const offsets = codeRanges
643
+ .map((r) => rangeToCharOffsets(r, lineLens))
644
+ .filter(({ start, end }) => end > start);
645
+ if (offsets.length === 0) return result;
646
+
647
+ let off = 0;
648
+ for (let li = 0; li < lines.length; li++) {
649
+ for (const tok of lines[li].tokens) {
650
+ const tStart = off;
651
+ const tEnd = off + tok.content.length;
652
+ if (offsets.some(({ start, end }) => tEnd > start && tStart < end)) result.add(tok.id);
653
+ off = tEnd;
654
+ }
655
+ if (li < lines.length - 1) off += 1;
804
656
  }
657
+ return result;
658
+ }
659
+
660
+ /** One band per range, over every line it touches; a range past the end of the listing gives none. */
661
+ function rowBandsIn(lines: IdLine[], codeRanges: CodeRange[], color: NormalizedColor, radius: number): RowBand[] {
662
+ const count = lines.length;
663
+ const bands: RowBand[] = [];
664
+ for (const range of codeRanges) {
665
+ const first = Math.max(1, Math.round(range.startLine ?? 1));
666
+ const last = Math.min(count, range.endLine == null || range.endLine === Infinity ? count : Math.round(range.endLine));
667
+ if (first > last) continue;
668
+ bands.push({ startId: lines[first - 1].id, endId: lines[last - 1].id, color, radius });
669
+ }
670
+ return bands;
805
671
  }
806
672
 
807
673
  /**
808
- * Normalize `highlight()`'s `codeRange` argument to a plain array: a single
809
- * `CodeRange` (a plain object, not iterable) becomes a one-element array; a
810
- * `CodeRangeChain`/plain array of ranges spreads as-is. `CodeRange` and
811
- * `CodeRangeChain` are distinguished by iterability, not `instanceof` — a
812
- * bare array of `CodeRange`s (no chain) works the same way.
674
+ * A single range (a plain object) as a one-element array; a chain or an array
675
+ * of ranges as itself; the `{ list }` a chain serialises as, as that chain.
813
676
  */
814
- function normalizeCodeRanges(codeRange: CodeRange | Iterable<CodeRange>): CodeRange[] {
677
+ function normalizeCodeRanges(codeRange: CodeTarget | undefined): CodeRange[] {
815
678
  const maybeIterable = codeRange as Partial<Iterable<CodeRange>>;
816
679
  if (typeof maybeIterable[Symbol.iterator] === "function") {
817
680
  return [...(codeRange as Iterable<CodeRange>)];
818
681
  }
682
+ const stored = (codeRange as { list?: unknown }).list;
683
+ if (Array.isArray(stored)) return [...(stored as CodeRange[])];
819
684
  return [codeRange as CodeRange];
820
685
  }
821
686
 
@@ -832,3 +697,158 @@ function grammarLoader(language: string): () => Promise<void> {
832
697
  return load;
833
698
  }
834
699
 
700
+ // ── Geometry and drawing ────────────────────────────────────────────────────
701
+
702
+ interface Layouts {
703
+ readonly from: CodeLayout;
704
+ readonly to: CodeLayout;
705
+ }
706
+
707
+ /**
708
+ * A node's measurements, kept between frames: the advance of each token, and
709
+ * the layouts last built, reused while what they were built from is unchanged.
710
+ */
711
+ interface Measured {
712
+ readonly advances: TokenAdvanceCache;
713
+ measurer: Measurer | null;
714
+ /** The measurer of the node's last measure, as the engine's helpers take one. */
715
+ scope: MeasureContext2D | null;
716
+ settled: { readonly lines: IdLine[]; readonly key: string; readonly layout: CodeLayout } | null;
717
+ edit: { readonly transition: StructuralTransition; readonly key: string; readonly from: CodeLayout; readonly to: CodeLayout } | null;
718
+ }
719
+
720
+ /** By the node's state view, one object per node that `measure` and `render` are both handed. */
721
+ const measuredBy = new WeakMap<object, Measured>();
722
+
723
+ function measuredOf(state: object): Measured {
724
+ let found = measuredBy.get(state);
725
+ if (!found) measuredBy.set(state, found = { advances: new TokenAdvanceCache(), measurer: null, scope: null, settled: null, edit: null });
726
+ return found;
727
+ }
728
+
729
+ function metricsOf(s: LayoutState): CodeMetrics {
730
+ return {
731
+ fontSize: s.fontSize,
732
+ fontFamily: s.fontFamily,
733
+ lineHeight: s.lineHeight,
734
+ letterSpacing: s.letterSpacing,
735
+ padding: s.padding,
736
+ showLineNumbers: s.showLineNumbers,
737
+ lineNumberGap: s.lineNumberGap,
738
+ };
739
+ }
740
+
741
+ /**
742
+ * The one or two layouts a frame is drawn from: a settled frame has one, a
743
+ * frame inside an edit two (where every token was, and where it is going).
744
+ * `null` for a node never measured, which has nothing to measure them with.
745
+ */
746
+ function layoutsOf(measured: Measured, view: Shown, m: CodeMetrics): Layouts | null {
747
+ const signature = measured.advances.signature(m.fontSize, m.fontFamily);
748
+ measured.advances.sync(signature);
749
+ const key = `${metricsSignature(m)}|${signature}`;
750
+ const scope = measured.scope;
751
+ if (!view.transition) {
752
+ const hit = measured.settled;
753
+ if (hit && hit.lines === view.lines && hit.key === key) return { from: hit.layout, to: hit.layout };
754
+ if (!scope) return null;
755
+ const layout = layoutCode(view.lines, m, measured.advances, scope);
756
+ measured.settled = { lines: view.lines, key, layout };
757
+ return { from: layout, to: layout };
758
+ }
759
+ const hit = measured.edit;
760
+ if (hit && hit.transition === view.transition && hit.key === key) return hit;
761
+ if (!scope) return null;
762
+ const both = {
763
+ transition: view.transition,
764
+ key,
765
+ from: layoutCode(view.transition.from, m, measured.advances, scope),
766
+ to: layoutCode(view.transition.to, m, measured.advances, scope),
767
+ };
768
+ measured.edit = both;
769
+ return both;
770
+ }
771
+
772
+ function measureListing(measurer: Measurer, s: LayoutState, kit: Kit): Size2D {
773
+ const measured = measuredOf(s);
774
+ if (measured.measurer !== measurer) {
775
+ measured.measurer = measurer;
776
+ measured.scope = {
777
+ measureText: (text, fontSize, fontFamily, fontWeight, letterSpacing) =>
778
+ measurer.text(text, { fontFamily, fontSize, fontWeight, letterSpacing }),
779
+ };
780
+ }
781
+ const view = shown(s, kit);
782
+ const m = metricsOf(s);
783
+ const layouts = layoutsOf(measured, view, m);
784
+ if (!layouts) return { width: 0, height: 0 };
785
+ const t = moveProgress(view.transition ? { phases: view.transition.phases, progress: view.progress } : null);
786
+ const innerW = view.transition ? lerpNumber(layouts.from.innerW, layouts.to.innerW, t) : layouts.to.innerW;
787
+ const innerH = view.transition ? lerpNumber(layouts.from.innerH, layouts.to.innerH, t) : layouts.to.innerH;
788
+ return { width: innerW + m.padding.left + m.padding.right, height: innerH + m.padding.top + m.padding.bottom };
789
+ }
790
+
791
+ const NO_PAINT: Paint.PaintState = { fill: [], stroke: [], shadow: [], overlay: [], start: 0, end: 1, clip: false };
792
+ const NOTHING = (): void => { };
793
+
794
+ function drawListing(ctx: RenderContext, s: DrawState, kit: Kit): void {
795
+ const measured = measuredOf(s);
796
+ const view = shown(s, kit);
797
+ const m = metricsOf(s);
798
+ const layouts = layoutsOf(measured, view, m);
799
+ // Paints nothing: it declares the outline picking and backdrop effects read.
800
+ Paint.draw(ctx, NO_PAINT, null, { clipShape: outlineOf(view, layouts, m, ctx.size), children: NOTHING });
801
+ const scope = measured.scope;
802
+ if (!layouts || !scope) return;
803
+ const { focus, banding } = s;
804
+ drawCode({ draw: (graphics) => ctx.draw(graphics), measureText: scope.measureText }, {
805
+ tokenLines: view.lines,
806
+ metrics: m,
807
+ advanceCache: measured.advances,
808
+ from: layouts.from,
809
+ to: layouts.to,
810
+ edit: editOf(view),
811
+ tokenStates: resolveTokenStates(view.lines, focus.fade ? [focus.fade] : [], focus.dim, focus.ids),
812
+ highlightActive: focus.dim !== null || focus.fade !== null,
813
+ rowBands: banding.bands,
814
+ rowsEdit: banding.fade,
815
+ boxW: ctx.size.width,
816
+ });
817
+ }
818
+
819
+ /**
820
+ * A listing is grabbed by its text, line by ragged line: each line a slot as
821
+ * wide as its ink, plus the gutter where numbers show, inside the box. Code is
822
+ * mostly whitespace, and its empty corners would swallow clicks meant for what
823
+ * is behind it. Mid-edit, or before it is laid out, the box.
824
+ */
825
+ function outlineOf(view: Shown, layouts: Layouts | null, m: CodeMetrics, size: Size2D): Graphics2D {
826
+ const box = { x: 0, y: 0, width: size.width, height: size.height };
827
+ if (view.transition || !layouts) return new Graphics2D().rect(box);
828
+ const layout = layouts.to;
829
+ const half = (m.fontSize * m.lineHeight) / 2;
830
+ const gutter = m.showLineNumbers ? layout.gutter : 0;
831
+ const left = Math.max(layout.startX - gutter, -size.width / 2);
832
+ const outline = new Graphics2D();
833
+ let lines = 0;
834
+ for (let i = 0; i < layout.lineY.length; i++) {
835
+ const ink = layout.lineW[i] ?? 0;
836
+ if (ink + gutter <= 0) continue;
837
+ const right = Math.min(layout.startX + ink, size.width / 2);
838
+ const y = layout.lineY[i];
839
+ const top = Math.min(y + half, size.height / 2);
840
+ const bottom = Math.max(y - half, -size.height / 2);
841
+ if (right < left || top < bottom) continue;
842
+ // The row's own centre where the box leaves it whole, so a whole row is tested at its own centre.
843
+ const whole = top === y + half && bottom === y - half;
844
+ outline.rect({
845
+ x: (left + right) / 2,
846
+ y: whole ? y : (top + bottom) / 2,
847
+ width: right - left,
848
+ height: whole ? 2 * half : top - bottom,
849
+ });
850
+ lines++;
851
+ }
852
+ // No line has ink: the box with itself cut away, so nothing is grabbed (an empty outline would fall back to the box).
853
+ return lines > 0 ? outline : new Graphics2D().rect(box).rect(box).cut();
854
+ }