@motionscript/code 0.1.0-alpha.3 → 0.1.0-alpha.5

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 (59) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +201 -201
  3. package/NOTICE +48 -0
  4. package/README.md +161 -108
  5. package/dist/browser/THIRD_PARTY_LICENSES.txt +431 -0
  6. package/dist/browser/index.js +7 -6
  7. package/dist/browser/index.js.map +3 -3
  8. package/dist/browser/manifest.json +84 -2
  9. package/dist/code-range.d.ts +6 -0
  10. package/dist/code-range.d.ts.map +1 -1
  11. package/dist/code-range.js +8 -0
  12. package/dist/code-range.js.map +1 -1
  13. package/dist/diff.d.ts +14 -0
  14. package/dist/diff.d.ts.map +1 -1
  15. package/dist/diff.js +134 -2
  16. package/dist/diff.js.map +1 -1
  17. package/dist/engine.d.ts +1 -1
  18. package/dist/engine.d.ts.map +1 -1
  19. package/dist/engine.js +1 -1
  20. package/dist/engine.js.map +1 -1
  21. package/dist/highlight.d.ts +10 -2
  22. package/dist/highlight.d.ts.map +1 -1
  23. package/dist/highlight.js +40 -6
  24. package/dist/highlight.js.map +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/layout.js.map +1 -1
  27. package/dist/node.d.ts +42 -7
  28. package/dist/node.d.ts.map +1 -1
  29. package/dist/node.js +174 -47
  30. package/dist/node.js.map +1 -1
  31. package/dist/nodes.js.map +1 -1
  32. package/dist/render.d.ts +6 -1
  33. package/dist/render.d.ts.map +1 -1
  34. package/dist/render.js +59 -0
  35. package/dist/render.js.map +1 -1
  36. package/dist/style.d.ts +3 -3
  37. package/dist/style.d.ts.map +1 -1
  38. package/dist/style.js +10 -6
  39. package/dist/style.js.map +1 -1
  40. package/dist/tokens.d.ts.map +1 -1
  41. package/dist/tokens.js +5 -1
  42. package/dist/tokens.js.map +1 -1
  43. package/dist/transitions.d.ts +19 -1
  44. package/dist/transitions.d.ts.map +1 -1
  45. package/dist/transitions.js.map +1 -1
  46. package/package.json +7 -5
  47. package/registry.json +6 -6
  48. package/src/code-range.ts +165 -156
  49. package/src/diff.ts +351 -216
  50. package/src/engine.ts +21 -21
  51. package/src/highlight.ts +340 -291
  52. package/src/index.ts +3 -3
  53. package/src/layout.ts +157 -157
  54. package/src/node.ts +1038 -904
  55. package/src/nodes.ts +18 -18
  56. package/src/render.ts +312 -247
  57. package/src/style.ts +255 -251
  58. package/src/tokens.ts +79 -75
  59. package/src/transitions.ts +204 -184
package/src/nodes.ts CHANGED
@@ -1,18 +1,18 @@
1
- import { Code } from "./node";
2
-
3
- /**
4
- * Every node type this package publishes.
5
- *
6
- * A host registers these by handing the array to an engine —
7
- * `new Engine(platform, { nodes: NODES })` — which reads each class's `@node()`
8
- * key. The decorator only *declares* that key: nothing is registered by the mere
9
- * act of importing a module, so a registry holds exactly what its owner asked
10
- * for and two engines in one process can differ about it.
11
- *
12
- * Listing class **values** is also what survives bundling. This package declares
13
- * `sideEffects: false`, which licenses a bundler to drop a module nothing
14
- * imports a value from, and a document names a node type by string — so
15
- * `import "./node"` for its side effect is exactly what gets shaken out, where an
16
- * array of bindings is a data dependency that cannot be.
17
- */
18
- export const NODES = [Code];
1
+ import { Code } from "./node";
2
+
3
+ /**
4
+ * Every node type this package publishes.
5
+ *
6
+ * A host registers these by handing the array to an engine —
7
+ * `new Engine(platform, { nodes: NODES })` — which reads each class's `@node()`
8
+ * key. The decorator only *declares* that key: nothing is registered by the mere
9
+ * act of importing a module, so a registry holds exactly what its owner asked
10
+ * for and two engines in one process can differ about it.
11
+ *
12
+ * Listing class **values** is also what survives bundling. This package declares
13
+ * `sideEffects: false`, which licenses a bundler to drop a module nothing
14
+ * imports a value from, and a document names a node type by string — so
15
+ * `import "./node"` for its side effect is exactly what gets shaken out, where an
16
+ * array of bindings is a data dependency that cannot be.
17
+ */
18
+ export const NODES = [Code];
package/src/render.ts CHANGED
@@ -1,247 +1,312 @@
1
- import { RenderContext2D, Graphics2D, NormalizedColor, parseColor, lerpNumber } from "@motionscript/sdk";
2
- import type { CodeLayout, CodeMetrics } from "./layout";
3
- import type { IdLine } from "./tokens";
4
- import { windowProgress, moveProgress } from "./transitions";
5
- import type { TokenState, StructuralTransition } from "./transitions";
6
- import type { TokenAdvanceCache } from "./measure-cache";
7
-
8
- /** Colour of a token the grammar had no opinion about. */
9
- const DEFAULT_TOKEN_COLOR: NormalizedColor = [0.82, 0.84, 0.86, 1];
10
- /** Gutter line-number colour: a muted version of the standard text colour. */
11
- const LINE_NUMBER_COLOR: NormalizedColor = [0.45, 0.5, 0.55, 1];
12
-
13
- // `parseColor` walks a CSS string; a listing asks for the same handful of theme
14
- // colours on every token of every frame, so the parse is memoized. Module-level,
15
- // because the answer depends only on the string.
16
- const colorCache = new Map<string, NormalizedColor>();
17
- function tokenColor(css: string | undefined): NormalizedColor {
18
- if (!css) return DEFAULT_TOKEN_COLOR;
19
- let hit = colorCache.get(css);
20
- if (!hit) {
21
- hit = parseColor(css);
22
- colorCache.set(css, hit);
23
- }
24
- return hit;
25
- }
26
-
27
- function lerpColor(from: NormalizedColor, to: NormalizedColor, t: number): NormalizedColor {
28
- if (t <= 0) return from;
29
- if (t >= 1) return to;
30
- return [
31
- from[0] + (to[0] - from[0]) * t,
32
- from[1] + (to[1] - from[1]) * t,
33
- from[2] + (to[2] - from[2]) * t,
34
- from[3] + (to[3] - from[3]) * t,
35
- ];
36
- }
37
-
38
- // Opacity multiplier for a line's number under the active highlight. The
39
- // number stays bright only when the WHOLE line is highlighted, so we take the
40
- // min token opacity on the line. Returns 1 (no dimming) when no highlight is
41
- // active, so an edit's entries don't drag the number down.
42
- function lineHighlightOpacity(line: IdLine, stateById: Map<number, TokenState>, highlightActive: boolean): number {
43
- if (!highlightActive) return 1;
44
- let min = 1;
45
- for (const tok of line.tokens) {
46
- if (tok.content.length === 0) continue;
47
- const op = stateById.get(tok.id)?.opacity ?? 1;
48
- if (op < min) min = op;
49
- }
50
- return min;
51
- }
52
-
53
- /** Everything a `Code` node's own instance state supplies to draw one frame. */
54
- export interface DrawCodeState {
55
- tokenLines: IdLine[];
56
- metrics: CodeMetrics;
57
- advanceCache: TokenAdvanceCache;
58
- /** The one or two layouts this frame draws from — see `frameLayout` in `layout.ts`. */
59
- from: CodeLayout;
60
- to: CodeLayout;
61
- edit: StructuralTransition | null;
62
- /** Per-token opacity from `resolveTokenStates` (persistent highlight + any active dim transition). */
63
- tokenStates: Map<number, TokenState>;
64
- /** Whether a highlight (persistent dim, or a highlight()/resetHighlight() cross-fade) is currently engaged. */
65
- highlightActive: boolean;
66
- }
67
-
68
- /**
69
- * Draw one frame of a code listing: the settled structure, or — mid
70
- * structural edit — the two structures it is interpolating between (what
71
- * stays reflows, what is leaving fades out where it was, what is arriving
72
- * fades/slides in where it lands). Pure over `state`; touches nothing but
73
- * `draw`.
74
- */
75
- export function drawCode(draw: RenderContext2D, state: DrawCodeState): void {
76
- const { tokenLines, metrics, advanceCache, from, to, edit, tokenStates: dim } = state;
77
-
78
- const progress = edit ? edit.progress : 1;
79
- const pOut = edit ? windowProgress(edit.phases.out, progress) : 1;
80
- const pMove = moveProgress(edit);
81
- const pIn = edit ? windowProgress(edit.phases.in, progress) : 1;
82
-
83
- const blockW = edit ? lerpNumber(from.blockW, to.blockW, pMove) : to.blockW;
84
- const gutter = edit ? lerpNumber(from.gutter, to.gutter, pMove) : to.gutter;
85
- const gutterGap = to.gutterGap || from.gutterGap;
86
- // Right edge of the line-number column: one gap to the left of where the
87
- // code text begins.
88
- const numberRight = -blockW / 2 + metrics.padding.left + gutter - gutterGap;
89
-
90
- const drawNumber = (label: string, y: number, opacity: number): void => {
91
- if (opacity <= 0) return;
92
- const labelW = advanceCache.advance(draw, label, metrics.fontSize, metrics.fontFamily, 0);
93
- draw.draw(new Graphics2D()
94
- .text({
95
- text: label,
96
- fontSize: metrics.fontSize,
97
- fontFamily: metrics.fontFamily,
98
- lineHeight: metrics.lineHeight,
99
- x: numberRight - labelW / 2,
100
- y,
101
- textAlign: 'left',
102
- })
103
- .fill([{ type: "solid", color: LINE_NUMBER_COLOR, opacity }]));
104
- };
105
-
106
- const drawToken = (text: string, x: number, y: number, color: NormalizedColor, opacity: number): void => {
107
- if (opacity <= 0 || text.length === 0) return;
108
- const width = advanceCache.advance(draw, text, metrics.fontSize, metrics.fontFamily, metrics.letterSpacing);
109
- draw.draw(new Graphics2D()
110
- .text({
111
- text,
112
- fontSize: metrics.fontSize,
113
- fontFamily: metrics.fontFamily,
114
- lineHeight: metrics.lineHeight,
115
- letterSpacing: metrics.letterSpacing,
116
- // The renderer centres a single-token block on the (x, y) it is
117
- // given, so every token is anchored at the centre of its cell
118
- // rather than at its left edge. Passing lineHeight makes the
119
- // block's height deterministic (fontSize × lineHeight) rather
120
- // than the font's natural metrics, so the vertical centre
121
- // lands exactly on the slot centre.
122
- x: x + width / 2,
123
- y,
124
- textAlign: 'left',
125
- })
126
- .fill([{ type: "solid", color, opacity }]));
127
- };
128
-
129
- // What survived the edit, and what it brought with it — drawn from the
130
- // structure the edit lands on.
131
- for (let i = 0; i < tokenLines.length; i++) {
132
- const line = tokenLines[i];
133
- const toIndex = to.lineIndex.get(line.id);
134
- if (toIndex === undefined) continue;
135
- const toY = to.lineY[toIndex];
136
-
137
- const entering = edit?.enteringLineIds.has(line.id) ?? false;
138
- let rowY = toY;
139
- let rowAlpha = 1;
140
- if (edit && entering) {
141
- // A wholly new row fades as one piece, in the place it lands — the
142
- // line arrives, it does not travel to get there, and it is not a
143
- // spray of glyphs each finding its own way in. See
144
- // `StructuralTransition.enteringLineIds`.
145
- rowAlpha = pIn;
146
- } else if (edit) {
147
- const fromIndex = from.lineIndex.get(line.id);
148
- if (fromIndex !== undefined) rowY = lerpNumber(from.lineY[fromIndex], toY, pMove);
149
- }
150
-
151
- for (const token of line.tokens) {
152
- const box = to.tokens.get(token.id);
153
- if (!box) continue;
154
-
155
- let x = box.x;
156
- let alpha = rowAlpha;
157
- let color = tokenColor(token.color);
158
-
159
- if (edit && !entering) {
160
- if (edit.addedIds.has(token.id)) {
161
- // Spliced into a row that already existed: the reflow
162
- // already opened the space, so this only has to appear.
163
- alpha = pIn;
164
- } else {
165
- const was = from.tokens.get(token.id);
166
- if (was) x = lerpNumber(was.x, box.x, pMove);
167
- if (edit.fromColorById.has(token.id)) {
168
- color = lerpColor(tokenColor(edit.fromColorById.get(token.id)), color, pMove);
169
- }
170
- }
171
- }
172
-
173
- drawToken(token.content, x, rowY, color, alpha * (dim.get(token.id)?.opacity ?? 1));
174
- }
175
- }
176
-
177
- // What the edit takes away, drawn from the structure it is leaving — it
178
- // has no place in the new one, which is the whole reason it is fading.
179
- if (edit && pOut < 1) {
180
- for (let i = 0; i < edit.from.length; i++) {
181
- const line = edit.from[i];
182
- const fromIndex = from.lineIndex.get(line.id);
183
- if (fromIndex === undefined) continue;
184
- const toIndex = to.lineIndex.get(line.id);
185
- // A row that survived the edit is still moving, so its outgoing
186
- // tokens travel with it; a deleted row stays where it was and
187
- // dissolves while the rows below close over it.
188
- const y = toIndex === undefined
189
- ? from.lineY[fromIndex]
190
- : lerpNumber(from.lineY[fromIndex], to.lineY[toIndex], pMove);
191
-
192
- for (const token of line.tokens) {
193
- if (!edit.removedIds.has(token.id)) continue;
194
- const box = from.tokens.get(token.id);
195
- if (!box) continue;
196
- drawToken(token.content, box.x, y, tokenColor(token.color), 1 - pOut);
197
- }
198
- }
199
- }
200
-
201
- if (metrics.showLineNumbers) drawLineNumbers(state, drawNumber, pMove, pIn, pOut);
202
- }
203
-
204
- /**
205
- * Draw the gutter — one number per *slot*, not one per row.
206
- *
207
- * That distinction is the whole of it. A line number is an ordinal of the
208
- * block: "3" means the third line, and it is there for exactly as long as the
209
- * listing has a third line. Drawing it alongside the row it labels — the
210
- * obvious thing, since that is where it appears — tied it to the row's identity
211
- * instead, so a line rewritten past the point the diff could pair it took its
212
- * number down with it and brought an identical one back up. The number blinked
213
- * although it had never gone away, and every substantially rewritten line did
214
- * it at once.
215
- *
216
- * Numbering the slots means only the numbers at the end move at all: those the
217
- * block grew to reach fade in, those it shrank past fade out, and every number
218
- * both structures have simply travels to its new row height. Which is also the
219
- * honest reading of the edit — inserting a line in the middle does not make a
220
- * new number appear there, it makes the block one line longer.
221
- */
222
- function drawLineNumbers(
223
- state: DrawCodeState,
224
- drawNumber: (label: string, y: number, opacity: number) => void,
225
- pMove: number,
226
- pIn: number,
227
- pOut: number,
228
- ): void {
229
- const { tokenLines, from, to, edit, tokenStates: dim, highlightActive } = state;
230
-
231
- const toCount = tokenLines.length;
232
- const fromCount = edit ? edit.from.length : toCount;
233
-
234
- for (let i = 0; i < Math.max(fromCount, toCount); i++) {
235
- const inFrom = i < fromCount;
236
- const inTo = i < toCount;
237
- // Highlight dimming follows whatever content occupies the slot, which
238
- // is the new listing wherever there is one.
239
- const line = inTo ? tokenLines[i] : edit?.from[i];
240
- const dimming = line ? lineHighlightOpacity(line, dim, highlightActive) : 1;
241
-
242
- if (!edit) drawNumber(String(i + 1), to.lineY[i], dimming);
243
- else if (inFrom && inTo) drawNumber(String(i + 1), lerpNumber(from.lineY[i], to.lineY[i], pMove), dimming);
244
- else if (inTo) drawNumber(String(i + 1), to.lineY[i], pIn * dimming);
245
- else drawNumber(String(i + 1), from.lineY[i], (1 - pOut) * dimming);
246
- }
247
- }
1
+ import { RenderContext2D, Graphics2D, NormalizedColor, parseColor, lerpNumber } from "@motionscript/sdk";
2
+ import type { CodeLayout, CodeMetrics } from "./layout";
3
+ import type { IdLine } from "./tokens";
4
+ import { windowProgress, moveProgress } from "./transitions";
5
+ import type { TokenState, StructuralTransition, RowBand, RowsTransition } from "./transitions";
6
+ import type { TokenAdvanceCache } from "./measure-cache";
7
+
8
+ /** Colour of a token the grammar had no opinion about. */
9
+ const DEFAULT_TOKEN_COLOR: NormalizedColor = [0.82, 0.84, 0.86, 1];
10
+ /** Gutter line-number colour: a muted version of the standard text colour. */
11
+ const LINE_NUMBER_COLOR: NormalizedColor = [0.45, 0.5, 0.55, 1];
12
+
13
+ // `parseColor` walks a CSS string; a listing asks for the same handful of theme
14
+ // colours on every token of every frame, so the parse is memoized. Module-level,
15
+ // because the answer depends only on the string.
16
+ const colorCache = new Map<string, NormalizedColor>();
17
+ function tokenColor(css: string | undefined): NormalizedColor {
18
+ if (!css) return DEFAULT_TOKEN_COLOR;
19
+ let hit = colorCache.get(css);
20
+ if (!hit) {
21
+ hit = parseColor(css);
22
+ colorCache.set(css, hit);
23
+ }
24
+ return hit;
25
+ }
26
+
27
+ function lerpColor(from: NormalizedColor, to: NormalizedColor, t: number): NormalizedColor {
28
+ if (t <= 0) return from;
29
+ if (t >= 1) return to;
30
+ return [
31
+ from[0] + (to[0] - from[0]) * t,
32
+ from[1] + (to[1] - from[1]) * t,
33
+ from[2] + (to[2] - from[2]) * t,
34
+ from[3] + (to[3] - from[3]) * t,
35
+ ];
36
+ }
37
+
38
+ // Opacity multiplier for a line's number under the active highlight. The
39
+ // number stays bright only when the WHOLE line is highlighted, so we take the
40
+ // min token opacity on the line. Returns 1 (no dimming) when no highlight is
41
+ // active, so an edit's entries don't drag the number down.
42
+ function lineHighlightOpacity(line: IdLine, stateById: Map<number, TokenState>, highlightActive: boolean): number {
43
+ if (!highlightActive) return 1;
44
+ let min = 1;
45
+ for (const tok of line.tokens) {
46
+ if (tok.content.length === 0) continue;
47
+ const op = stateById.get(tok.id)?.opacity ?? 1;
48
+ if (op < min) min = op;
49
+ }
50
+ return min;
51
+ }
52
+
53
+ /** Everything a `Code` node's own instance state supplies to draw one frame. */
54
+ export interface DrawCodeState {
55
+ tokenLines: IdLine[];
56
+ metrics: CodeMetrics;
57
+ advanceCache: TokenAdvanceCache;
58
+ /** The one or two layouts this frame draws from — see `frameLayout` in `layout.ts`. */
59
+ from: CodeLayout;
60
+ to: CodeLayout;
61
+ edit: StructuralTransition | null;
62
+ /** Per-token opacity from `resolveTokenStates` (persistent highlight + any active dim transition). */
63
+ tokenStates: Map<number, TokenState>;
64
+ /** Whether a highlight (persistent dim, or a highlight()/resetHighlight() cross-fade) is currently engaged. */
65
+ highlightActive: boolean;
66
+ /** The settled row bands, and the change between two sets of them in flight, if any. */
67
+ rowBands?: RowBand[];
68
+ rowsEdit?: RowsTransition | null;
69
+ /** The node's laid-out width, which a band spans when it is wider than the block. */
70
+ boxW?: number;
71
+ }
72
+
73
+ /**
74
+ * Draw one frame of a code listing: the settled structure, or — mid
75
+ * structural edit — the two structures it is interpolating between (what
76
+ * stays reflows, what is leaving fades out where it was, what is arriving
77
+ * fades/slides in where it lands). Pure over `state`; touches nothing but
78
+ * `draw`.
79
+ */
80
+ export function drawCode(draw: RenderContext2D, state: DrawCodeState): void {
81
+ const { tokenLines, metrics, advanceCache, from, to, edit, tokenStates: dim } = state;
82
+
83
+ const progress = edit ? edit.progress : 1;
84
+ const pOut = edit ? windowProgress(edit.phases.out, progress) : 1;
85
+ const pMove = moveProgress(edit);
86
+ const pIn = edit ? windowProgress(edit.phases.in, progress) : 1;
87
+
88
+ const blockW = edit ? lerpNumber(from.blockW, to.blockW, pMove) : to.blockW;
89
+ const gutter = edit ? lerpNumber(from.gutter, to.gutter, pMove) : to.gutter;
90
+ const gutterGap = to.gutterGap || from.gutterGap;
91
+ // Right edge of the line-number column: one gap to the left of where the
92
+ // code text begins.
93
+ const numberRight = -blockW / 2 + metrics.padding.left + gutter - gutterGap;
94
+
95
+ drawRowBands(draw, state, Math.max(blockW, state.boxW ?? 0), pMove);
96
+
97
+ const drawNumber = (label: string, y: number, opacity: number): void => {
98
+ if (opacity <= 0) return;
99
+ const labelW = advanceCache.advance(draw, label, metrics.fontSize, metrics.fontFamily, 0);
100
+ draw.draw(new Graphics2D()
101
+ .text({
102
+ text: label,
103
+ fontSize: metrics.fontSize,
104
+ fontFamily: metrics.fontFamily,
105
+ lineHeight: metrics.lineHeight,
106
+ x: numberRight - labelW / 2,
107
+ y,
108
+ textAlign: 'left',
109
+ })
110
+ .fill([{ type: "solid", color: LINE_NUMBER_COLOR, opacity }]));
111
+ };
112
+
113
+ const drawToken = (text: string, x: number, y: number, color: NormalizedColor, opacity: number): void => {
114
+ if (opacity <= 0 || text.length === 0) return;
115
+ const width = advanceCache.advance(draw, text, metrics.fontSize, metrics.fontFamily, metrics.letterSpacing);
116
+ draw.draw(new Graphics2D()
117
+ .text({
118
+ text,
119
+ fontSize: metrics.fontSize,
120
+ fontFamily: metrics.fontFamily,
121
+ lineHeight: metrics.lineHeight,
122
+ letterSpacing: metrics.letterSpacing,
123
+ // The renderer centres a single-token block on the (x, y) it is
124
+ // given, so every token is anchored at the centre of its cell
125
+ // rather than at its left edge. Passing lineHeight makes the
126
+ // block's height deterministic (fontSize × lineHeight) rather
127
+ // than the font's natural metrics, so the vertical centre
128
+ // lands exactly on the slot centre.
129
+ x: x + width / 2,
130
+ y,
131
+ textAlign: 'left',
132
+ })
133
+ .fill([{ type: "solid", color, opacity }]));
134
+ };
135
+
136
+ // What survived the edit, and what it brought with it — drawn from the
137
+ // structure the edit lands on.
138
+ for (let i = 0; i < tokenLines.length; i++) {
139
+ const line = tokenLines[i];
140
+ const toIndex = to.lineIndex.get(line.id);
141
+ if (toIndex === undefined) continue;
142
+ const toY = to.lineY[toIndex];
143
+
144
+ const entering = edit?.enteringLineIds.has(line.id) ?? false;
145
+ let rowY = toY;
146
+ let rowAlpha = 1;
147
+ if (edit && entering) {
148
+ // A wholly new row fades as one piece, in the place it lands — the
149
+ // line arrives, it does not travel to get there, and it is not a
150
+ // spray of glyphs each finding its own way in. See
151
+ // `StructuralTransition.enteringLineIds`.
152
+ rowAlpha = pIn;
153
+ } else if (edit) {
154
+ const fromIndex = from.lineIndex.get(line.id);
155
+ if (fromIndex !== undefined) rowY = lerpNumber(from.lineY[fromIndex], toY, pMove);
156
+ }
157
+
158
+ for (const token of line.tokens) {
159
+ const box = to.tokens.get(token.id);
160
+ if (!box) continue;
161
+
162
+ let x = box.x;
163
+ let alpha = rowAlpha;
164
+ let color = tokenColor(token.color);
165
+
166
+ if (edit && !entering) {
167
+ if (edit.addedIds.has(token.id)) {
168
+ // Spliced into a row that already existed: the reflow
169
+ // already opened the space, so this only has to appear.
170
+ alpha = pIn;
171
+ } else {
172
+ const was = from.tokens.get(token.id);
173
+ if (was) x = lerpNumber(was.x, box.x, pMove);
174
+ if (edit.fromColorById.has(token.id)) {
175
+ color = lerpColor(tokenColor(edit.fromColorById.get(token.id)), color, pMove);
176
+ }
177
+ }
178
+ }
179
+
180
+ drawToken(token.content, x, rowY, color, alpha * (dim.get(token.id)?.opacity ?? 1));
181
+ }
182
+ }
183
+
184
+ // What the edit takes away, drawn from the structure it is leaving — it
185
+ // has no place in the new one, which is the whole reason it is fading.
186
+ if (edit && pOut < 1) {
187
+ for (let i = 0; i < edit.from.length; i++) {
188
+ const line = edit.from[i];
189
+ const fromIndex = from.lineIndex.get(line.id);
190
+ if (fromIndex === undefined) continue;
191
+ const toIndex = to.lineIndex.get(line.id);
192
+ // A row that survived the edit is still moving, so its outgoing
193
+ // tokens travel with it; a deleted row stays where it was and
194
+ // dissolves while the rows below close over it.
195
+ const y = toIndex === undefined
196
+ ? from.lineY[fromIndex]
197
+ : lerpNumber(from.lineY[fromIndex], to.lineY[toIndex], pMove);
198
+
199
+ for (const token of line.tokens) {
200
+ if (!edit.removedIds.has(token.id)) continue;
201
+ const box = from.tokens.get(token.id);
202
+ if (!box) continue;
203
+ drawToken(token.content, box.x, y, tokenColor(token.color), 1 - pOut);
204
+ }
205
+ }
206
+ }
207
+
208
+ if (metrics.showLineNumbers) drawLineNumbers(state, drawNumber, pMove, pIn, pOut);
209
+ }
210
+
211
+ /** A band's top and bottom edge this frame, or `null` when neither of its rows is in the listing any more. */
212
+ function bandEdges(band: RowBand, state: DrawCodeState, pMove: number): { top: number; bottom: number } | null {
213
+ const { from, to, metrics } = state;
214
+ const half = (metrics.fontSize * metrics.lineHeight) / 2;
215
+ const centre = (id: number): number | null => {
216
+ const toIndex = to.lineIndex.get(id);
217
+ const fromIndex = from.lineIndex.get(id);
218
+ if (toIndex !== undefined && fromIndex !== undefined) return lerpNumber(from.lineY[fromIndex], to.lineY[toIndex], pMove);
219
+ if (toIndex !== undefined) return to.lineY[toIndex];
220
+ return fromIndex === undefined ? null : from.lineY[fromIndex];
221
+ };
222
+ const first = centre(band.startId);
223
+ const last = centre(band.endId);
224
+ if (first === null && last === null) return null;
225
+ return { top: (first ?? last as number) + half, bottom: (last ?? first as number) - half };
226
+ }
227
+
228
+ /**
229
+ * Draw the row bands, under the text and across the whole block. A band rides
230
+ * its rows through a structural edit; between two sets, bands paired by position
231
+ * slide and recolour while an unpaired one fades.
232
+ */
233
+ function drawRowBands(draw: RenderContext2D, state: DrawCodeState, width: number, pMove: number): void {
234
+ const { rowBands = [], rowsEdit = null } = state;
235
+ const paint = (top: number, bottom: number, color: NormalizedColor, radius: number, opacity: number): void => {
236
+ if (opacity <= 0 || top <= bottom) return;
237
+ draw.draw(new Graphics2D()
238
+ .rect({ x: 0, y: (top + bottom) / 2, width, height: top - bottom, cornerRadius: radius })
239
+ .fill([{ type: "solid", color, opacity }]));
240
+ };
241
+
242
+ if (!rowsEdit) {
243
+ for (const band of rowBands) {
244
+ const edges = bandEdges(band, state, pMove);
245
+ if (edges) paint(edges.top, edges.bottom, band.color, band.radius, 1);
246
+ }
247
+ return;
248
+ }
249
+
250
+ const p = rowsEdit.progress;
251
+ for (let i = 0; i < Math.max(rowsEdit.from.length, rowsEdit.to.length); i++) {
252
+ const was = rowsEdit.from[i];
253
+ const next = rowsEdit.to[i];
254
+ const a = was ? bandEdges(was, state, pMove) : null;
255
+ const b = next ? bandEdges(next, state, pMove) : null;
256
+ if (a && b) {
257
+ paint(
258
+ lerpNumber(a.top, b.top, p), lerpNumber(a.bottom, b.bottom, p),
259
+ lerpColor(was.color, next.color, p), lerpNumber(was.radius, next.radius, p), 1,
260
+ );
261
+ } else if (a) {
262
+ paint(a.top, a.bottom, was.color, was.radius, 1 - p);
263
+ } else if (b) {
264
+ paint(b.top, b.bottom, next.color, next.radius, p);
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Draw the gutter — one number per *slot*, not one per row.
271
+ *
272
+ * That distinction is the whole of it. A line number is an ordinal of the
273
+ * block: "3" means the third line, and it is there for exactly as long as the
274
+ * listing has a third line. Drawing it alongside the row it labels — the
275
+ * obvious thing, since that is where it appears — tied it to the row's identity
276
+ * instead, so a line rewritten past the point the diff could pair it took its
277
+ * number down with it and brought an identical one back up. The number blinked
278
+ * although it had never gone away, and every substantially rewritten line did
279
+ * it at once.
280
+ *
281
+ * Numbering the slots means only the numbers at the end move at all: those the
282
+ * block grew to reach fade in, those it shrank past fade out, and every number
283
+ * both structures have simply travels to its new row height. Which is also the
284
+ * honest reading of the edit — inserting a line in the middle does not make a
285
+ * new number appear there, it makes the block one line longer.
286
+ */
287
+ function drawLineNumbers(
288
+ state: DrawCodeState,
289
+ drawNumber: (label: string, y: number, opacity: number) => void,
290
+ pMove: number,
291
+ pIn: number,
292
+ pOut: number,
293
+ ): void {
294
+ const { tokenLines, from, to, edit, tokenStates: dim, highlightActive } = state;
295
+
296
+ const toCount = tokenLines.length;
297
+ const fromCount = edit ? edit.from.length : toCount;
298
+
299
+ for (let i = 0; i < Math.max(fromCount, toCount); i++) {
300
+ const inFrom = i < fromCount;
301
+ const inTo = i < toCount;
302
+ // Highlight dimming follows whatever content occupies the slot, which
303
+ // is the new listing wherever there is one.
304
+ const line = inTo ? tokenLines[i] : edit?.from[i];
305
+ const dimming = line ? lineHighlightOpacity(line, dim, highlightActive) : 1;
306
+
307
+ if (!edit) drawNumber(String(i + 1), to.lineY[i], dimming);
308
+ else if (inFrom && inTo) drawNumber(String(i + 1), lerpNumber(from.lineY[i], to.lineY[i], pMove), dimming);
309
+ else if (inTo) drawNumber(String(i + 1), to.lineY[i], pIn * dimming);
310
+ else drawNumber(String(i + 1), from.lineY[i], (1 - pOut) * dimming);
311
+ }
312
+ }