@motionscript/code 0.1.0-alpha.8 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/LICENSE +201 -201
- package/NOTICE +49 -49
- package/README.md +175 -176
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/manifest.json +1 -1
- package/dist/code-range.js.map +1 -1
- package/dist/diff.js.map +1 -1
- package/dist/engine.js +3 -3
- package/dist/engine.js.map +1 -1
- package/dist/highlight.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout.js.map +1 -1
- package/dist/node.js.map +1 -1
- package/dist/nodes.js.map +1 -1
- package/dist/render.js.map +1 -1
- package/dist/style.js.map +1 -1
- package/dist/tokens.js.map +1 -1
- package/dist/transitions.js.map +1 -1
- package/package.json +4 -4
- package/registry.json +6 -6
- package/src/code-range.ts +165 -165
- package/src/diff.ts +351 -351
- package/src/engine.ts +21 -21
- package/src/highlight.ts +340 -340
- package/src/index.ts +3 -3
- package/src/layout.ts +157 -157
- package/src/node.ts +854 -854
- package/src/nodes.ts +16 -16
- package/src/render.ts +315 -315
- package/src/style.ts +255 -255
- package/src/tokens.ts +79 -79
- package/src/transitions.ts +204 -204
package/src/node.ts
CHANGED
|
@@ -1,854 +1,854 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
20
|
-
import {
|
|
21
|
-
DefaultHighlightStyle,
|
|
22
|
-
TokenAdvanceCache,
|
|
23
|
-
canHighlight,
|
|
24
|
-
charOffsetsToRange,
|
|
25
|
-
defaultCodeDiff,
|
|
26
|
-
defaultCodePhases,
|
|
27
|
-
drawCode,
|
|
28
|
-
ensureHighlighter,
|
|
29
|
-
layoutCode,
|
|
30
|
-
makeAnim,
|
|
31
|
-
metricsSignature,
|
|
32
|
-
moveProgress,
|
|
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,
|
|
49
|
-
} from "./engine";
|
|
50
|
-
|
|
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;
|
|
54
|
-
|
|
55
|
-
/**
|
|
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.
|
|
59
|
-
*/
|
|
60
|
-
export interface CodeStrategies {
|
|
61
|
-
/** Decides which token became which across an edit. Defaults to `defaultCodeDiff`. */
|
|
62
|
-
diffStrategy?: CodeDiffStrategy;
|
|
63
|
-
/** Decides when (in normalized transition time) each part of an edit happens. Defaults to `defaultCodePhases`. */
|
|
64
|
-
phaseStrategy?: CodePhaseStrategy;
|
|
65
|
-
}
|
|
66
|
-
|
|
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
|
-
}
|
|
73
|
-
|
|
74
|
-
/** What `code` holds: a source, or a {@link CodeBlend} while a `to({ code })` runs. */
|
|
75
|
-
export type CodeSource = string | CodeBlend;
|
|
76
|
-
|
|
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[] };
|
|
79
|
-
|
|
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
|
-
}
|
|
86
|
-
|
|
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
|
-
}
|
|
93
|
-
|
|
94
|
-
export interface CodeEditFrame {
|
|
95
|
-
readonly transition: StructuralTransition;
|
|
96
|
-
readonly progress: number;
|
|
97
|
-
}
|
|
98
|
-
|
|
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
|
-
}
|
|
105
|
-
|
|
106
|
-
export interface CodeBanding {
|
|
107
|
-
readonly bands: RowBand[];
|
|
108
|
-
readonly fade: RowsTransition | null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Each `Code` component's strategies and edit cache, by the node class it generated. */
|
|
112
|
-
const kits = new WeakMap<object, Kit>();
|
|
113
|
-
|
|
114
|
-
const NO_FOCUS: CodeFocus = { dim: null, ids: new Set(), fade: null };
|
|
115
|
-
const NO_BANDING: CodeBanding = { bands: [], fade: null };
|
|
116
|
-
|
|
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
|
-
}
|
|
121
|
-
|
|
122
|
-
const TARGET = Types.custom<CodeTarget | undefined>(undefined, { kind: "selector" });
|
|
123
|
-
const TEXT = Types.text("", { multiline: true });
|
|
124
|
-
|
|
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
|
-
}
|
|
263
|
-
|
|
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
|
-
}
|
|
280
|
-
|
|
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
|
-
}
|
|
296
|
-
|
|
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
|
-
}
|
|
301
|
-
|
|
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
|
-
}
|
|
306
|
-
|
|
307
|
-
// ── What a listing shows ────────────────────────────────────────────────────
|
|
308
|
-
|
|
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
|
-
}
|
|
315
|
-
|
|
316
|
-
interface Edited {
|
|
317
|
-
readonly transition: StructuralTransition;
|
|
318
|
-
readonly listing: CodeListing;
|
|
319
|
-
}
|
|
320
|
-
|
|
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
|
-
}
|
|
329
|
-
|
|
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
|
-
}
|
|
339
|
-
|
|
340
|
-
interface DrawState extends LayoutState {
|
|
341
|
-
readonly focus: CodeFocus;
|
|
342
|
-
readonly banding: CodeBanding;
|
|
343
|
-
}
|
|
344
|
-
|
|
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
|
-
}
|
|
352
|
-
|
|
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
|
-
}
|
|
367
|
-
|
|
368
|
-
function editOf(view: Shown): StructuralTransition | null {
|
|
369
|
-
return view.transition ? { ...view.transition, progress: view.progress } : null;
|
|
370
|
-
}
|
|
371
|
-
|
|
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
|
-
}
|
|
376
|
-
|
|
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
|
-
}
|
|
392
|
-
|
|
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
|
-
}
|
|
396
|
-
|
|
397
|
-
function sameBasis(a: CodeTokenBasis, b: CodeTokenBasis): boolean {
|
|
398
|
-
return a.language === b.language && a.theme === b.theme && a.highlighted === b.highlighted;
|
|
399
|
-
}
|
|
400
|
-
|
|
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
|
-
}
|
|
405
|
-
|
|
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
|
-
}
|
|
421
|
-
|
|
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}`;
|
|
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
|
-
}
|
|
504
|
-
|
|
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;
|
|
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 ────────────────────────────────────────────────────────────────
|
|
521
|
-
|
|
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;
|
|
560
|
-
}
|
|
561
|
-
if (end <= start) return null;
|
|
562
|
-
return editFrame(kit, seen, source.slice(0, start) + source.slice(end), basisOf(state));
|
|
563
|
-
}
|
|
564
|
-
|
|
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
|
-
}
|
|
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
|
-
}
|
|
591
|
-
|
|
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] }));
|
|
603
|
-
}
|
|
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
|
-
}
|
|
611
|
-
|
|
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;
|
|
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;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/**
|
|
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.
|
|
676
|
-
*/
|
|
677
|
-
function normalizeCodeRanges(codeRange: CodeTarget | undefined): CodeRange[] {
|
|
678
|
-
const maybeIterable = codeRange as Partial<Iterable<CodeRange>>;
|
|
679
|
-
if (typeof maybeIterable[Symbol.iterator] === "function") {
|
|
680
|
-
return [...(codeRange as Iterable<CodeRange>)];
|
|
681
|
-
}
|
|
682
|
-
const stored = (codeRange as { list?: unknown }).list;
|
|
683
|
-
if (Array.isArray(stored)) return [...(stored as CodeRange[])];
|
|
684
|
-
return [codeRange as CodeRange];
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const grammarLoaders = new Map<string, () => Promise<void>>();
|
|
688
|
-
|
|
689
|
-
// One function per language: blocks sharing a grammar declare one key, and a
|
|
690
|
-
// second load function for a key already declared is ignored with a warning.
|
|
691
|
-
function grammarLoader(language: string): () => Promise<void> {
|
|
692
|
-
let load = grammarLoaders.get(language);
|
|
693
|
-
if (load === undefined) {
|
|
694
|
-
load = () => ensureHighlighter(undefined, [language]);
|
|
695
|
-
grammarLoaders.set(language, load);
|
|
696
|
-
}
|
|
697
|
-
return load;
|
|
698
|
-
}
|
|
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
|
-
}
|
|
1
|
+
import {
|
|
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";
|
|
20
|
+
import {
|
|
21
|
+
DefaultHighlightStyle,
|
|
22
|
+
TokenAdvanceCache,
|
|
23
|
+
canHighlight,
|
|
24
|
+
charOffsetsToRange,
|
|
25
|
+
defaultCodeDiff,
|
|
26
|
+
defaultCodePhases,
|
|
27
|
+
drawCode,
|
|
28
|
+
ensureHighlighter,
|
|
29
|
+
layoutCode,
|
|
30
|
+
makeAnim,
|
|
31
|
+
metricsSignature,
|
|
32
|
+
moveProgress,
|
|
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,
|
|
49
|
+
} from "./engine";
|
|
50
|
+
|
|
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;
|
|
54
|
+
|
|
55
|
+
/**
|
|
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.
|
|
59
|
+
*/
|
|
60
|
+
export interface CodeStrategies {
|
|
61
|
+
/** Decides which token became which across an edit. Defaults to `defaultCodeDiff`. */
|
|
62
|
+
diffStrategy?: CodeDiffStrategy;
|
|
63
|
+
/** Decides when (in normalized transition time) each part of an edit happens. Defaults to `defaultCodePhases`. */
|
|
64
|
+
phaseStrategy?: CodePhaseStrategy;
|
|
65
|
+
}
|
|
66
|
+
|
|
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
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What `code` holds: a source, or a {@link CodeBlend} while a `to({ code })` runs. */
|
|
75
|
+
export type CodeSource = string | CodeBlend;
|
|
76
|
+
|
|
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[] };
|
|
79
|
+
|
|
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
|
+
}
|
|
86
|
+
|
|
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
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CodeEditFrame {
|
|
95
|
+
readonly transition: StructuralTransition;
|
|
96
|
+
readonly progress: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
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
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CodeBanding {
|
|
107
|
+
readonly bands: RowBand[];
|
|
108
|
+
readonly fade: RowsTransition | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Each `Code` component's strategies and edit cache, by the node class it generated. */
|
|
112
|
+
const kits = new WeakMap<object, Kit>();
|
|
113
|
+
|
|
114
|
+
const NO_FOCUS: CodeFocus = { dim: null, ids: new Set(), fade: null };
|
|
115
|
+
const NO_BANDING: CodeBanding = { bands: [], fade: null };
|
|
116
|
+
|
|
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
|
+
}
|
|
121
|
+
|
|
122
|
+
const TARGET = Types.custom<CodeTarget | undefined>(undefined, { kind: "selector" });
|
|
123
|
+
const TEXT = Types.text("", { multiline: true });
|
|
124
|
+
|
|
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
|
+
}
|
|
263
|
+
|
|
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
|
+
}
|
|
280
|
+
|
|
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
|
+
}
|
|
296
|
+
|
|
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
|
+
}
|
|
301
|
+
|
|
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
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── What a listing shows ────────────────────────────────────────────────────
|
|
308
|
+
|
|
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
|
+
}
|
|
315
|
+
|
|
316
|
+
interface Edited {
|
|
317
|
+
readonly transition: StructuralTransition;
|
|
318
|
+
readonly listing: CodeListing;
|
|
319
|
+
}
|
|
320
|
+
|
|
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
|
+
}
|
|
329
|
+
|
|
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
|
+
}
|
|
339
|
+
|
|
340
|
+
interface DrawState extends LayoutState {
|
|
341
|
+
readonly focus: CodeFocus;
|
|
342
|
+
readonly banding: CodeBanding;
|
|
343
|
+
}
|
|
344
|
+
|
|
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
|
+
}
|
|
352
|
+
|
|
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
|
+
}
|
|
367
|
+
|
|
368
|
+
function editOf(view: Shown): StructuralTransition | null {
|
|
369
|
+
return view.transition ? { ...view.transition, progress: view.progress } : null;
|
|
370
|
+
}
|
|
371
|
+
|
|
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
|
+
}
|
|
376
|
+
|
|
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
|
+
}
|
|
392
|
+
|
|
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
|
+
}
|
|
396
|
+
|
|
397
|
+
function sameBasis(a: CodeTokenBasis, b: CodeTokenBasis): boolean {
|
|
398
|
+
return a.language === b.language && a.theme === b.theme && a.highlighted === b.highlighted;
|
|
399
|
+
}
|
|
400
|
+
|
|
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
|
+
}
|
|
405
|
+
|
|
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
|
+
}
|
|
421
|
+
|
|
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}`;
|
|
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
|
+
}
|
|
504
|
+
|
|
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;
|
|
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 ────────────────────────────────────────────────────────────────
|
|
521
|
+
|
|
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;
|
|
560
|
+
}
|
|
561
|
+
if (end <= start) return null;
|
|
562
|
+
return editFrame(kit, seen, source.slice(0, start) + source.slice(end), basisOf(state));
|
|
563
|
+
}
|
|
564
|
+
|
|
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
|
+
}
|
|
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
|
+
}
|
|
591
|
+
|
|
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] }));
|
|
603
|
+
}
|
|
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
|
+
}
|
|
611
|
+
|
|
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;
|
|
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;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
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.
|
|
676
|
+
*/
|
|
677
|
+
function normalizeCodeRanges(codeRange: CodeTarget | undefined): CodeRange[] {
|
|
678
|
+
const maybeIterable = codeRange as Partial<Iterable<CodeRange>>;
|
|
679
|
+
if (typeof maybeIterable[Symbol.iterator] === "function") {
|
|
680
|
+
return [...(codeRange as Iterable<CodeRange>)];
|
|
681
|
+
}
|
|
682
|
+
const stored = (codeRange as { list?: unknown }).list;
|
|
683
|
+
if (Array.isArray(stored)) return [...(stored as CodeRange[])];
|
|
684
|
+
return [codeRange as CodeRange];
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const grammarLoaders = new Map<string, () => Promise<void>>();
|
|
688
|
+
|
|
689
|
+
// One function per language: blocks sharing a grammar declare one key, and a
|
|
690
|
+
// second load function for a key already declared is ignored with a warning.
|
|
691
|
+
function grammarLoader(language: string): () => Promise<void> {
|
|
692
|
+
let load = grammarLoaders.get(language);
|
|
693
|
+
if (load === undefined) {
|
|
694
|
+
load = () => ensureHighlighter(undefined, [language]);
|
|
695
|
+
grammarLoaders.set(language, load);
|
|
696
|
+
}
|
|
697
|
+
return load;
|
|
698
|
+
}
|
|
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
|
+
}
|