prolog-notebook 0.1.2 → 0.3.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/CHANGELOG.md +211 -0
- package/README.md +130 -27
- package/bin/prolog-notebook.mjs +206 -0
- package/package.json +19 -4
- package/src/browser.js +252 -16
- package/src/build-info.js +103 -0
- package/src/build-info.json +4 -0
- package/src/clauses.js +236 -0
- package/src/engine.js +227 -16
- package/src/export.js +126 -0
- package/src/format.js +649 -0
- package/src/node.js +14 -4
- package/src/notebook.css +319 -1
- package/src/notebook.js +1261 -60
- package/src/page.js +85 -0
- package/src/render.js +367 -0
- package/src/run.js +128 -0
- package/src/session.js +227 -0
- package/src/version.js +51 -0
- package/src/worker.js +77 -0
package/src/notebook.js
CHANGED
|
@@ -1,74 +1,969 @@
|
|
|
1
1
|
// Browser wiring: turns marked-up cells in a page into a running notebook.
|
|
2
2
|
//
|
|
3
3
|
// Deliberately not a framework. A page declares its cells as ordinary elements and
|
|
4
|
-
// calls mount(); the DOM is the notebook. A file-backed renderer (reading
|
|
5
|
-
//
|
|
6
|
-
|
|
4
|
+
// calls mount(); the DOM is the notebook. A file-backed renderer (reading markdown
|
|
5
|
+
// and generating these cells) is the next layer up.
|
|
6
|
+
//
|
|
7
|
+
// The engine runs in a worker (src/browser.js), so every call here is awaited and
|
|
8
|
+
// a goal that never terminates leaves the page usable. That is not a nicety: a
|
|
9
|
+
// Prolog chapter has to be able to demonstrate non-termination.
|
|
10
|
+
//
|
|
11
|
+
// WHY THERE IS SO MUCH STATE IN HERE. A reader is looking at three things that can
|
|
12
|
+
// disagree: the text on screen, what the engine is holding, and the answers below.
|
|
13
|
+
// Any two of them come apart in a single click. So every cell answers the same two
|
|
14
|
+
// questions at all times — is this still what the chapter published, and does the
|
|
15
|
+
// engine agree with what I can see — through a tick that names its state and a
|
|
16
|
+
// reset that undoes it. A blank tick beside a greyed button is indistinguishable
|
|
17
|
+
// from a broken page, which is how an earlier version of this file read.
|
|
18
|
+
import { createSession, formatSolution, prologVersion, readableInCell } from './browser.js';
|
|
19
|
+
import { declaredDynamic, definedPredicates, unknownProcedure } from './clauses.js';
|
|
20
|
+
import { download } from './export.js';
|
|
21
|
+
import { solutionSequence } from './format.js';
|
|
22
|
+
import { colophon } from './version.js';
|
|
7
23
|
|
|
8
24
|
let serial = 0;
|
|
9
|
-
let
|
|
25
|
+
let panels = 0;
|
|
26
|
+
|
|
27
|
+
/** Absolute, never relative: "3 minutes ago" is wrong the moment it is written. */
|
|
28
|
+
function clock(date = new Date()) {
|
|
29
|
+
return date.toLocaleTimeString(undefined, { hour12: false });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One notebook's internal notifications.
|
|
34
|
+
*
|
|
35
|
+
* Cells have to hear about each other: answers stop being current when a program
|
|
36
|
+
* cell ABOVE them changes, and no query can notice that by itself. Created per
|
|
37
|
+
* mount() rather than per module, because a page may hold more than one notebook
|
|
38
|
+
* (an embedded chapter, v0.4) and one notebook's edits are not another's.
|
|
39
|
+
*/
|
|
40
|
+
function createBus() {
|
|
41
|
+
const watchers = new Set();
|
|
42
|
+
// Auto re-runs go through here one at a time. A consult can make several cells
|
|
43
|
+
// want to refresh at once, and this notebook has ONE engine: opening four
|
|
44
|
+
// queries into it simultaneously interleaves four solution streams in a single
|
|
45
|
+
// WASM heap for no gain, since the worker answers them serially anyway.
|
|
46
|
+
let chain = Promise.resolve();
|
|
47
|
+
return {
|
|
48
|
+
on: (watcher) => watchers.add(watcher),
|
|
49
|
+
emit: (event) => { for (const watcher of watchers) watcher(event); },
|
|
50
|
+
// The cells with a solution sequence open right now. One engine allows one
|
|
51
|
+
// (869epzqpc), so this is 0 or 1 — but it is a set because what the page
|
|
52
|
+
// needs to know is "is the reader mid-enquiry anywhere", and counting is how
|
|
53
|
+
// a cell that is not this one gets to say so.
|
|
54
|
+
stepping: new Set(),
|
|
55
|
+
queue: (job) => {
|
|
56
|
+
// Both arms are the same job on purpose: a re-run that threw must not stop
|
|
57
|
+
// the next cell's from ever starting.
|
|
58
|
+
chain = chain.then(job, job);
|
|
59
|
+
return chain;
|
|
60
|
+
},
|
|
61
|
+
// Whether THIS notebook's engine has started. Per mount rather than per
|
|
62
|
+
// module: "5.9 MB, first time only" is a claim about a particular notebook's
|
|
63
|
+
// first Run, and a second chapter embedded in the same page (v0.4) is a
|
|
64
|
+
// second notebook, not a continuation of this one.
|
|
65
|
+
booted: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
10
68
|
|
|
11
|
-
export function mount(root = document) {
|
|
69
|
+
export function mount(root = document, options = {}) {
|
|
12
70
|
// A page can carry a #boot-warning element saying "this notebook is not running".
|
|
13
71
|
// It is removed only once mount() has actually run, so any failure that prevents
|
|
14
72
|
// this module from loading — opening the page over file://, a bad path, a syntax
|
|
15
73
|
// error — leaves the warning on screen instead of silently inert buttons.
|
|
16
74
|
document.getElementById('boot-warning')?.remove();
|
|
17
75
|
|
|
18
|
-
|
|
19
|
-
|
|
76
|
+
const bus = createBus();
|
|
77
|
+
|
|
78
|
+
// Document order matters, and it is the only thing that does. A query is run
|
|
79
|
+
// against the program cells ABOVE it, and a predicate it cannot find may be
|
|
80
|
+
// defined in one BELOW it — which is worth saying rather than leaving as
|
|
81
|
+
// "Unknown procedure".
|
|
82
|
+
const cells = [...root.querySelectorAll('.cell')];
|
|
83
|
+
const programs = [];
|
|
84
|
+
cells.forEach((cell, index) => {
|
|
85
|
+
if (cell.classList.contains('program')) programs.push({ index, ...mountProgram(cell, options, bus) });
|
|
86
|
+
});
|
|
87
|
+
// A cell held until a prediction is answered names that prediction by POSITION:
|
|
88
|
+
// the nearest one above it. That is not the position-inferred spoiler rule the
|
|
89
|
+
// format refuses — the AUTHOR opted in, per cell, in the file (format §5). This
|
|
90
|
+
// is only how the cell finds the box it was told to wait for.
|
|
91
|
+
const predictions = [...root.querySelectorAll('.predict textarea')];
|
|
92
|
+
const predictionAbove = (el) => predictions
|
|
93
|
+
.filter((box) => box.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
94
|
+
.pop() ?? null;
|
|
95
|
+
|
|
96
|
+
const queries = [];
|
|
97
|
+
cells.forEach((cell, index) => {
|
|
98
|
+
if (!cell.classList.contains('query')) return;
|
|
99
|
+
queries.push(mountQuery(cell, options, bus, {
|
|
100
|
+
above: programs.filter((p) => p.index < index),
|
|
101
|
+
below: programs.filter((p) => p.index > index),
|
|
102
|
+
prediction: predictionAbove(cell),
|
|
103
|
+
}));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (programs.length) mountPageBar(root, options, bus, programs, queries);
|
|
107
|
+
|
|
108
|
+
// Returned so a shell that HAS the parsed model — page.js, today — can ask the
|
|
109
|
+
// cells what they now say and hand the reader a file (869ejgbxf). notebook.js
|
|
110
|
+
// still knows nothing about markdown, and does not gain a parser to do it.
|
|
111
|
+
//
|
|
112
|
+
// `on` goes with them because page-level chrome has to hear about cells that
|
|
113
|
+
// change WITHOUT anyone clicking: an automatic re-run (format §5) is started by
|
|
114
|
+
// a consult in another cell, and a panel that only listens to its own clicks
|
|
115
|
+
// reports the state before it.
|
|
116
|
+
return { programs, queries, on: bus.on };
|
|
20
117
|
}
|
|
21
118
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
119
|
+
/**
|
|
120
|
+
* The controls that belong to the page rather than to any one cell: what the
|
|
121
|
+
* engine is holding, whether the chapter is showing its answers, and the one
|
|
122
|
+
* button that acts on all of the first.
|
|
123
|
+
*
|
|
124
|
+
* CHROME, NOT CONTENT: built here at runtime rather than emitted into the
|
|
125
|
+
* document, so a built page, an EPUB or the GitHub view never carries a button
|
|
126
|
+
* that cannot work.
|
|
127
|
+
*
|
|
128
|
+
* Sticky, which is a fix rather than a flourish: this is where the answer to
|
|
129
|
+
* "what is the engine holding now" lives, and a reader who has to scroll to the
|
|
130
|
+
* end of the chapter to see it will read every cell above as unexplained.
|
|
131
|
+
*
|
|
132
|
+
* But a chapter is for reading, and a full-width bar pinned across the foot of
|
|
133
|
+
* every page is a tool insisting on itself. So it is a lozenge in the corner
|
|
134
|
+
* carrying the state at a glance — a dot and a word — and clicking it RAISES A
|
|
135
|
+
* CARD directly above it, right edges aligned, one row per thing the page
|
|
136
|
+
* controls. The thing the reader pressed is visibly the thing that opened.
|
|
137
|
+
*
|
|
138
|
+
* It rises rather than widening, and that is a correctness decision as much as a
|
|
139
|
+
* visual one: a widening pill has to be measured from the DOM every time its
|
|
140
|
+
* words change, and a measured animation is cancelled by anything that re-renders
|
|
141
|
+
* while it runs (869enmuy9). A row also costs vertical space, which nobody is
|
|
142
|
+
* short of — the widening pill had reached the edge of the viewport with three
|
|
143
|
+
* units in it, and there are more coming.
|
|
144
|
+
*
|
|
145
|
+
* On a click, not on hover: hover opens a panel nobody asked for, does not exist
|
|
146
|
+
* on a touch screen, and cannot be reached from a keyboard, so one gesture that
|
|
147
|
+
* works everywhere beats three that do not.
|
|
148
|
+
*
|
|
149
|
+
* IT OPENS ITSELF ONLY TO REPORT A FAILURE. An earlier version also opened when
|
|
150
|
+
* the engine started, on the grounds that its words were worth reading at that
|
|
151
|
+
* moment — but the reader had pressed Run in a cell, their attention was on that
|
|
152
|
+
* cell's output, and a second thing moving in the corner competed with the
|
|
153
|
+
* answers they had actually asked for. It was also redundant: the light says the
|
|
154
|
+
* engine came on, which is the whole reason a light is there. The panel is the
|
|
155
|
+
* detail, and detail is fetched, not pushed.
|
|
156
|
+
*
|
|
157
|
+
* A failure is the exception, because a message nobody sees is not a message.
|
|
158
|
+
*
|
|
159
|
+
* Otherwise it opens on a click and closes on Escape, on a click elsewhere, or on
|
|
160
|
+
* a second click of the lozenge.
|
|
161
|
+
*/
|
|
162
|
+
/**
|
|
163
|
+
* The small line icons the page controls use.
|
|
164
|
+
*
|
|
165
|
+
* THE RULE, because "should this icon show the state or the action?" has a
|
|
166
|
+
* different obvious answer every time it is asked, and answering it per control
|
|
167
|
+
* is how a vocabulary rots:
|
|
168
|
+
*
|
|
169
|
+
* A LIGHT SAYS WHAT IS TRUE. AN ICON SAYS WHAT WILL HAPPEN.
|
|
170
|
+
*
|
|
171
|
+
* So every glyph in a button depicts that button's verb — the eye with a stroke
|
|
172
|
+
* through it means "hide these", not "these are hidden" — and the one piece of
|
|
173
|
+
* pure status, the engine's dot, is deliberately not an icon at all. It is a
|
|
174
|
+
* light: grey for no engine, amber while one is arriving, green once it is
|
|
175
|
+
* running. Nothing has to be read to see it.
|
|
176
|
+
*
|
|
177
|
+
* The chevron is the lozenge's own verb (it opens the panel), which is why the
|
|
178
|
+
* lozenge can carry a status word without becoming a button that lies: the light
|
|
179
|
+
* and the word are the state, the chevron is the action, and they are visibly
|
|
180
|
+
* different things.
|
|
181
|
+
*
|
|
182
|
+
* Inline SVG rather than a font or a file: this is chrome built at runtime, and a
|
|
183
|
+
* control that depends on a network fetch to say what it does is a control that
|
|
184
|
+
* sometimes does not. They inherit currentColor, so dark mode needs nothing.
|
|
185
|
+
*/
|
|
186
|
+
const ICONS = {
|
|
187
|
+
chevron: '<path d="M15 5.5 8.5 12l6.5 6.5"/>',
|
|
188
|
+
power: '<path d="M12 3.2v8.2"/><path d="M6.6 6.7a7.6 7.6 0 1 0 10.8 0"/>',
|
|
189
|
+
restart: '<path d="M20.4 12a8.4 8.4 0 1 1-2.9-6.4"/><path d="M20.4 4.2v5.4h-5.2"/>',
|
|
190
|
+
download: '<path d="M12 3.6v10.6"/><path d="m7.6 10.2 4.4 4.4 4.4-4.4"/><path d="M4.6 19.4h14.8"/>',
|
|
191
|
+
hide: '<path d="M2.5 12S6 6 12 6s9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.7"/><path d="M4 4l16 16"/>',
|
|
192
|
+
show: '<path d="M2.5 12S6 6 12 6s9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.7"/>',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function icon(name) {
|
|
196
|
+
return `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor"`
|
|
197
|
+
+ ` stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</svg>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Set a button's icon and words without disturbing the other. */
|
|
201
|
+
function label(button, name, text) {
|
|
202
|
+
button.querySelector('.icon').innerHTML = icon(name);
|
|
203
|
+
button.querySelector('.label').textContent = text;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function mountPageBar(root, options, bus, programs, queries) {
|
|
207
|
+
const host = root === document ? document.querySelector('main') ?? document.body : root;
|
|
208
|
+
const bar = document.createElement('div');
|
|
209
|
+
bar.className = 'page-controls';
|
|
210
|
+
bar.dataset.open = 'false';
|
|
211
|
+
// Its own counter, not the cell one: a page-control id is not a cell name, and
|
|
212
|
+
// sharing the counter would leave gaps in cell ids for no reason.
|
|
213
|
+
const panelId = `page-controls-${++panels}`;
|
|
214
|
+
// The handle comes FIRST in the markup and sits below the panel on screen, which
|
|
215
|
+
// is the order both want: the button that opens the panel is reached before it
|
|
216
|
+
// in the tab order, and the card rises from the thing that was pressed.
|
|
217
|
+
bar.innerHTML = `<button class="handle" data-act="handle" aria-expanded="false" aria-controls="${panelId}">`
|
|
218
|
+
+ `<span class="dot"></span><span class="count"></span><span class="chev">${icon('chevron')}</span></button>`
|
|
219
|
+
+ `<div class="panel" id="${panelId}">`
|
|
220
|
+
+ '<div class="unit answers"><span class="state answers-state"></span></div>'
|
|
221
|
+
+ '<div class="unit"><span class="state engine-state"></span>'
|
|
222
|
+
+ `<button data-act="restart"><span class="icon">${icon('power')}</span>`
|
|
223
|
+
+ '<span class="label">Start engine</span></button></div>'
|
|
224
|
+
// WHAT THIS IS, rather than what it is doing. Every row above is a state and
|
|
225
|
+
// the button that changes it; this changes nothing and has no verb, so it is
|
|
226
|
+
// not a row — it is the panel's footer, quieter than everything above it.
|
|
227
|
+
//
|
|
228
|
+
// The same words as `prolog-notebook --version`, from the same module, so the
|
|
229
|
+
// page and the command cannot describe one release differently. The engine's
|
|
230
|
+
// own version arrives on the second line once it has started, because until
|
|
231
|
+
// then nothing here knows it: it lives inside the WebAssembly, and asking is
|
|
232
|
+
// what starting is for.
|
|
233
|
+
+ `<p class="about"><span class="running">${colophon().running}`
|
|
234
|
+
+ '<span class="engine-version"></span></span>'
|
|
235
|
+
+ `<span class="legal">${colophon().legal}</span></p>`
|
|
236
|
+
+ '</div>';
|
|
237
|
+
const state = bar.querySelector('.engine-state');
|
|
238
|
+
// One button, whose label is always the thing it will do. "restart engine" over
|
|
239
|
+
// an engine that has never started names a state the reader cannot act on and
|
|
240
|
+
// leaves them asking how to switch it on — which is a fair question to ask of a
|
|
241
|
+
// control that says "off".
|
|
242
|
+
const restart = bar.querySelector('[data-act="restart"]');
|
|
243
|
+
let live = false;
|
|
244
|
+
const handle = bar.querySelector('.handle');
|
|
245
|
+
const count = bar.querySelector('.count');
|
|
246
|
+
|
|
247
|
+
// Open because the reader asked, or open because something went wrong. Kept
|
|
248
|
+
// apart so a flash cannot close a pill the reader deliberately pinned.
|
|
249
|
+
let pinned = false;
|
|
250
|
+
let flash = null;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Open or shut, and that is the whole of it.
|
|
254
|
+
*
|
|
255
|
+
* NOTHING IS MEASURED HERE, which is the point. The first version of this
|
|
256
|
+
* control was one pill that widened, so its open width had to be measured from
|
|
257
|
+
* the DOM on every state change — CSS cannot transition to `auto`. That made
|
|
258
|
+
* the animation cancellable by anything that re-rendered while it ran, and
|
|
259
|
+
* export's "has anything changed?" listener re-rendered on the frame after
|
|
260
|
+
* every click, including the click that opened it. The pill stopped animating
|
|
261
|
+
* and nobody could see why from the CSS, because the CSS was fine.
|
|
262
|
+
*
|
|
263
|
+
* A card that rises needs one attribute and a transform. It cannot be cancelled
|
|
264
|
+
* by a re-render, it costs no reflow, and a unit added tomorrow costs a row.
|
|
265
|
+
*/
|
|
266
|
+
const render = () => {
|
|
267
|
+
const open = pinned || flash !== null;
|
|
268
|
+
bar.dataset.open = String(open);
|
|
269
|
+
handle.setAttribute('aria-expanded', String(open));
|
|
270
|
+
};
|
|
271
|
+
const show = (ms) => {
|
|
272
|
+
clearTimeout(flash);
|
|
273
|
+
// Long enough to read the message, short enough not to become furniture.
|
|
274
|
+
flash = setTimeout(() => { flash = null; render(); }, ms);
|
|
275
|
+
render();
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const close = () => {
|
|
279
|
+
pinned = false;
|
|
280
|
+
clearTimeout(flash);
|
|
281
|
+
flash = null;
|
|
282
|
+
render();
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
handle.addEventListener('click', () => {
|
|
286
|
+
if (pinned || flash !== null) return close();
|
|
287
|
+
pinned = true;
|
|
288
|
+
render();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// The two ways out of any panel a reader expects to work. Both check that it is
|
|
292
|
+
// open first, so this adds no listener behaviour to a page that is only reading.
|
|
293
|
+
document.addEventListener('keydown', (e) => {
|
|
294
|
+
if (e.key === 'Escape' && bar.dataset.open === 'true') {
|
|
295
|
+
close();
|
|
296
|
+
handle.focus();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
document.addEventListener('click', (e) => {
|
|
300
|
+
if (bar.dataset.open === 'true' && !bar.contains(e.target)) close();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const say = (text, title) => {
|
|
304
|
+
state.textContent = text;
|
|
305
|
+
if (title) state.title = title;
|
|
306
|
+
else state.removeAttribute('title');
|
|
307
|
+
// The pill's own label, for when the words are tucked away: the state at a
|
|
308
|
+
// glance, which is all a reader wants until they want the buttons.
|
|
309
|
+
handle.title = text;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Counted in cells rather than bytes, because cells are what the reader can act
|
|
313
|
+
// on — and because "0 of 2 loaded" is the fact that makes a per-cell reset
|
|
314
|
+
// visibly do something.
|
|
315
|
+
const loaded = () => {
|
|
316
|
+
const n = programs.filter((p) => p.isLoaded()).length;
|
|
317
|
+
return `${n} of ${programs.length} program cell${programs.length === 1 ? '' : 's'} loaded`;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Visible proof of the property the chapter is built on: nothing has been
|
|
321
|
+
// downloaded, and the answers above are still there to read.
|
|
322
|
+
//
|
|
323
|
+
// Says how it starts rather than only that it has not: pressing the button is
|
|
324
|
+
// the second way and the slower question to answer, so the sentence names the
|
|
325
|
+
// first. It does not repeat "engine off" either — the light two inches to its
|
|
326
|
+
// right already says that, and a panel that restates its own summary is one
|
|
327
|
+
// nobody finishes reading.
|
|
328
|
+
say('starts on your first Run',
|
|
329
|
+
'the chapter is showing its saved answers; 5.9 MB of WebAssembly arrives when you press Run');
|
|
330
|
+
// "off" on its own says nothing about what is off. The word anchors what the
|
|
331
|
+
// pill is for, which is most of what makes a two-inch control discoverable.
|
|
332
|
+
count.textContent = 'Engine off';
|
|
333
|
+
render();
|
|
334
|
+
|
|
335
|
+
// How long this engine has been the engine. Kept rather than announced once,
|
|
336
|
+
// because "restarted 13:53:10" stops being visible the moment anything else
|
|
337
|
+
// happens — and it is precisely then that the reader wants it.
|
|
338
|
+
let age = null;
|
|
339
|
+
|
|
340
|
+
bus.on((event) => {
|
|
341
|
+
// A cell's own hide control moved something this panel is reporting on.
|
|
342
|
+
if (event.kind === 'answers') return refreshAnswers();
|
|
343
|
+
if (event.kind === 'booting') {
|
|
344
|
+
// Lit from wherever the engine was asked for — a Run halfway up the chapter
|
|
345
|
+
// starts it just as this button does, and the light should not care which.
|
|
346
|
+
bar.classList.add('busy');
|
|
347
|
+
count.textContent = 'Starting…';
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
bar.classList.remove('busy');
|
|
351
|
+
if (event.kind === 'started') {
|
|
352
|
+
live = true;
|
|
353
|
+
label(restart, 'restart', 'Restart engine');
|
|
354
|
+
restart.title = 'throw this engine away and load your cells into a fresh one';
|
|
355
|
+
age = `started ${event.at}`;
|
|
356
|
+
} else if (event.kind === 'restarted') {
|
|
357
|
+
age = `restarted ${event.at}`;
|
|
358
|
+
} else if (event.kind === 'engine-version') {
|
|
359
|
+
// Joins the line that says what is running, because that is what it is.
|
|
360
|
+
// A rebuilt engine is the same build, so this is set once and left.
|
|
361
|
+
bar.querySelector('.engine-version').textContent = ` · SWI-Prolog ${event.version}`;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (!age) return;
|
|
365
|
+
say(`${loaded()} · ${age}`, event.kind === 'restarted'
|
|
366
|
+
? 'assert/retract state is gone; the clauses in your cells were loaded again'
|
|
367
|
+
: 'SWI-Prolog is running in a Web Worker');
|
|
368
|
+
bar.classList.add('live');
|
|
369
|
+
// On or off, not a count. A count is only readable with the pill open, and by
|
|
370
|
+
// then the words beside it say the same thing at greater length.
|
|
371
|
+
count.textContent = 'Engine on';
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Work the whole chapter cold. Per-cell hiding is right there in each cell, but a
|
|
375
|
+
// reader who wants to do the exercises should not have to click every one of them
|
|
376
|
+
// first — and pressing Run on any cell brings that cell's answers back anyway.
|
|
377
|
+
//
|
|
378
|
+
// A STATE AND A VERB, like the engine beside it. A button labelled with its
|
|
379
|
+
// action always implies the opposite of what is true — "Hide saved answers" can
|
|
380
|
+
// only appear while they are visible — so a control with no state phrase leaves
|
|
381
|
+
// its own label as the only clue, and that clue has to be read backwards. That
|
|
382
|
+
// was the asymmetry: the engine had both, this had only the verb.
|
|
383
|
+
// Cells that HAVE saved answers in the file — but what this unit reports on is
|
|
384
|
+
// the ones SHOWING them. A cell displaying the reader's own run is not a spoiler:
|
|
385
|
+
// its saved answers are behind reset, and it refuses to hide precisely because
|
|
386
|
+
// they are no longer what is on screen.
|
|
387
|
+
//
|
|
388
|
+
// Counting the file rather than the screen is what jammed this control. The run
|
|
389
|
+
// cell could never be hidden, so "is anything still showing?" was permanently
|
|
390
|
+
// true, the set never reached all-hidden, the label never flipped to Show, and
|
|
391
|
+
// every click after the first re-hid the same cells and did nothing visible.
|
|
392
|
+
const spoilers = queries.filter((q) => q.hasSaved);
|
|
393
|
+
const showing = () => spoilers.filter((q) => q.showsChapter());
|
|
394
|
+
const answersUnit = bar.querySelector('.unit.answers');
|
|
395
|
+
const answersState = bar.querySelector('.answers-state');
|
|
396
|
+
let refreshAnswers = () => {};
|
|
397
|
+
if (!spoilers.length) {
|
|
398
|
+
answersUnit.remove();
|
|
399
|
+
} else {
|
|
400
|
+
const peek = document.createElement('button');
|
|
401
|
+
peek.dataset.act = 'peek-all';
|
|
402
|
+
peek.innerHTML = '<span class="icon"></span><span class="label"></span>';
|
|
403
|
+
peek.title = 'put every saved answer in this chapter out of sight, to work through it cold';
|
|
404
|
+
answersUnit.appendChild(peek);
|
|
405
|
+
|
|
406
|
+
refreshAnswers = () => {
|
|
407
|
+
// Counted rather than remembered, so hiding one output by hand leaves this
|
|
408
|
+
// telling the truth instead of contradicting the page.
|
|
409
|
+
const on = showing();
|
|
410
|
+
const hidden = on.filter((q) => q.isHidden()).length;
|
|
411
|
+
const all = on.length > 0 && hidden === on.length;
|
|
412
|
+
// Every cell is showing the reader's own answers: there is nothing here of
|
|
413
|
+
// the chapter's to put away. Saying so and going quiet beats offering a
|
|
414
|
+
// button that cannot do anything — a control that does nothing reads as a
|
|
415
|
+
// broken page, which is how this one was reported.
|
|
416
|
+
peek.disabled = on.length === 0;
|
|
417
|
+
answersState.textContent = on.length === 0 ? 'No saved answers on screen'
|
|
418
|
+
: hidden === 0 ? 'Answers shown'
|
|
419
|
+
: all ? 'Answers hidden'
|
|
420
|
+
: `${hidden} of ${on.length} hidden`;
|
|
421
|
+
label(peek, all ? 'show' : 'hide', all ? 'Show saved answers' : 'Hide saved answers');
|
|
422
|
+
render();
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
peek.addEventListener('click', () => {
|
|
426
|
+
// Anything still showing means the useful move is to hide the rest.
|
|
427
|
+
const on = showing();
|
|
428
|
+
const away = on.some((q) => !q.isHidden());
|
|
429
|
+
for (const q of on) q.setHidden(away);
|
|
430
|
+
refreshAnswers();
|
|
431
|
+
});
|
|
432
|
+
refreshAnswers();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
restart.title = 'download SWI-Prolog and have it ready, so your first Run is not the slow one';
|
|
436
|
+
|
|
437
|
+
restart.addEventListener('click', async () => {
|
|
438
|
+
const starting = !live;
|
|
439
|
+
restart.disabled = true;
|
|
440
|
+
bar.classList.add('busy');
|
|
441
|
+
count.textContent = starting ? 'Starting…' : 'Restarting…';
|
|
442
|
+
say(starting ? 'starting SWI-Prolog (5.9 MB, first time only)…' : 'restarting…');
|
|
443
|
+
try {
|
|
444
|
+
if (starting) {
|
|
445
|
+
// Started empty, deliberately: consulting the chapter here would load
|
|
446
|
+
// cells the reader has not asked for, and Run loads what it needs anyway.
|
|
447
|
+
await boot(options, bus);
|
|
448
|
+
} else {
|
|
449
|
+
const session = await sessionFactory(options)(options);
|
|
450
|
+
await session.restart();
|
|
451
|
+
bus.emit({ kind: 'restarted', at: clock(), cells: [...session.log].length });
|
|
452
|
+
}
|
|
453
|
+
} catch (e) {
|
|
454
|
+
bar.classList.remove('busy');
|
|
455
|
+
count.textContent = live ? 'Engine on' : 'Engine off';
|
|
456
|
+
say(`${starting ? 'start' : 'restart'} failed: ${e.message}`);
|
|
457
|
+
show(10000);
|
|
458
|
+
} finally {
|
|
459
|
+
restart.disabled = false;
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
host.appendChild(bar);
|
|
464
|
+
render();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Add "download this notebook" to the page's controls.
|
|
469
|
+
*
|
|
470
|
+
* Called by whoever HAS the parsed model — page.js, because a markdown cell has
|
|
471
|
+
* been rendered to HTML by the time it reaches this file and cannot be read back
|
|
472
|
+
* out of the DOM. So notebook.js offers the affordance and someone else supplies
|
|
473
|
+
* the bytes; this module still knows nothing about markdown.
|
|
474
|
+
*
|
|
475
|
+
* A state and a verb, like everything else in that pill: whether the notebook on
|
|
476
|
+
* screen is still the chapter's, and the button that hands you a copy of it.
|
|
477
|
+
*
|
|
478
|
+
* @param {Element|Document} root
|
|
479
|
+
* @param {() => {filename: string, text: string}} produce
|
|
480
|
+
*/
|
|
481
|
+
export function offerDownload(root, options = {}) {
|
|
482
|
+
const { produce, published = null, isEdited = null, on = null } = options;
|
|
483
|
+
const scope = root && root.querySelector ? root : document;
|
|
484
|
+
const bar = scope.querySelector('.page-controls') ?? document.querySelector('.page-controls');
|
|
485
|
+
if (!bar) return;
|
|
486
|
+
|
|
487
|
+
const unit = document.createElement('div');
|
|
488
|
+
unit.className = 'unit notebook';
|
|
489
|
+
// THE STATE IS THE CHOICE, once there is one to make.
|
|
490
|
+
//
|
|
491
|
+
// Every other row in this card says what is true and offers the verb that
|
|
492
|
+
// changes it. This row is the one place where the reader's version and the
|
|
493
|
+
// chapter's both exist at once, and what the button should hand them is
|
|
494
|
+
// genuinely a question — so the phrase that was reporting which of the two is
|
|
495
|
+
// on screen becomes the control that picks between them. One statement, one
|
|
496
|
+
// button, and no second download to be taken by mistake.
|
|
497
|
+
//
|
|
498
|
+
// A PHRASE UNTIL THERE ARE TWO. Before the reader has run or edited anything
|
|
499
|
+
// their version IS the chapter, and a menu with one item is a control
|
|
500
|
+
// pretending to offer something.
|
|
501
|
+
unit.innerHTML = '<span class="state notebook-state"><span class="only"></span>'
|
|
502
|
+
+ '<span class="picker" hidden><select aria-label="which version to download">'
|
|
503
|
+
+ '<option value="mine">Your version</option>'
|
|
504
|
+
+ '<option value="published">As published</option>'
|
|
505
|
+
+ `</select>${icon('chevron')}</span></span>`
|
|
506
|
+
+ `<button data-act="download"><span class="icon">${icon('download')}</span>`
|
|
507
|
+
+ '<span class="label">Download .prolog.md</span></button>';
|
|
508
|
+
bar.querySelector('.panel').prepend(unit);
|
|
509
|
+
|
|
510
|
+
const only = unit.querySelector('.only');
|
|
511
|
+
const picker = unit.querySelector('.picker');
|
|
512
|
+
const select = unit.querySelector('select');
|
|
513
|
+
|
|
514
|
+
const say = () => {
|
|
515
|
+
// ASKED, not sniffed. `data-edited` is written as a side effect of a cell's
|
|
516
|
+
// own reset button refreshing, so it is missing on a cell that has no reset
|
|
517
|
+
// and stale between an action and that cell's next refresh. The cells
|
|
518
|
+
// themselves are the authority on whether anything here is the reader's.
|
|
519
|
+
const edited = isEdited
|
|
520
|
+
? isEdited()
|
|
521
|
+
: [...scope.querySelectorAll('.cell')].some((cell) => cell.dataset.edited === 'true');
|
|
522
|
+
// Offered only when both exist AND someone can produce the published copy.
|
|
523
|
+
const choose = edited && Boolean(published);
|
|
524
|
+
picker.hidden = !choose;
|
|
525
|
+
only.hidden = choose;
|
|
526
|
+
only.textContent = edited ? 'Your version' : 'As published';
|
|
527
|
+
// Not reset when the reader edits again: they chose, and a control that
|
|
528
|
+
// silently returns to its default hands them a file they did not pick.
|
|
529
|
+
if (!choose) select.value = 'mine';
|
|
530
|
+
unit.querySelector('button').title = choose && select.value === 'published'
|
|
531
|
+
? 'the chapter exactly as published, without your edits — they stay on the page'
|
|
532
|
+
: 'this notebook as it now stands, with your edits and your answers in it';
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
unit.querySelector('button').addEventListener('click', () => {
|
|
536
|
+
const from = !picker.hidden && select.value === 'published' ? published : produce;
|
|
537
|
+
const { filename, text } = from();
|
|
538
|
+
download(filename, text);
|
|
539
|
+
});
|
|
540
|
+
select.addEventListener('change', say);
|
|
541
|
+
|
|
542
|
+
// Most of what changes this is a click or a keystroke, and both bubble.
|
|
543
|
+
scope.addEventListener('input', say);
|
|
544
|
+
scope.addEventListener('click', () => requestAnimationFrame(say));
|
|
545
|
+
// But not all of it. A cell can produce answers with nobody touching it — an
|
|
546
|
+
// automatic re-run is started by a consult somewhere else, and finishes after
|
|
547
|
+
// that click has been and gone — so the notebook's own events are heard too.
|
|
548
|
+
// Without this the row reports the state as it was before the run, until the
|
|
549
|
+
// reader happens to click again.
|
|
550
|
+
on?.(() => say());
|
|
551
|
+
say();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function sessionFactory(options) {
|
|
555
|
+
return options.createSession ?? createSession;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function boot(options, bus, status) {
|
|
559
|
+
if (!bus.booted && status) {
|
|
25
560
|
status.textContent = 'starting SWI-Prolog (5.9 MB, first time only)…';
|
|
26
561
|
status.className = 'status busy';
|
|
27
562
|
}
|
|
28
|
-
const
|
|
29
|
-
|
|
563
|
+
const wasBooted = bus.booted;
|
|
564
|
+
if (!wasBooted) bus.emit({ kind: 'booting', at: clock() });
|
|
565
|
+
const session = await sessionFactory(options)(options);
|
|
566
|
+
bus.booted = true;
|
|
567
|
+
if (!wasBooted) {
|
|
568
|
+
bus.emit({ kind: 'started', at: clock() });
|
|
569
|
+
// Asked for, not waited for. The light going green is the answer to "did it
|
|
570
|
+
// start", and holding that back for a round trip about a version number
|
|
571
|
+
// would be the page reporting the less interesting fact first.
|
|
572
|
+
prologVersion(session)
|
|
573
|
+
.then((version) => version && bus.emit({ kind: 'engine-version', version }))
|
|
574
|
+
.catch(() => {});
|
|
575
|
+
}
|
|
30
576
|
return session;
|
|
31
577
|
}
|
|
32
578
|
|
|
33
|
-
function mountProgram(cell) {
|
|
579
|
+
function mountProgram(cell, options, bus) {
|
|
34
580
|
const source = cell.querySelector('textarea');
|
|
35
|
-
const button = cell.querySelector('button');
|
|
581
|
+
const button = cell.querySelector('[data-act="consult"]') ?? cell.querySelector('button');
|
|
582
|
+
const resetBtn = cell.querySelector('[data-act="reset"]');
|
|
36
583
|
const status = cell.querySelector('.status');
|
|
37
|
-
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The one cell whose state a re-consult does not undo (format §8).
|
|
587
|
+
*
|
|
588
|
+
* Chrome, not content: whether a cell is stateful is a fact about what happens
|
|
589
|
+
* when you run it, and a printed page has no engine for it to be true of. It is
|
|
590
|
+
* also derived from the text on every keystroke rather than at mount, so typing
|
|
591
|
+
* `:- dynamic` makes the badge appear — which is the moment the reader most
|
|
592
|
+
* wants to be told, rather than after they have asserted something and found it
|
|
593
|
+
* survived an edit.
|
|
594
|
+
*/
|
|
595
|
+
const stateful = document.createElement('span');
|
|
596
|
+
stateful.className = 'badge stateful';
|
|
597
|
+
stateful.textContent = 'stateful';
|
|
598
|
+
stateful.hidden = true;
|
|
599
|
+
status.parentNode.insertBefore(stateful, status);
|
|
600
|
+
|
|
601
|
+
// One cell, one virtual file. A generated cell carries its notebook id, so SWI
|
|
602
|
+
// says "/p-family.pl" when this cell redefines another's clauses — a warning
|
|
603
|
+
// that names a cell the reader can actually find in the source.
|
|
604
|
+
const name = cell.dataset.cell || `cell-${serial++}`;
|
|
605
|
+
|
|
606
|
+
// The chapter's own version of this cell, for the way back. Correct today,
|
|
607
|
+
// because mount() runs against markup generated straight from the file. It
|
|
608
|
+
// becomes WRONG the moment a scratchpad restores the reader's edits before
|
|
609
|
+
// mount (869ectt5d) — at that point this must come from the parsed model.
|
|
610
|
+
const published = source.value;
|
|
611
|
+
|
|
612
|
+
// What the engine is actually holding for this cell, and when it took it.
|
|
613
|
+
let loaded = null;
|
|
614
|
+
let failure = null;
|
|
38
615
|
|
|
39
616
|
autosize(source);
|
|
40
617
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
618
|
+
const say = (text, cls, title) => {
|
|
619
|
+
status.textContent = text;
|
|
620
|
+
status.className = `status ${cls}`;
|
|
621
|
+
if (title) status.title = title;
|
|
622
|
+
else status.removeAttribute('title');
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Say what is true, which is not always what the reader last pressed.
|
|
627
|
+
*
|
|
628
|
+
* A tick that still says "consulted" over text the reader has since edited is
|
|
629
|
+
* reassuring and wrong — and it became easy to hit the moment Run started
|
|
630
|
+
* consulting cells by itself (869ejgyaa).
|
|
631
|
+
*
|
|
632
|
+
* Every state is named, including the ones that used to be blank: a cell that
|
|
633
|
+
* says nothing looks like a cell whose buttons do nothing.
|
|
634
|
+
*/
|
|
635
|
+
const refresh = () => {
|
|
636
|
+
const dynamic = declaredDynamic(source.value);
|
|
637
|
+
stateful.hidden = dynamic.size === 0;
|
|
638
|
+
if (!stateful.hidden) {
|
|
639
|
+
stateful.title = `this cell declares :- dynamic ${[...dynamic].join(', ')}.`
|
|
640
|
+
+ ' Whatever a goal asserts into it lives in no file, so re-consulting the cell will'
|
|
641
|
+
+ ' not undo it and neither will reset — restart the engine to clear it.';
|
|
642
|
+
}
|
|
643
|
+
if (resetBtn) {
|
|
644
|
+
// Enabled whenever this cell is not as the chapter published it — which
|
|
645
|
+
// includes being LOADED, because a published chapter has no engine at all.
|
|
646
|
+
// Reset staying grey after a consult is what made this read as broken: the
|
|
647
|
+
// reader had just changed the world and was offered no way back.
|
|
648
|
+
const mine = source.value !== published || loaded !== null;
|
|
649
|
+
// On the element, so the page-level control can read "is any of this the
|
|
650
|
+
// reader's?" without holding a reference to every cell.
|
|
651
|
+
cell.dataset.edited = String(source.value !== published);
|
|
652
|
+
resetBtn.disabled = !mine;
|
|
653
|
+
resetBtn.title = mine
|
|
654
|
+
? 'undo this cell: the chapter’s program back, and out of the engine'
|
|
655
|
+
: 'this cell is exactly as the chapter published it';
|
|
656
|
+
}
|
|
657
|
+
if (failure) return say(failure, 'err');
|
|
658
|
+
if (!loaded) {
|
|
659
|
+
return say('not consulted', '',
|
|
660
|
+
'the engine does not have this cell; Run on a query below loads it');
|
|
661
|
+
}
|
|
662
|
+
if (source.value !== loaded.text) {
|
|
663
|
+
return say('edited since consulted', 'warn',
|
|
664
|
+
`consulted ${loaded.at}; press Consult to load your changes`);
|
|
665
|
+
}
|
|
666
|
+
if (loaded.warning) return say(loaded.warning, 'warn', `consulted ${loaded.at}`);
|
|
667
|
+
return say(`✓ consulted ${loaded.at}`, 'ok', 'the engine is holding exactly this text');
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* @param {object} session
|
|
672
|
+
* @param {'consult'|'run'|'auto'} cause who asked for this — see the `consulted`
|
|
673
|
+
* event below. It is threaded rather than inferred because the difference
|
|
674
|
+
* between "the reader said this is what I mean now" and "the page reloaded
|
|
675
|
+
* its own state" is invisible from in here.
|
|
676
|
+
*/
|
|
677
|
+
const consult = async (session, cause = 'consult') => {
|
|
678
|
+
const text = source.value;
|
|
679
|
+
const r = await session.consult(text, name);
|
|
680
|
+
// A warning here usually means this cell has just destroyed another
|
|
681
|
+
// cell's clauses, which the reader has no other way of finding out.
|
|
682
|
+
const warning = r.messages && r.messages.find((m) => m.kind === 'warning');
|
|
683
|
+
// Said the way this cell would say it: one cell is one virtual file, so SWI's
|
|
684
|
+
// line numbers are already the cell's own, and the path in front of them is a
|
|
685
|
+
// filename the reader never chose and cannot open.
|
|
686
|
+
failure = r.ok ? null : readableInCell(r.error, name);
|
|
687
|
+
loaded = r.ok
|
|
688
|
+
? { text, at: clock(), warning: warning ? readableInCell(warning.text, name) : null }
|
|
689
|
+
: null;
|
|
690
|
+
refresh();
|
|
691
|
+
// Any answer below this cell was produced against whatever it held before.
|
|
692
|
+
// The cause travels with it: a cell that re-runs itself on a consult must not
|
|
693
|
+
// then react to the consults its own re-run performed (format §5).
|
|
694
|
+
bus.emit({ kind: 'consulted', name, at: clock(), cause });
|
|
695
|
+
return r;
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
source.addEventListener('input', () => {
|
|
699
|
+
refresh();
|
|
700
|
+
bus.emit({ kind: 'edited', name });
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
/** Any button that talks to the engine: busy while it does, honest afterwards. */
|
|
704
|
+
const working = async (btn, job) => {
|
|
705
|
+
const label = btn.textContent;
|
|
706
|
+
btn.disabled = true;
|
|
707
|
+
btn.textContent = 'Working…';
|
|
45
708
|
try {
|
|
46
|
-
|
|
47
|
-
const r = session.consult(source.value, name);
|
|
48
|
-
// A warning here usually means this cell has just destroyed another
|
|
49
|
-
// cell's clauses, which the reader has no other way of finding out.
|
|
50
|
-
const warning = r.messages && r.messages.find((m) => m.kind === 'warning');
|
|
51
|
-
status.textContent = r.ok ? warning ? warning.text : '✓ consulted' : r.error;
|
|
52
|
-
status.className = `status ${r.ok ? (warning ? 'warn' : 'ok') : 'err'}`;
|
|
709
|
+
await job();
|
|
53
710
|
} catch (e) {
|
|
54
|
-
|
|
55
|
-
status.className = 'status err';
|
|
711
|
+
failure = e.message;
|
|
56
712
|
} finally {
|
|
57
|
-
|
|
58
|
-
button
|
|
713
|
+
btn.textContent = label;
|
|
714
|
+
// refresh() re-decides `disabled` from the state, so a button never comes
|
|
715
|
+
// back enabled just because it was the one that was pressed.
|
|
716
|
+
btn.disabled = false;
|
|
717
|
+
refresh();
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
button.addEventListener('click', () =>
|
|
722
|
+
working(button, async () => consult(await boot(options, bus, status))));
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Undo this cell entirely: the chapter's program back, and out of the engine.
|
|
726
|
+
*
|
|
727
|
+
* Taking it out matters as much as putting the text back. A reader who presses
|
|
728
|
+
* reset has said "pretend I never touched this", and a page that restores the
|
|
729
|
+
* text while quietly leaving the clauses loaded has agreed with them in words
|
|
730
|
+
* and disagreed in fact.
|
|
731
|
+
*
|
|
732
|
+
* The cell is deliberately NOT re-consulted here, and nothing cascades to the
|
|
733
|
+
* cells that used it: Run on any query below consults the cells above it
|
|
734
|
+
* (869ejgyaa), so the chapter heals itself on the next click, and until then the
|
|
735
|
+
* tick says "not consulted" because that is what is true.
|
|
736
|
+
*/
|
|
737
|
+
resetBtn?.addEventListener('click', () => working(resetBtn, async () => {
|
|
738
|
+
source.value = published;
|
|
739
|
+
autosizeNow(source);
|
|
740
|
+
if (loaded) {
|
|
741
|
+
const session = await boot(options, bus, status);
|
|
742
|
+
await session.unconsult(name);
|
|
743
|
+
loaded = null;
|
|
744
|
+
failure = null;
|
|
745
|
+
bus.emit({ kind: 'unconsulted', name, at: clock() });
|
|
746
|
+
}
|
|
747
|
+
bus.emit({ kind: 'edited', name });
|
|
748
|
+
}));
|
|
749
|
+
|
|
750
|
+
// A rebuilt engine consulted this cell again, at a new time. Saying the old one
|
|
751
|
+
// is a small lie that costs nothing to avoid and is invisible right up until the
|
|
752
|
+
// reader is trying to work out what the engine is holding — the one thing this
|
|
753
|
+
// tick exists to tell them.
|
|
754
|
+
bus.on((event) => {
|
|
755
|
+
if (event.kind === 'restarted' && loaded) {
|
|
756
|
+
loaded = { ...loaded, at: event.at };
|
|
757
|
+
refresh();
|
|
59
758
|
}
|
|
60
759
|
});
|
|
760
|
+
|
|
761
|
+
refresh();
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
name,
|
|
765
|
+
/** The exact text the engine would be given, for a query deciding if it is stale. */
|
|
766
|
+
text: () => source.value,
|
|
767
|
+
isEdited: () => source.value !== published,
|
|
768
|
+
isLoaded: () => loaded !== null,
|
|
769
|
+
/**
|
|
770
|
+
* Load this cell unless it is already loaded at exactly this text.
|
|
771
|
+
*
|
|
772
|
+
* Cheap enough to do on every Run: the second Run of a chapter consults
|
|
773
|
+
* nothing at all, and an edited cell invalidates itself and nothing else.
|
|
774
|
+
*/
|
|
775
|
+
ensure: async (session, cause = 'run') =>
|
|
776
|
+
(session.log.isCurrent(name, source.value) ? { ok: true } : consult(session, cause)),
|
|
777
|
+
/** What this cell defines, used only to explain an error, never to run one. */
|
|
778
|
+
defines: () => definedPredicates(source.value),
|
|
779
|
+
};
|
|
61
780
|
}
|
|
62
781
|
|
|
63
|
-
function mountQuery(cell) {
|
|
782
|
+
function mountQuery(cell, options, bus, { above = [], below = [], prediction = null } = {}) {
|
|
64
783
|
const input = cell.querySelector('input');
|
|
65
784
|
const runBtn = cell.querySelector('[data-act="run"]');
|
|
66
785
|
const nextBtn = cell.querySelector('[data-act="next"]');
|
|
67
786
|
const allBtn = cell.querySelector('[data-act="all"]');
|
|
787
|
+
const stopBtn = cell.querySelector('[data-act="stop"]');
|
|
788
|
+
const resetBtn = cell.querySelector('[data-act="reset"]');
|
|
789
|
+
const status = cell.querySelector('.status');
|
|
68
790
|
const out = cell.querySelector('.out');
|
|
69
791
|
|
|
792
|
+
// The chapter's own query and the chapter's own answers, kept together because
|
|
793
|
+
// they only mean anything together (docs/modes.md §3). Same caveat as a program
|
|
794
|
+
// cell: correct until a scratchpad restores the reader's version before mount.
|
|
795
|
+
const published = { goal: input.value, out: out.innerHTML };
|
|
796
|
+
|
|
70
797
|
let query = null;
|
|
798
|
+
let session = null;
|
|
71
799
|
let count = 0;
|
|
800
|
+
let running = false;
|
|
801
|
+
let aborting = false;
|
|
802
|
+
// The reader's own run: when, against what — and what has happened since.
|
|
803
|
+
let ran = null;
|
|
804
|
+
// Kept as SWI rendered each solution, so an export carries the answers the
|
|
805
|
+
// reader actually saw rather than a reconstruction of them from the screen.
|
|
806
|
+
let produced = [];
|
|
807
|
+
let failed = null;
|
|
808
|
+
// Whether the SEARCH ended, which is not the same as whether the RUN ended. A
|
|
809
|
+
// reader who takes three of six and stops leaves a query that was never
|
|
810
|
+
// exhausted, and only the engine saying `done` can tell us otherwise — the
|
|
811
|
+
// buttons look identical either way (format §6).
|
|
812
|
+
let exhausted = false;
|
|
813
|
+
// Whose answers are on screen. A flag rather than a diff of out.innerHTML: the
|
|
814
|
+
// reader's own controls live in there too, and chrome must never read as a change
|
|
815
|
+
// to the chapter.
|
|
816
|
+
let mine = false;
|
|
817
|
+
let hidden = false;
|
|
818
|
+
// The author's own spoiler mark (format §5). It is a starting state rather than
|
|
819
|
+
// a lock: the reader can always press show, because withholding the answer from
|
|
820
|
+
// someone who has decided they want it is theatre, not teaching.
|
|
821
|
+
const hold = cell.dataset.hold ?? null;
|
|
822
|
+
let held = hold !== null && published.out !== '';
|
|
823
|
+
// Who decides when these answers are refreshed (format §5). `manual` is the
|
|
824
|
+
// default and is the whole of the behaviour this file had until now.
|
|
825
|
+
const auto = cell.dataset.rerun === 'auto';
|
|
826
|
+
// The renderer's verdict on the answers this cell was PUBLISHED with: their
|
|
827
|
+
// input-hash did not match the program above them, so the file itself shipped
|
|
828
|
+
// stale. Read once, because a run replaces the answers it is a claim about.
|
|
829
|
+
const savedStale = cell.dataset.stale === 'saved';
|
|
830
|
+
// Latched, unlike everything else here, because it is the one change that
|
|
831
|
+
// leaves no trace in the text: a rebuilt engine looks exactly like the old one.
|
|
832
|
+
let engineChanged = false;
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Everything these answers depended on.
|
|
836
|
+
*
|
|
837
|
+
* Only the cells ABOVE, because those are the only ones Run loads. Compared as
|
|
838
|
+
* text rather than as a hash because the strings are already in hand and there
|
|
839
|
+
* are five of them, not five thousand.
|
|
840
|
+
*/
|
|
841
|
+
const context = () => above.map((p) => `${p.name} ${p.text()}`).join('\n');
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Why the answers on screen are no longer the answers this page would produce,
|
|
845
|
+
* or null.
|
|
846
|
+
*
|
|
847
|
+
* The question a reader cannot answer by looking, and the one that quietly makes
|
|
848
|
+
* a notebook untrustworthy when nobody answers it: they edit a program cell, the
|
|
849
|
+
* answers below sit there unchanged, and nothing says those answers came from a
|
|
850
|
+
* program that no longer exists. This is the live twin of the input-hash check
|
|
851
|
+
* the renderer does for SAVED answers (render.js) — the same question, asked of
|
|
852
|
+
* a run that happened a minute ago rather than at publish time.
|
|
853
|
+
*
|
|
854
|
+
* DERIVED, not remembered. A reader who edits a program cell and then undoes the
|
|
855
|
+
* edit is back where they started, and a warning that stays put through that
|
|
856
|
+
* teaches them to ignore warnings — which is worse than never having shown one.
|
|
857
|
+
*/
|
|
858
|
+
const staleReason = () => {
|
|
859
|
+
if (!ran) return null;
|
|
860
|
+
// A restart replays the same clauses, so answers only really change for a
|
|
861
|
+
// query that depended on assert/retract state — but that is exactly the case
|
|
862
|
+
// format §8 says restart exists for, and the reader is owed the flag.
|
|
863
|
+
if (engineChanged) return 'engine restarted since this ran';
|
|
864
|
+
// Comparing the whole context ignores cells BELOW without this having to know
|
|
865
|
+
// which cell is which: they were never part of it.
|
|
866
|
+
if (ran.context !== context()) return 'program changed since this ran';
|
|
867
|
+
if (input.value.trim().replace(/\.$/, '') !== ran.goal) return 'query edited since this ran';
|
|
868
|
+
return null;
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const refresh = () => {
|
|
872
|
+
if (resetBtn) {
|
|
873
|
+
const changed = mine || input.value !== published.goal;
|
|
874
|
+
cell.dataset.edited = String(changed);
|
|
875
|
+
resetBtn.disabled = !changed;
|
|
876
|
+
resetBtn.title = changed
|
|
877
|
+
? 'put the chapter’s query and its saved answers back'
|
|
878
|
+
: 'this query and these answers are exactly as the chapter published them';
|
|
879
|
+
}
|
|
880
|
+
if (!status) return;
|
|
881
|
+
if (!ran) {
|
|
882
|
+
// Deliberately silent: the output's own first line already says whose
|
|
883
|
+
// answers those are, and a second label saying it again is noise.
|
|
884
|
+
status.textContent = '';
|
|
885
|
+
status.className = 'status';
|
|
886
|
+
status.removeAttribute('title');
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const stale = staleReason();
|
|
890
|
+
status.textContent = stale ?? `✓ ran ${ran.at}`;
|
|
891
|
+
status.className = `status ${stale ? 'warn' : 'ok'}`;
|
|
892
|
+
status.title = stale
|
|
893
|
+
? `ran ${ran.at}; press Run to see what the program does now`
|
|
894
|
+
: 'these answers came from the cells above, exactly as they are now';
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
bus.on((event) => {
|
|
898
|
+
if (event.kind === 'restarted' && ran) engineChanged = true;
|
|
899
|
+
refresh();
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Put the chapter's answers away, so the reader can work the question cold.
|
|
904
|
+
*
|
|
905
|
+
* A chapter shows its answers, always — that is the property the whole project
|
|
906
|
+
* is for (docs/modes.md §2), and it is why this is opt-in rather than the
|
|
907
|
+
* default. But prose that says "press Run for the first answer, then ; next to
|
|
908
|
+
* walk through the rest" is arguing with a page that has already printed all
|
|
909
|
+
* six, and the reader loses either the exercise or the trust.
|
|
910
|
+
*
|
|
911
|
+
* The answers are hidden, never discarded: they are the chapter's, and one click
|
|
912
|
+
* brings them back. That is also why this is not what reset does — there is
|
|
913
|
+
* nothing here to undo.
|
|
914
|
+
*
|
|
915
|
+
* The author can declare the same thing in the file — `hold="until-run"` on a
|
|
916
|
+
* query cell (format §5) — and it arrives here as the same mechanism, because a
|
|
917
|
+
* held answer and a hidden one differ only in who asked for it and what ends the
|
|
918
|
+
* wait. The reader's half needed no format change, which is why it came first.
|
|
919
|
+
*/
|
|
920
|
+
/** What a held cell is waiting for, in the words of the thing that ends it. */
|
|
921
|
+
const heldNote = () => (hold === 'until-answered'
|
|
922
|
+
? ' · held until you write your prediction above'
|
|
923
|
+
: ' · held until you run it');
|
|
924
|
+
|
|
925
|
+
const setHidden = (value) => {
|
|
926
|
+
const was = hidden;
|
|
927
|
+
hidden = value && !mine;
|
|
928
|
+
// Showing an answer ENDS THE AUTHOR'S WAIT for it, by whatever route it was
|
|
929
|
+
// shown — this cell's control, the page's, a prediction answered, a run, a
|
|
930
|
+
// reset afterwards. The reader has seen it, and a cell that goes back to
|
|
931
|
+
// saying "held until you run it" is arguing with them. Withholding it twice
|
|
932
|
+
// is theatre; the first time is the teaching.
|
|
933
|
+
if (!hidden) held = false;
|
|
934
|
+
// The page's own control reports on every cell, so it has to hear about one.
|
|
935
|
+
if (hidden !== was) bus.emit({ kind: 'answers' });
|
|
936
|
+
out.classList.toggle('answers-hidden', hidden);
|
|
937
|
+
const toggle = out.querySelector('[data-act="peek"]');
|
|
938
|
+
// Same word and same icon as the whole-chapter control in the page pill: one
|
|
939
|
+
// vocabulary, learned once.
|
|
940
|
+
if (toggle) label(toggle, hidden ? 'show' : 'hide', hidden ? 'show' : 'hide');
|
|
941
|
+
const note = out.querySelector('.peek-note');
|
|
942
|
+
// A held cell says WHY it is empty and what ends the wait. "hidden" alone
|
|
943
|
+
// reads as something the reader did and forgot doing.
|
|
944
|
+
if (note) note.textContent = hidden ? (held ? heldNote() : ' · hidden') : '';
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* CHROME, NOT CONTENT, and injected rather than rendered for the usual reason: a
|
|
950
|
+
* built page, an EPUB or the GitHub view must not carry a hide button that
|
|
951
|
+
* cannot work — and a printed chapter has no way to unhide.
|
|
952
|
+
*/
|
|
953
|
+
const decorateSaved = () => {
|
|
954
|
+
const from = out.querySelector('.line.from');
|
|
955
|
+
if (mine || !from || from.querySelector('[data-act="peek"]')) return;
|
|
956
|
+
const note = document.createElement('span');
|
|
957
|
+
note.className = 'peek-note';
|
|
958
|
+
const toggle = document.createElement('button');
|
|
959
|
+
toggle.className = 'peek';
|
|
960
|
+
toggle.dataset.act = 'peek';
|
|
961
|
+
toggle.innerHTML = '<span class="icon"></span><span class="label"></span>';
|
|
962
|
+
toggle.title = 'the chapter’s answers stay here either way; this only puts them out of sight';
|
|
963
|
+
toggle.addEventListener('click', () => setHidden(!hidden));
|
|
964
|
+
from.append(note, toggle);
|
|
965
|
+
setHidden(hidden);
|
|
966
|
+
};
|
|
72
967
|
|
|
73
968
|
const write = (text, cls) => {
|
|
74
969
|
const line = document.createElement('div');
|
|
@@ -80,55 +975,315 @@ function mountQuery(cell) {
|
|
|
80
975
|
|
|
81
976
|
const finish = () => {
|
|
82
977
|
query = null;
|
|
978
|
+
running = false;
|
|
979
|
+
bus.stepping.delete(cell);
|
|
980
|
+
// Run must come back, whatever ended the query. Leaving it disabled after a
|
|
981
|
+
// Stop turns a rescued page into a dead one, which is worse than the freeze
|
|
982
|
+
// this whole mechanism exists to prevent.
|
|
983
|
+
runBtn.disabled = false;
|
|
83
984
|
nextBtn.disabled = true;
|
|
84
985
|
allBtn.disabled = true;
|
|
986
|
+
if (stopBtn) stopBtn.disabled = true;
|
|
987
|
+
refresh();
|
|
85
988
|
};
|
|
86
989
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
990
|
+
/** While a goal is in flight the only useful button is Stop. */
|
|
991
|
+
const setRunning = (state) => {
|
|
992
|
+
running = state;
|
|
993
|
+
runBtn.disabled = state;
|
|
994
|
+
nextBtn.disabled = state || !query;
|
|
995
|
+
allBtn.disabled = state || !query;
|
|
996
|
+
if (stopBtn) stopBtn.disabled = !state;
|
|
997
|
+
refresh();
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Load every program cell above this query, in document order.
|
|
1002
|
+
*
|
|
1003
|
+
* Consult order cannot affect correctness — Prolog has no load-time name
|
|
1004
|
+
* binding, so `q(X) :- p(X)` merely mentions p/1 and the lookup happens when
|
|
1005
|
+
* it is called. So there is no dependency graph to compute, and at ~3.5 ms a
|
|
1006
|
+
* cell there is nothing to gain by computing one.
|
|
1007
|
+
*/
|
|
1008
|
+
const loadPrograms = async (session, cause = 'run') => {
|
|
1009
|
+
for (const program of above) {
|
|
1010
|
+
const r = await program.ensure(session, cause);
|
|
1011
|
+
// Running a query against a chapter that failed to load would answer a
|
|
1012
|
+
// question the reader did not ask.
|
|
1013
|
+
if (!r.ok) return { ok: false, name: program.name, error: r.error };
|
|
96
1014
|
}
|
|
97
|
-
|
|
98
|
-
|
|
1015
|
+
return { ok: true };
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* "Unknown procedure: son_a/1" is true and unhelpful when the cell defining
|
|
1020
|
+
* son_a/1 is two inches further down the page.
|
|
1021
|
+
*/
|
|
1022
|
+
const locate = (message) => {
|
|
1023
|
+
const indicator = unknownProcedure(message);
|
|
1024
|
+
if (!indicator) return null;
|
|
1025
|
+
const cell = below.find((program) => program.defines().has(indicator));
|
|
1026
|
+
return cell
|
|
1027
|
+
? `${indicator} is defined below this query, in cell ${cell.name}. Press Consult there, or move that cell above the query.`
|
|
1028
|
+
: null;
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
const step = async () => {
|
|
1032
|
+
if (!query || running) return false;
|
|
1033
|
+
setRunning(true);
|
|
1034
|
+
try {
|
|
1035
|
+
const r = await query.next();
|
|
1036
|
+
// r.text is rendered by SWI itself, so operators and quoting are right.
|
|
1037
|
+
if (r.solution) {
|
|
1038
|
+
const text = r.text ?? formatSolution(r.solution);
|
|
1039
|
+
produced.push(text);
|
|
1040
|
+
write(`${++count}. ${text}`, 'sol');
|
|
1041
|
+
}
|
|
1042
|
+
if (r.error) {
|
|
1043
|
+
failed = r.error;
|
|
1044
|
+
write(r.error, 'err');
|
|
1045
|
+
const hint = locate(r.error);
|
|
1046
|
+
if (hint) write(hint, 'done');
|
|
1047
|
+
finish();
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
if (r.done) {
|
|
1051
|
+
exhausted = true;
|
|
1052
|
+
write(count === 0 ? 'false.' : 'no more solutions.', 'done');
|
|
1053
|
+
finish();
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
setRunning(false);
|
|
1057
|
+
return true;
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
// An abort rejects whatever was in flight. When the reader asked for that,
|
|
1060
|
+
// it is not an error and saying "aborted" in red suggests something broke.
|
|
1061
|
+
if (!aborting) write(e.message, 'err');
|
|
99
1062
|
finish();
|
|
1063
|
+
return false;
|
|
100
1064
|
}
|
|
101
1065
|
};
|
|
102
1066
|
|
|
103
|
-
|
|
1067
|
+
/**
|
|
1068
|
+
* Run this cell's goal.
|
|
1069
|
+
*
|
|
1070
|
+
* The reader's own Run takes the first solution and waits, which is the whole
|
|
1071
|
+
* point of `; next`. An automatic one takes the sequence to the end — see
|
|
1072
|
+
* drain(), below, for why that is a correctness matter and not a preference.
|
|
1073
|
+
*
|
|
1074
|
+
* @param {{cause?: 'reader'|'auto'}} [options] `auto` when the page started this
|
|
1075
|
+
* rather than the reader — it changes what the output says about itself, and
|
|
1076
|
+
* it marks the consults this run performs so they cannot start it again.
|
|
1077
|
+
*/
|
|
1078
|
+
const run = async ({ cause = 'reader' } = {}) => {
|
|
1079
|
+
// The chapter's saved answers are on screen until this moment. Replacing them
|
|
1080
|
+
// with the reader's own is fine; replacing them SILENTLY is not, so the run is
|
|
1081
|
+
// labelled and the way back is stated (docs/modes.md §3) — and the way back is
|
|
1082
|
+
// now a button on this cell rather than a page reload.
|
|
1083
|
+
const hadSaved = !mine && out.querySelector('.line.from') !== null;
|
|
104
1084
|
out.innerHTML = '';
|
|
1085
|
+
mine = true;
|
|
1086
|
+
// This cell has just stopped showing the chapter's answers, which changes what
|
|
1087
|
+
// the page's control is counting. setHidden only speaks up when the hidden
|
|
1088
|
+
// flag itself moves, so a cell that was already visible would leave the count
|
|
1089
|
+
// reporting on a spoiler that is no longer on screen.
|
|
1090
|
+
bus.emit({ kind: 'answers' });
|
|
1091
|
+
produced = [];
|
|
1092
|
+
failed = null;
|
|
1093
|
+
exhausted = false;
|
|
1094
|
+
setHidden(false);
|
|
105
1095
|
count = 0;
|
|
106
1096
|
const goal = input.value.trim().replace(/\.$/, '');
|
|
107
1097
|
if (!goal) return;
|
|
1098
|
+
const at = clock();
|
|
1099
|
+
// WHOSE ANSWERS THESE ARE is the one claim an output has always made, and an
|
|
1100
|
+
// automatic re-run is the first thing on this page that produces answers
|
|
1101
|
+
// nobody pressed a button for. Saying "your run" over those would be the page
|
|
1102
|
+
// attributing its own work to the reader.
|
|
1103
|
+
const whose = cause === 'auto' ? `re-run automatically · ${at}` : `your run · ${at}`;
|
|
1104
|
+
write(hadSaved
|
|
1105
|
+
? `${whose} · press reset for the chapter’s saved answers`
|
|
1106
|
+
: whose, 'from');
|
|
108
1107
|
write(`?- ${goal}.`, 'echo');
|
|
109
|
-
|
|
1108
|
+
setRunning(true);
|
|
110
1109
|
try {
|
|
111
|
-
if (!booted) write('starting SWI-Prolog (5.9 MB, first time only)…', 'done');
|
|
112
|
-
|
|
1110
|
+
if (!bus.booted) write('starting SWI-Prolog (5.9 MB, first time only)…', 'done');
|
|
1111
|
+
session = await boot(options, bus);
|
|
1112
|
+
const ok = await loadPrograms(session, cause === 'auto' ? 'auto' : 'run');
|
|
1113
|
+
// Recorded whatever happened, and recorded AFTER the consults: these answers
|
|
1114
|
+
// are the reader's either way, and the context is the one the goal actually
|
|
1115
|
+
// ran against rather than the one it was about to.
|
|
1116
|
+
ran = { at, goal, context: context() };
|
|
1117
|
+
engineChanged = false;
|
|
1118
|
+
if (!ok.ok) {
|
|
1119
|
+
failed = `cell ${ok.name} did not load: ${ok.error}`;
|
|
1120
|
+
write(failed, 'err');
|
|
1121
|
+
finish();
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
113
1124
|
query = session.query(goal);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
1125
|
+
bus.stepping.add(cell);
|
|
1126
|
+
// A sequence ends when another one starts — one engine, one open query
|
|
1127
|
+
// (869epzqpc). The reader hears it here, in the cell it happened to, with
|
|
1128
|
+
// the solutions they did take still under it and Run still lit. Silence was
|
|
1129
|
+
// the old behaviour and it was worse than an interruption: the cell looked
|
|
1130
|
+
// fine until they came back to it and got SWI's own words for a stack they
|
|
1131
|
+
// never knew existed.
|
|
1132
|
+
query.onSuperseded = () => {
|
|
1133
|
+
write('sequence closed — another query was run. Press Run to start this one again.', 'done');
|
|
1134
|
+
finish();
|
|
1135
|
+
};
|
|
1136
|
+
setRunning(false);
|
|
1137
|
+
// A re-run the reader did not ask for must not leave the page in a state
|
|
1138
|
+
// they did not ask for either: it takes the whole sequence, exactly as the
|
|
1139
|
+
// saved answers it replaced showed the whole sequence, and finishing is
|
|
1140
|
+
// what closes the query. A cell left mid-sequence would be a frame nobody
|
|
1141
|
+
// will ever step, and the next cell to run would be trapped underneath it.
|
|
1142
|
+
if (cause === 'auto') await drain();
|
|
1143
|
+
else await step();
|
|
117
1144
|
} catch (e) {
|
|
118
1145
|
write(e.message, 'err');
|
|
119
1146
|
finish();
|
|
120
|
-
} finally {
|
|
121
|
-
runBtn.disabled = false;
|
|
122
1147
|
}
|
|
123
|
-
}
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
runBtn.addEventListener('click', () => run());
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Would these answers be different if the page produced them now?
|
|
1154
|
+
*
|
|
1155
|
+
* The whole of what `rerun="auto"` acts on. It is deliberately not "did
|
|
1156
|
+
* something happen" — a Consult of a cell the reader never edited changes
|
|
1157
|
+
* nothing, and re-running four cells to print the same four answers is a page
|
|
1158
|
+
* being busy at the reader.
|
|
1159
|
+
*/
|
|
1160
|
+
const outOfDate = () => {
|
|
1161
|
+
// Their own run: exactly the question the tick already answers out loud.
|
|
1162
|
+
if (ran) return staleReason() !== null;
|
|
1163
|
+
// Never run, and nothing on screen to be wrong: the author asked for this
|
|
1164
|
+
// cell to be live, and an empty box is not what the page would produce.
|
|
1165
|
+
if (published.out === '') return true;
|
|
1166
|
+
// Never run, showing the chapter's answers. Those were produced against the
|
|
1167
|
+
// chapter's own program, so they stop being true when a cell above is not the
|
|
1168
|
+
// one that was published — or when the file already shipped saying so.
|
|
1169
|
+
return savedStale || above.some((p) => p.isEdited());
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* `rerun="auto"`: the answers follow the program, without the reader
|
|
1174
|
+
* re-pressing Run in every cell below the one they just changed (869eddzgq).
|
|
1175
|
+
*
|
|
1176
|
+
* ON CONSULT, NEVER ON EDIT. Consult is the reader saying "this is what I mean
|
|
1177
|
+
* now"; a keystroke is not, and a page that re-ran mid-word would be handing
|
|
1178
|
+
* the engine half a clause and the reader a syntax error for something they
|
|
1179
|
+
* were still typing.
|
|
1180
|
+
*
|
|
1181
|
+
* AND NEVER WORK NOBODY ASKED FOR. Three things are deliberately not triggers:
|
|
1182
|
+
* page load, where a stale saved answer stays MARKED rather than pulling 5.9 MB
|
|
1183
|
+
* down a reader's connection; the consults an abort replays, which restore the
|
|
1184
|
+
* engine to what it already was and mean nothing changed; and this cell's own
|
|
1185
|
+
* re-runs, which is what the cause on the event is for. Auto follows the
|
|
1186
|
+
* reader's edits closely — it does not decide to work on its own.
|
|
1187
|
+
*/
|
|
1188
|
+
if (auto) {
|
|
1189
|
+
bus.on((event) => {
|
|
1190
|
+
if (event.kind !== 'consulted' || event.cause === 'auto') return;
|
|
1191
|
+
// Only the cells this query actually runs against. A consult below it was
|
|
1192
|
+
// never part of these answers, and re-running for it would be a cell
|
|
1193
|
+
// reacting to something it cannot see.
|
|
1194
|
+
if (!above.some((program) => program.name === event.name)) return;
|
|
1195
|
+
// A held cell is forced to manual until its wait ends (format §5). Answering
|
|
1196
|
+
// a question the reader is still being asked is not a refresh, and a chapter
|
|
1197
|
+
// that quizzes the reader and then answers itself is worse than one that
|
|
1198
|
+
// never asked.
|
|
1199
|
+
if (held) return;
|
|
1200
|
+
if (running || query) return;
|
|
1201
|
+
if (!outOfDate()) return;
|
|
1202
|
+
bus.queue(() => {
|
|
1203
|
+
// MID-SEQUENCE ANYWHERE ON THE PAGE, not just in this cell. One engine
|
|
1204
|
+
// allows one open query, so an automatic re-run would close whatever the
|
|
1205
|
+
// reader is walking with `; next` (869epzqpc) — the page interrupting an
|
|
1206
|
+
// enquiry nobody asked it to interrupt. It stays as it is instead, and
|
|
1207
|
+
// the tick says the program has changed since, exactly as a manual cell
|
|
1208
|
+
// would; the next consult after they finish picks it up.
|
|
1209
|
+
//
|
|
1210
|
+
// Checked here rather than where the event arrived, because this is
|
|
1211
|
+
// queued behind the other cells' re-runs and the reader may have started
|
|
1212
|
+
// stepping in between.
|
|
1213
|
+
if (bus.stepping.size || running || query) return undefined;
|
|
1214
|
+
return run({ cause: 'auto' });
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
124
1218
|
|
|
125
1219
|
nextBtn.addEventListener('click', step);
|
|
126
|
-
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Take every solution, and leave no query open behind.
|
|
1223
|
+
*
|
|
1224
|
+
* THE SECOND HALF IS NOT A DETAIL. SWI's query frames are a stack: a query
|
|
1225
|
+
* opened while another is still open must be finished first, and stepping the
|
|
1226
|
+
* outer one afterwards fails with "Attempt to access not innermost query". An
|
|
1227
|
+
* abandoned sequence is not closed by anything — the worker forgets the id, the
|
|
1228
|
+
* frame stays — so the only thing that actually releases it is running it to
|
|
1229
|
+
* `done`. That is what this does, and it is why an automatic re-run uses it.
|
|
1230
|
+
*/
|
|
1231
|
+
const drain = async () => {
|
|
127
1232
|
let guard = 0;
|
|
128
|
-
while (query && guard++ < 500)
|
|
1233
|
+
while (query && guard++ < 500) {
|
|
1234
|
+
if (!(await step())) break;
|
|
1235
|
+
}
|
|
129
1236
|
if (guard >= 500) write('stopped after 500 solutions.', 'done');
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
allBtn.addEventListener('click', drain);
|
|
1240
|
+
|
|
1241
|
+
stopBtn?.addEventListener('click', async () => {
|
|
1242
|
+
if (!session) return;
|
|
1243
|
+
aborting = true;
|
|
1244
|
+
stopBtn.disabled = true;
|
|
1245
|
+
write('stopping…', 'done');
|
|
1246
|
+
// Terminating the worker is the only thing that reaches a goal already
|
|
1247
|
+
// spinning inside WASM. The consults are replayed into the new engine, so the
|
|
1248
|
+
// clause store is back exactly as it was; only assert/retract state is lost,
|
|
1249
|
+
// which format §8 already says needs a restart anyway.
|
|
1250
|
+
await session.abort();
|
|
1251
|
+
write('stopped. the engine was restarted and every cell re-consulted.', 'done');
|
|
1252
|
+
finish();
|
|
1253
|
+
aborting = false;
|
|
1254
|
+
// Announced as a restart, because that is what it was: every other query that
|
|
1255
|
+
// had run is now showing answers from an engine that no longer exists.
|
|
1256
|
+
bus.emit({ kind: 'restarted', at: clock(), cells: [...session.log].length });
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Put the chapter's query and its answers back.
|
|
1261
|
+
*
|
|
1262
|
+
* No CLAUSES change, and none need to: the chapter's saved answers make no
|
|
1263
|
+
* claim about what the engine is holding. They say whose they are, which is the
|
|
1264
|
+
* only claim they have ever made (docs/modes.md §3).
|
|
1265
|
+
*
|
|
1266
|
+
* The one piece of engine state it does give back is this cell's own open
|
|
1267
|
+
* query. A reader pressing reset has said "pretend I never ran this", and a
|
|
1268
|
+
* page that leaves a frame open in their name has agreed with them in words and
|
|
1269
|
+
* disagreed in fact — the same argument that makes a program cell's reset
|
|
1270
|
+
* un-consult.
|
|
1271
|
+
*/
|
|
1272
|
+
resetBtn?.addEventListener('click', () => {
|
|
1273
|
+
query?.close();
|
|
1274
|
+
input.value = published.goal;
|
|
1275
|
+
out.innerHTML = published.out;
|
|
1276
|
+
mine = false;
|
|
1277
|
+
ran = null;
|
|
1278
|
+
engineChanged = false;
|
|
1279
|
+
decorateSaved();
|
|
1280
|
+
// Back in the set the page's control acts on, for the same reason.
|
|
1281
|
+
bus.emit({ kind: 'answers' });
|
|
1282
|
+
finish();
|
|
130
1283
|
});
|
|
131
1284
|
|
|
1285
|
+
input.addEventListener('input', refresh);
|
|
1286
|
+
|
|
132
1287
|
input.addEventListener('keydown', (e) => {
|
|
133
1288
|
if (e.key === 'Enter') runBtn.click();
|
|
134
1289
|
if (e.key === ';') {
|
|
@@ -136,13 +1291,59 @@ function mountQuery(cell) {
|
|
|
136
1291
|
if (!nextBtn.disabled) step();
|
|
137
1292
|
}
|
|
138
1293
|
});
|
|
1294
|
+
|
|
1295
|
+
if (held) {
|
|
1296
|
+
hidden = true;
|
|
1297
|
+
// `change` rather than `input`: it fires when they leave the box, so the
|
|
1298
|
+
// answers do not appear under a reader who is still mid-sentence. An empty
|
|
1299
|
+
// box is not a prediction, so it does not end the wait.
|
|
1300
|
+
prediction?.addEventListener('change', () => {
|
|
1301
|
+
if (held && prediction.value.trim() !== '') setHidden(false);
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
decorateSaved();
|
|
1306
|
+
refresh();
|
|
1307
|
+
|
|
1308
|
+
return {
|
|
1309
|
+
id: cell.dataset.cell,
|
|
1310
|
+
hasSaved: published.out !== '',
|
|
1311
|
+
/**
|
|
1312
|
+
* Is the chapter's own output what this cell is showing right now?
|
|
1313
|
+
*
|
|
1314
|
+
* Not the same question as `hasSaved`, which is about the FILE. The page's
|
|
1315
|
+
* control acts on what is on screen, and after a run the chapter's answers
|
|
1316
|
+
* are behind reset rather than in front of the reader.
|
|
1317
|
+
*/
|
|
1318
|
+
showsChapter: () => !mine && published.out !== '',
|
|
1319
|
+
setHidden,
|
|
1320
|
+
isHidden: () => hidden,
|
|
1321
|
+
isEdited: () => mine || input.value !== published.goal,
|
|
1322
|
+
goal: () => input.value,
|
|
1323
|
+
/**
|
|
1324
|
+
* This cell's answers for an export, in the format's own spelling (§6).
|
|
1325
|
+
*
|
|
1326
|
+
* `undefined` is a fourth answer that only this side has: the chapter's own
|
|
1327
|
+
* answers are on screen, so the file's output and its hash are left exactly
|
|
1328
|
+
* where they are. Everything else is the shared spelling — including the
|
|
1329
|
+
* empty terminator, which is what a reader who took two of six and moved on
|
|
1330
|
+
* has actually produced.
|
|
1331
|
+
*/
|
|
1332
|
+
output: () => {
|
|
1333
|
+
if (!mine) return undefined;
|
|
1334
|
+
// Stopping and finishing look the same from outside — both leave no open
|
|
1335
|
+
// query — so `exhausted` is the only thing that distinguishes them.
|
|
1336
|
+
return solutionSequence({ solutions: produced, exhausted, error: failed });
|
|
1337
|
+
},
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function autosizeNow(ta) {
|
|
1342
|
+
ta.style.height = 'auto';
|
|
1343
|
+
ta.style.height = `${ta.scrollHeight}px`;
|
|
139
1344
|
}
|
|
140
1345
|
|
|
141
1346
|
function autosize(ta) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
ta.style.height = `${ta.scrollHeight}px`;
|
|
145
|
-
};
|
|
146
|
-
ta.addEventListener('input', fit);
|
|
147
|
-
requestAnimationFrame(fit);
|
|
1347
|
+
ta.addEventListener('input', () => autosizeNow(ta));
|
|
1348
|
+
requestAnimationFrame(() => autosizeNow(ta));
|
|
148
1349
|
}
|