@bramas/simulant-ui 0.0.0-stage → 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.
Files changed (53) hide show
  1. package/LICENCE.txt +515 -0
  2. package/dist/SimulatorApp.jsx +586 -0
  3. package/dist/components/AlgorithmViewer.css +270 -0
  4. package/dist/components/AlgorithmViewer.jsx +218 -0
  5. package/dist/components/BranchPicker.css +96 -0
  6. package/dist/components/BranchPicker.jsx +65 -0
  7. package/dist/components/ConfigGraphGenerator.css +129 -0
  8. package/dist/components/ConfigGraphGenerator.jsx +408 -0
  9. package/dist/components/GenericEditor.jsx +228 -0
  10. package/dist/components/LeanView.jsx +133 -0
  11. package/dist/components/ModelView.css +520 -0
  12. package/dist/components/ModelView.jsx +272 -0
  13. package/dist/components/SettingsLibrary.jsx +61 -0
  14. package/dist/components/StateInspector.css +136 -0
  15. package/dist/components/StateInspector.jsx +103 -0
  16. package/dist/components/highlight.js +99 -0
  17. package/dist/pico.min.css +4 -0
  18. package/dist/renderers/define.js +28 -0
  19. package/dist/renderers/ring.js +103 -0
  20. package/dist/sim/branches.js +28 -0
  21. package/dist/sim/describe.js +403 -0
  22. package/dist/sim/execution.js +42 -0
  23. package/dist/sim/project.js +344 -0
  24. package/dist/sim/snapshot.js +29 -0
  25. package/dist/sim/types.js +1 -0
  26. package/dist/simulator.css +584 -0
  27. package/package.json +50 -3
  28. package/src/SimulatorApp.tsx +646 -0
  29. package/src/components/AlgorithmViewer.css +270 -0
  30. package/src/components/AlgorithmViewer.tsx +256 -0
  31. package/src/components/BranchPicker.css +96 -0
  32. package/src/components/BranchPicker.tsx +90 -0
  33. package/src/components/ConfigGraphGenerator.css +129 -0
  34. package/src/components/ConfigGraphGenerator.tsx +448 -0
  35. package/src/components/GenericEditor.tsx +413 -0
  36. package/src/components/LeanView.tsx +162 -0
  37. package/src/components/ModelView.css +520 -0
  38. package/src/components/ModelView.tsx +317 -0
  39. package/src/components/SettingsLibrary.tsx +84 -0
  40. package/src/components/StateInspector.css +136 -0
  41. package/src/components/StateInspector.tsx +125 -0
  42. package/src/components/highlight.ts +100 -0
  43. package/src/pico.min.css +4 -0
  44. package/src/renderers/define.ts +54 -0
  45. package/src/renderers/ring.ts +112 -0
  46. package/src/sim/branches.ts +30 -0
  47. package/src/sim/describe.ts +411 -0
  48. package/src/sim/execution.ts +65 -0
  49. package/src/sim/project.ts +372 -0
  50. package/src/sim/snapshot.ts +60 -0
  51. package/src/sim/types.ts +28 -0
  52. package/src/simulator.css +584 -0
  53. package/README.md +0 -3
@@ -0,0 +1,413 @@
1
+ // A form generated from an editor schema (the settings of a project).
2
+ import { createEffect, For, Match, Switch } from "solid-js";
3
+ import { createStore } from "solid-js/store"
4
+
5
+ import type { EditorSchema, EditorField } from "@bramas/simulant-core/types/editor-schema";
6
+
7
+ export interface GenericEditorProps {
8
+ schema: EditorSchema;
9
+ onInput: (value: any) => void;
10
+ value?: any;
11
+ }
12
+
13
+ function commitNativeEdit(
14
+ element: HTMLTextAreaElement,
15
+ replacement: string,
16
+ start: number,
17
+ end: number,
18
+ selectionStart: number,
19
+ selectionEnd: number,
20
+ onInput: (value: string) => void
21
+ ) {
22
+ element.setRangeText(replacement, start, end, 'end');
23
+ element.setSelectionRange(selectionStart, selectionEnd);
24
+ onInput(element.value);
25
+ }
26
+
27
+ function getTouchedLineRange(value: string, selectionStart: number, selectionEnd: number) {
28
+ const blockStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
29
+ let effectiveEnd = selectionEnd;
30
+
31
+ if (selectionEnd > selectionStart && value[selectionEnd - 1] === '\n') {
32
+ effectiveEnd -= 1;
33
+ }
34
+
35
+ const nextNewLine = value.indexOf('\n', Math.max(blockStart, effectiveEnd));
36
+ const blockEnd = nextNewLine === -1 ? value.length : nextNewLine;
37
+
38
+ return { blockStart, blockEnd };
39
+ }
40
+
41
+ function indentSelectedLines(
42
+ element: HTMLTextAreaElement,
43
+ onInput: (value: string) => void,
44
+ indentUnit: string,
45
+ selectionStart: number,
46
+ selectionEnd: number
47
+ ) {
48
+ const value = element.value;
49
+ const { blockStart, blockEnd } = getTouchedLineRange(value, selectionStart, selectionEnd);
50
+ const selectedBlock = value.slice(blockStart, blockEnd);
51
+ const lines = selectedBlock.split('\n');
52
+ const replacement = lines.map((line) => `${indentUnit}${line}`).join('\n');
53
+
54
+ const startOffset = selectionStart === selectionEnd ? indentUnit.length : selectionStart - blockStart + indentUnit.length;
55
+ const endOffset = selectionEnd - blockStart + indentUnit.length * lines.length;
56
+
57
+ commitNativeEdit(
58
+ element,
59
+ replacement,
60
+ blockStart,
61
+ blockEnd,
62
+ blockStart + startOffset,
63
+ blockStart + endOffset,
64
+ onInput,
65
+ );
66
+ }
67
+
68
+ function unindentSelectedLines(
69
+ element: HTMLTextAreaElement,
70
+ onInput: (value: string) => void,
71
+ selectionStart: number,
72
+ selectionEnd: number
73
+ ) {
74
+ const value = element.value;
75
+ const { blockStart, blockEnd } = getTouchedLineRange(value, selectionStart, selectionEnd);
76
+ const selectedBlock = value.slice(blockStart, blockEnd);
77
+ const lines = selectedBlock.split('\n');
78
+
79
+ let removedBeforeSelectionStart = 0;
80
+ let removedBeforeSelectionEnd = 0;
81
+
82
+ const replacement = lines.map((line, index) => {
83
+ let removal = 0;
84
+ if (line.startsWith(' ')) {
85
+ removal = 2;
86
+ } else if (line.startsWith('\t')) {
87
+ removal = 1;
88
+ }
89
+
90
+ if (removal > 0) {
91
+ if (index === 0) {
92
+ removedBeforeSelectionStart = Math.min(removal, Math.max(0, selectionStart - blockStart));
93
+ }
94
+ removedBeforeSelectionEnd += removal;
95
+ }
96
+
97
+ return line.slice(removal);
98
+ }).join('\n');
99
+
100
+ const nextSelectionStart = Math.max(blockStart, selectionStart - removedBeforeSelectionStart);
101
+ const nextSelectionEnd = Math.max(nextSelectionStart, selectionEnd - removedBeforeSelectionEnd);
102
+
103
+ commitNativeEdit(
104
+ element,
105
+ replacement,
106
+ blockStart,
107
+ blockEnd,
108
+ nextSelectionStart,
109
+ nextSelectionEnd,
110
+ onInput,
111
+ );
112
+ }
113
+
114
+ function CodeEditor(props: {
115
+ value: string;
116
+ onInput: (value: string) => void;
117
+ placeholder?: string;
118
+ rows?: number;
119
+ }) {
120
+ let textareaRef: HTMLTextAreaElement | undefined;
121
+ let gutterRef: HTMLDivElement | undefined;
122
+
123
+ const lineNumbers = () => {
124
+ const lineCount = Math.max(1, props.value.split('\n').length);
125
+ return Array.from({ length: lineCount }, (_, index) => index + 1);
126
+ };
127
+
128
+ const syncScroll = () => {
129
+ if (textareaRef && gutterRef) {
130
+ gutterRef.scrollTop = textareaRef.scrollTop;
131
+ }
132
+ };
133
+
134
+ const handleKeyDown = (event: KeyboardEvent) => {
135
+ const textarea = event.currentTarget as HTMLTextAreaElement;
136
+ const { selectionStart, selectionEnd, value } = textarea;
137
+
138
+ if (event.key === 'Tab') {
139
+ event.preventDefault();
140
+ const hasSelection = selectionEnd > selectionStart;
141
+ const touchesMultipleLines = value.slice(selectionStart, selectionEnd).includes('\n');
142
+
143
+ if (event.shiftKey) {
144
+ unindentSelectedLines(textarea, props.onInput, selectionStart, selectionEnd);
145
+ return;
146
+ }
147
+
148
+ const indentation = ' ';
149
+
150
+ if (hasSelection || touchesMultipleLines) {
151
+ indentSelectedLines(textarea, props.onInput, indentation, selectionStart, selectionEnd);
152
+ return;
153
+ }
154
+
155
+ commitNativeEdit(
156
+ textarea,
157
+ indentation,
158
+ selectionStart,
159
+ selectionEnd,
160
+ selectionStart + indentation.length,
161
+ selectionStart + indentation.length,
162
+ props.onInput,
163
+ );
164
+ return;
165
+ }
166
+
167
+ if (event.key === 'Enter') {
168
+ const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
169
+ const currentLine = value.slice(lineStart, selectionStart);
170
+ const indentation = currentLine.match(/^[\t ]*/)?.[0] ?? '';
171
+
172
+ if (indentation.length > 0) {
173
+ event.preventDefault();
174
+ const insert = `\n${indentation}`;
175
+ const cursor = selectionStart + insert.length;
176
+ commitNativeEdit(
177
+ textarea,
178
+ insert,
179
+ selectionStart,
180
+ selectionEnd,
181
+ cursor,
182
+ cursor,
183
+ props.onInput,
184
+ );
185
+ }
186
+ }
187
+ };
188
+
189
+ return (
190
+ <div
191
+ style={{
192
+ display: 'grid',
193
+ 'grid-template-columns': '3.5em minmax(0, 1fr)',
194
+ border: '1px solid #c9d2dc',
195
+ 'border-radius': '6px',
196
+ overflow: 'hidden',
197
+ 'background-color': '#f8fafc'
198
+ }}
199
+ >
200
+ <div
201
+ ref={gutterRef}
202
+ aria-hidden="true"
203
+ style={{
204
+ 'font-family': 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace',
205
+ 'font-size': '0.9em',
206
+ 'line-height': '1.5',
207
+ padding: '0.75em 0.5em',
208
+ 'text-align': 'right',
209
+ color: '#6b7280',
210
+ 'background-color': '#eef2f7',
211
+ 'border-right': '1px solid #d7dee7',
212
+ overflow: 'hidden',
213
+ 'user-select': 'none'
214
+ }}
215
+ >
216
+ <For each={lineNumbers()}>
217
+ {(lineNumber) => <div>{lineNumber}</div>}
218
+ </For>
219
+ </div>
220
+ <textarea
221
+ ref={textareaRef}
222
+ value={props.value}
223
+ onInput={(event) => props.onInput(event.currentTarget.value)}
224
+ onKeyDown={handleKeyDown}
225
+ onScroll={syncScroll}
226
+ placeholder={props.placeholder}
227
+ rows={props.rows ?? 12}
228
+ spellcheck={false}
229
+ style={{
230
+ width: '100%',
231
+ margin: '0',
232
+ border: 'none',
233
+ outline: 'none',
234
+ resize: 'vertical',
235
+ padding: '0.75em',
236
+ 'background-color': '#fcfdff',
237
+ color: '#17202a',
238
+ 'font-family': 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace',
239
+ 'font-size': '0.9em',
240
+ 'line-height': '1.5',
241
+ 'white-space': 'pre',
242
+ 'tab-size': '2'
243
+ }}
244
+ />
245
+ </div>
246
+ );
247
+ }
248
+
249
+ type SetField = (key: string, value: any) => void;
250
+
251
+ function renderTextArea(
252
+ field: Extract<EditorField, { type: 'text' | 'textarea' | 'code' }>,
253
+ fieldStore: { [key: string]: any; },
254
+ setFieldStore: SetField
255
+ ) {
256
+ const isCodeEditor = field.type === 'code';
257
+
258
+ return (
259
+ <div class="sim-field sim-field-wide">
260
+ <label>
261
+ {field.label}:{' '}
262
+ {'placeholder' in field && field.placeholder && (
263
+ <span class="sim-muted">({field.placeholder})</span>
264
+ )}
265
+ {isCodeEditor ? (
266
+ <CodeEditor
267
+ value={fieldStore[field.key] ?? ''}
268
+ onInput={(value) => setFieldStore(field.key, value)}
269
+ placeholder={'placeholder' in field ? field.placeholder : undefined}
270
+ rows={'rows' in field && field.rows ? field.rows : 12}
271
+ />
272
+ ) : (
273
+ <textarea
274
+ value={fieldStore[field.key] ?? ''}
275
+ onInput={(e) => setFieldStore(field.key, e.currentTarget.value)}
276
+ placeholder={'placeholder' in field ? field.placeholder : undefined}
277
+ rows={'rows' in field && field.rows ? field.rows : 4}
278
+ spellcheck={field.type === 'textarea' ? field.spellcheck : false}
279
+ />
280
+ )}
281
+ </label>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ function RenderField(props: {field: EditorField, fieldStore: {[key: string]: any;}, setFieldStore: SetField}) {
287
+
288
+ return (
289
+ <Switch>
290
+ <Match when={props.field.type === 'number' && props.field}>
291
+ {(f) => (
292
+ <div class="sim-field">
293
+ <label>
294
+ {f().label}:{' '}
295
+ <input
296
+ type="number"
297
+ value={props.fieldStore[props.field.key]}
298
+ onInput={(e) => {
299
+ // An empty or partial number is not a value yet: wait for the next keystroke.
300
+ const n = e.currentTarget.valueAsNumber;
301
+ if (!Number.isNaN(n)) props.setFieldStore(props.field.key, n);
302
+ }}
303
+ min={'min' in props.field ? props.field.min : undefined}
304
+ max={'max' in props.field ? props.field.max : undefined}
305
+ step={'step' in props.field ? props.field.step : undefined}
306
+ />
307
+ </label>
308
+ </div>
309
+ )}
310
+ </Match>
311
+
312
+ <Match when={props.field.type === 'text' && props.field && !('multiline' in props.field && props.field.multiline)}>
313
+ <div class="sim-field sim-field-wide">
314
+ <label>
315
+ {props.field.label}:{' '}
316
+ {'placeholder' in props.field && props.field.placeholder && (
317
+ <span class="sim-muted">({props.field.placeholder})</span>
318
+ )}
319
+ <input
320
+ type="text"
321
+ value={props.fieldStore[props.field.key] ?? ''}
322
+ onInput={(e) => props.setFieldStore(props.field.key, e.currentTarget.value)}
323
+ placeholder={'placeholder' in props.field ? props.field.placeholder : undefined}
324
+ pattern={'pattern' in props.field ? props.field.pattern : undefined}
325
+ spellcheck={false}
326
+ />
327
+ </label>
328
+ </div>
329
+ </Match>
330
+
331
+ <Match when={props.field.type === 'code' || props.field.type === 'textarea' || (props.field.type === 'text' && 'multiline' in props.field && props.field.multiline)}>
332
+ {renderTextArea(
333
+ props.field as Extract<EditorField, { type: 'text' | 'textarea' | 'code' }>,
334
+ props.fieldStore,
335
+ props.setFieldStore
336
+ )}
337
+ </Match>
338
+
339
+ <Match when={props.field.type === 'boolean' && props.field}>
340
+ {(f) => (
341
+ <div class="sim-field">
342
+ <label>
343
+ <input
344
+ type="checkbox"
345
+ checked={props.fieldStore[props.field.key]}
346
+ onInput={(e) => props.setFieldStore(props.field.key, e.currentTarget.checked)}
347
+ />
348
+ {' '}{props.field.label}
349
+ </label>
350
+ </div>
351
+ )}
352
+ </Match>
353
+
354
+ <Match when={props.field.type === 'select' && props.field}>
355
+ {(f) => (
356
+ <div class="sim-field">
357
+ <label>
358
+ {props.field.label}:{' '}
359
+ <select
360
+ value={props.fieldStore[props.field.key]}
361
+ onInput={(e) => props.setFieldStore(props.field.key,
362
+ typeof props.field.defaultValue === 'number'
363
+ ? parseFloat(e.currentTarget.value)
364
+ : e.currentTarget.value
365
+ )}
366
+ >
367
+ <For each={'options' in f() ? f().options : []}>
368
+ {(option) => <option value={option.value}>{option.label}</option>}
369
+ </For>
370
+ </select>
371
+ </label>
372
+ </div>
373
+ )}
374
+ </Match>
375
+ </Switch>
376
+ );
377
+ };
378
+
379
+
380
+ /** The values the form shows: the schema's defaults, overridden by `value`. */
381
+ export function formValues(schema: EditorSchema, value?: any): Record<string, any> {
382
+ const result: Record<string, any> = {};
383
+ for (const field of schema.fields) {
384
+ result[field.key] = value && value[field.key] !== undefined ? value[field.key] : (field as any).defaultValue;
385
+ }
386
+ return result;
387
+ }
388
+
389
+ export function GenericEditor(props: GenericEditorProps) {
390
+
391
+ const [fieldStore, setFieldStore] = createStore<{[key: string]: any}>({});
392
+
393
+ // Show `props.value` (loading settings does not count as an edit).
394
+ createEffect(() => setFieldStore(formValues(props.schema, props.value)));
395
+
396
+ // Only the user's edits are reported.
397
+ const setField: SetField = (key, value) => {
398
+ setFieldStore(key, value);
399
+ props.onInput({ ...fieldStore });
400
+ };
401
+
402
+ return (
403
+ <div class="sim-form">
404
+ <For each={props.schema.fields}>
405
+ {(field) => <RenderField
406
+ field={field}
407
+ fieldStore={fieldStore}
408
+ setFieldStore={setField} />
409
+ }
410
+ </For>
411
+ </div>
412
+ );
413
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The generated Lean model of an experiment, as `simulant lean` writes it, beside the model
3
+ * view's `.sim` sources. A gutter says what each region translates; clicking a region shows its
4
+ * source, and `focus` (from the sources) highlights the regions that translate a declaration or
5
+ * a line. The library components the model imports follow, folded.
6
+ */
7
+ import { For, Show, createEffect, createMemo, createSignal, on, type JSX } from 'solid-js';
8
+ import type { LeanBlock, LeanDescription, SourceRef } from '@bramas/simulant-core/types/project';
9
+ import { leanModuleName } from '@bramas/simulant-lean-gen/names';
10
+ import { tokenizeLean } from './highlight';
11
+
12
+ export interface LeanViewProps {
13
+ lean: LeanDescription;
14
+ /** The simulation's name: `simulant lean -p <simulation>` names the module after it. */
15
+ simulation: string;
16
+ /** How a reference reads (`algo1.sim:32`). */
17
+ refLabel: (ref: SourceRef) => string;
18
+ onShowSource: (ref: SourceRef) => void;
19
+ /** Regions to highlight: those translating this declaration (and line); `seq` repeats a request. */
20
+ focus?: SourceRef & { seq: number };
21
+ }
22
+
23
+ const matches = (b: { refs: SourceRef[] }, f: SourceRef) =>
24
+ b.refs.some(r => r.source === f.source && (f.line === undefined || r.line === f.line));
25
+
26
+ export function LeanView(props: LeanViewProps) {
27
+ const module = () => leanModuleName(props.simulation);
28
+ const path = () => `lean/Generated/${module()}/Model.lean`;
29
+ /** The text as the CLI writes it for this simulation (the module is named after the simulation). */
30
+ const rename = (text: string) => text.split(`Generated.${props.lean.module}`).join(`Generated.${module()}`);
31
+
32
+ const download = (text: string, name: string) => {
33
+ const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
34
+ const a = document.createElement('a');
35
+ a.href = url;
36
+ a.download = name;
37
+ a.click();
38
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
39
+ };
40
+
41
+ return (
42
+ <div class="mv-lean">
43
+ <section class="sim-card mv-section">
44
+ <h3>
45
+ Lean model <span class="sim-muted mv-lean-path">{path()}</span>
46
+ <span class="sim-spacer" style={{ flex: 1 }} />
47
+ <Show when={'text' in props.lean}>
48
+ <button class="sim-ghost" onClick={() => 'text' in props.lean && download(rename(props.lean.text), 'Model.lean')}>Download</button>
49
+ </Show>
50
+ </h3>
51
+ <p class="mv-note">
52
+ Generated from the same compiled experiment as the simulation and the checks, by <code>simulant lean -p {props.simulation}</code>.
53
+ The rounds, executions, fairness and <code>Correct</code> are defined once, by hand, in the semantic core (<code>lean/Simulant/</code>);
54
+ proofs go in <code>Proofs/</code>. The gutter says what each part translates: click a part to see its source, or a declaration's
55
+ {' '}<em>Lean</em> button to find its translation.
56
+ </p>
57
+ </section>
58
+ <Show when={'text' in props.lean ? props.lean : undefined} fallback={
59
+ <section class="sim-card mv-section mv-caveats">
60
+ <h3>No Lean translation</h3>
61
+ <p class="mv-note">{(props.lean as { error: string }).error}</p>
62
+ </section>
63
+ }>
64
+ {lean => (
65
+ <>
66
+ <LeanFile text={rename(lean().text)} blocks={lean().blocks} {...props} />
67
+ <For each={lean().library}>
68
+ {lib => {
69
+ const [open, setOpen] = createSignal(false);
70
+ return (
71
+ <section class="algorithm-viewer mv-source mv-defs">
72
+ <button class="mv-defs-head" onClick={() => setOpen(v => !v)} aria-expanded={open()}>
73
+ <span class="mv-fold">{open() ? '▾' : '▸'}</span>
74
+ <span class="algorithm-caption">{lib.module}</span>
75
+ <span class="sim-muted">imported: generated from the standard library, shared by every model that uses it</span>
76
+ </button>
77
+ <Show when={open()}>
78
+ <LeanFile text={lib.text} blocks={[{ line: 1, endLine: lib.text.split('\n').length, label: 'library component', refs: lib.refs }]} {...props} />
79
+ </Show>
80
+ </section>
81
+ );
82
+ }}
83
+ </For>
84
+ </>
85
+ )}
86
+ </Show>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ function LeanFile(p: { text: string; blocks: LeanBlock[] } & LeanViewProps): JSX.Element {
92
+ const lines = createMemo(() => tokenizeLean(p.text.split('\n').map((text, i) => ({ number: i + 1, text }))));
93
+ /** For each line, the smallest region containing it (a case of the step, rather than the step). */
94
+ const innermost = createMemo(() => {
95
+ const out: (number | undefined)[] = [];
96
+ p.blocks.forEach((b, i) => {
97
+ for (let l = b.line; l <= b.endLine; l++) {
98
+ const cur = out[l];
99
+ if (cur === undefined || p.blocks[cur].endLine - p.blocks[cur].line > b.endLine - b.line) out[l] = i;
100
+ }
101
+ });
102
+ return out;
103
+ });
104
+ const starts = createMemo(() => {
105
+ const out = new Map<number, LeanBlock[]>();
106
+ for (const b of p.blocks) out.set(b.line, [...(out.get(b.line) ?? []), b]);
107
+ return out;
108
+ });
109
+ const [hovered, setHovered] = createSignal<number | undefined>();
110
+ const focused = createMemo(() => p.focus ? new Set(p.blocks.flatMap((b, i) => matches(b, p.focus!) ? [i] : [])) : new Set<number>());
111
+ const [flash, setFlash] = createSignal(false);
112
+ let body: HTMLDivElement | undefined;
113
+ createEffect(on(() => p.focus?.seq, () => {
114
+ const first = [...focused()].map(i => p.blocks[i]).sort((a, b) => a.line - b.line)[0];
115
+ if (!first) return;
116
+ setFlash(true);
117
+ setTimeout(() => setFlash(false), 1600);
118
+ body?.querySelector(`[data-line-number="${first.line}"]`)?.scrollIntoView({ block: 'center', behavior: 'smooth' });
119
+ }, { defer: true }));
120
+
121
+ const lit = (line: number) => {
122
+ const i = innermost()[line];
123
+ if (i === undefined) return '';
124
+ if (flash() && [...focused()].some(f => p.blocks[f].line <= line && line <= p.blocks[f].endLine)) return ' mv-flash-line';
125
+ return hovered() === i ? ' mv-lean-hover' : '';
126
+ };
127
+
128
+ return (
129
+ <div class="algorithm-viewer mv-source mv-lean-file">
130
+ <div class="algorithm-body" ref={body} onMouseLeave={() => setHovered(undefined)}>
131
+ <For each={lines()}>
132
+ {line => {
133
+ const block = () => innermost()[line.number];
134
+ const show = () => {
135
+ const b = block();
136
+ if (b !== undefined && p.blocks[b].refs[0]) p.onShowSource(p.blocks[b].refs[0]);
137
+ };
138
+ return (
139
+ <div class={`algorithm-line mv-lean-line${lit(line.number)}${block() !== undefined && p.blocks[block()!].refs.length ? ' mv-linked' : ''}`}
140
+ data-line-number={line.number} onMouseEnter={() => setHovered(block())} onClick={show}>
141
+ <span class="mv-gutter">
142
+ <For each={starts().get(line.number) ?? []}>
143
+ {b => (
144
+ <span class="mv-gutter-item" title={b.refs.map(p.refLabel).join(', ')}>
145
+ {b.label}
146
+ <For each={b.refs.slice(0, 1)}>{r => <span class="mv-gutter-ref">{p.refLabel(r)}</span>}</For>
147
+ </span>
148
+ )}
149
+ </For>
150
+ </span>
151
+ <span class="line-number">{line.number}</span>
152
+ <span class="line-content">
153
+ <For each={line.tokens}>{t => t.kind ? <span class={`tok-${t.kind}`}>{t.text}</span> : t.text}</For>
154
+ </span>
155
+ </div>
156
+ );
157
+ }}
158
+ </For>
159
+ </div>
160
+ </div>
161
+ );
162
+ }