@bramas/simulant-ui 0.0.0-stage → 0.1.1
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/LICENCE.txt +515 -0
- package/dist/SimulatorApp.jsx +601 -0
- package/dist/components/AlgorithmViewer.css +270 -0
- package/dist/components/AlgorithmViewer.jsx +218 -0
- package/dist/components/BranchPicker.css +96 -0
- package/dist/components/BranchPicker.jsx +65 -0
- package/dist/components/ConfigGraphGenerator.css +129 -0
- package/dist/components/ConfigGraphGenerator.jsx +408 -0
- package/dist/components/GenericEditor.jsx +228 -0
- package/dist/components/LeanView.jsx +133 -0
- package/dist/components/ModelView.css +520 -0
- package/dist/components/ModelView.jsx +272 -0
- package/dist/components/SettingsLibrary.jsx +61 -0
- package/dist/components/StateInspector.css +136 -0
- package/dist/components/StateInspector.jsx +103 -0
- package/dist/components/highlight.js +99 -0
- package/dist/pico.min.css +4 -0
- package/dist/renderers/define.js +28 -0
- package/dist/renderers/ring.js +103 -0
- package/dist/sim/branches.js +28 -0
- package/dist/sim/describe.js +408 -0
- package/dist/sim/execution.js +42 -0
- package/dist/sim/project.js +349 -0
- package/dist/sim/snapshot.js +29 -0
- package/dist/sim/types.js +1 -0
- package/dist/simulator.css +593 -0
- package/package.json +50 -3
- package/src/SimulatorApp.tsx +661 -0
- package/src/components/AlgorithmViewer.css +270 -0
- package/src/components/AlgorithmViewer.tsx +256 -0
- package/src/components/BranchPicker.css +96 -0
- package/src/components/BranchPicker.tsx +90 -0
- package/src/components/ConfigGraphGenerator.css +129 -0
- package/src/components/ConfigGraphGenerator.tsx +448 -0
- package/src/components/GenericEditor.tsx +413 -0
- package/src/components/LeanView.tsx +162 -0
- package/src/components/ModelView.css +520 -0
- package/src/components/ModelView.tsx +317 -0
- package/src/components/SettingsLibrary.tsx +84 -0
- package/src/components/StateInspector.css +136 -0
- package/src/components/StateInspector.tsx +125 -0
- package/src/components/highlight.ts +100 -0
- package/src/pico.min.css +4 -0
- package/src/renderers/define.ts +54 -0
- package/src/renderers/ring.ts +112 -0
- package/src/sim/branches.ts +30 -0
- package/src/sim/describe.ts +416 -0
- package/src/sim/execution.ts +65 -0
- package/src/sim/project.ts +377 -0
- package/src/sim/snapshot.ts +60 -0
- package/src/sim/types.ts +28 -0
- package/src/simulator.css +593 -0
- 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
|
+
}
|