@go-go-golems/pbui-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/builder.d.ts +74 -0
- package/builder.js +191 -0
- package/command.d.ts +91 -0
- package/command.js +64 -0
- package/docline.d.ts +5 -0
- package/docline.js +43 -0
- package/engine.d.ts +177 -0
- package/engine.js +790 -0
- package/index.d.ts +10 -0
- package/index.js +10 -0
- package/invocation.d.ts +34 -0
- package/invocation.js +71 -0
- package/package.json +25 -0
- package/ptype.d.ts +40 -0
- package/ptype.js +103 -0
- package/registry.d.ts +40 -0
- package/registry.js +143 -0
- package/transcript-text.d.ts +3 -0
- package/transcript-text.js +31 -0
- package/transcript.d.ts +16 -0
- package/transcript.js +49 -0
- package/types.d.ts +83 -0
- package/types.js +29 -0
package/engine.js
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
/* PbuiEngine: the framework-free interaction engine.
|
|
2
|
+
*
|
|
3
|
+
* Owns the accept-loop state machine (advance / startCommand / supply /
|
|
4
|
+
* abort), gesture routing, menu derivation, coercions, describe, the echo
|
|
5
|
+
* grammar, and the typed-input/command-line paths. React (or anything else)
|
|
6
|
+
* subscribes and renders; nothing here touches the DOM.
|
|
7
|
+
*/
|
|
8
|
+
import { refEquals, toPart, B, S } from "./types.js";
|
|
9
|
+
import { takesPresentationFirst } from "./command.js";
|
|
10
|
+
import { PresentationRegistry } from "./registry.js";
|
|
11
|
+
import { Transcript } from "./transcript.js";
|
|
12
|
+
import { InvocationLog } from "./invocation.js";
|
|
13
|
+
/* --------------------------------- engine --------------------------------- */
|
|
14
|
+
export class PbuiEngine {
|
|
15
|
+
ptypes;
|
|
16
|
+
commands;
|
|
17
|
+
registry = new PresentationRegistry();
|
|
18
|
+
transcript;
|
|
19
|
+
world;
|
|
20
|
+
resolver;
|
|
21
|
+
idleDoc;
|
|
22
|
+
/** every executed command, with lifecycle state (CLIM-JSX-004 §7) */
|
|
23
|
+
invocations = new InvocationLog();
|
|
24
|
+
/** transcript echo line awaiting its invocation (set by startCommand*) */
|
|
25
|
+
pendingEchoLineId;
|
|
26
|
+
/** bound resolver passthrough handed to spec callbacks (ResolveFn).
|
|
27
|
+
* Invocation refs resolve from the engine's own log; everything else
|
|
28
|
+
* delegates to the app resolver. */
|
|
29
|
+
resolveFn = (ref) => {
|
|
30
|
+
if ("id" in ref && ref.kind === "invocation")
|
|
31
|
+
return this.invocations.byId(ref.id);
|
|
32
|
+
return this.resolver.resolve(ref);
|
|
33
|
+
};
|
|
34
|
+
coercions = [];
|
|
35
|
+
listeners = new Set();
|
|
36
|
+
/** ids of registered presentations eligible for the current accept
|
|
37
|
+
* (CLIM-JSX-005 §6.3); null when no accept is active */
|
|
38
|
+
eligibleIds = null;
|
|
39
|
+
state = {
|
|
40
|
+
accept: null,
|
|
41
|
+
hover: null,
|
|
42
|
+
menu: null,
|
|
43
|
+
pointer: { x: 0, y: 0 },
|
|
44
|
+
focus: null,
|
|
45
|
+
};
|
|
46
|
+
constructor(opts) {
|
|
47
|
+
this.ptypes = opts.ptypes;
|
|
48
|
+
this.commands = opts.commands;
|
|
49
|
+
this.world = opts.world;
|
|
50
|
+
this.resolver = opts.resolver;
|
|
51
|
+
this.transcript = new Transcript(opts.transcriptCap ?? 300);
|
|
52
|
+
this.idleDoc =
|
|
53
|
+
opts.idleDoc ??
|
|
54
|
+
"Mouse-L: default action; Mouse-M: Describe; Mouse-R: menu of commands.";
|
|
55
|
+
// presentations registered mid-accept (e.g. transcript prints) join the
|
|
56
|
+
// eligible set incrementally — no full recompute on the hot path
|
|
57
|
+
this.registry.subscribe((ev) => {
|
|
58
|
+
if (!this.eligibleIds)
|
|
59
|
+
return;
|
|
60
|
+
if (ev.kind === "unregister") {
|
|
61
|
+
this.eligibleIds.delete(ev.id);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (this.eligibleUncached(ev.rec))
|
|
65
|
+
this.eligibleIds.add(ev.rec.id);
|
|
66
|
+
else
|
|
67
|
+
this.eligibleIds.delete(ev.rec.id);
|
|
68
|
+
this.registry.notifyPres(ev.rec.id);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/* ------------------------------ subscription ----------------------------- */
|
|
72
|
+
getState() {
|
|
73
|
+
return this.state;
|
|
74
|
+
}
|
|
75
|
+
subscribe(fn) {
|
|
76
|
+
this.listeners.add(fn);
|
|
77
|
+
return () => this.listeners.delete(fn);
|
|
78
|
+
}
|
|
79
|
+
setState(patch) {
|
|
80
|
+
this.state = { ...this.state, ...patch };
|
|
81
|
+
for (const fn of this.listeners)
|
|
82
|
+
fn();
|
|
83
|
+
}
|
|
84
|
+
/** hover transitions notify exactly the affected presentations: the old
|
|
85
|
+
* and new hover targets plus every presentation of the same objects
|
|
86
|
+
* (related-hover outlines). This is the mouse-frequency hot path. */
|
|
87
|
+
setHover(next) {
|
|
88
|
+
const prev = this.state.hover;
|
|
89
|
+
if (prev?.id === next?.id) {
|
|
90
|
+
if (next)
|
|
91
|
+
this.setState({ hover: next });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.setState({ hover: next });
|
|
95
|
+
const affected = new Set();
|
|
96
|
+
for (const p of [prev, next]) {
|
|
97
|
+
if (!p)
|
|
98
|
+
continue;
|
|
99
|
+
affected.add(p.id);
|
|
100
|
+
for (const r of this.registry.byRef(p.ref))
|
|
101
|
+
affected.add(r.id);
|
|
102
|
+
}
|
|
103
|
+
for (const id of affected)
|
|
104
|
+
this.registry.notifyPres(id);
|
|
105
|
+
}
|
|
106
|
+
/* -------------------------------- printing ------------------------------- */
|
|
107
|
+
print(...parts) {
|
|
108
|
+
this.transcript.out(...parts);
|
|
109
|
+
}
|
|
110
|
+
printErr(...parts) {
|
|
111
|
+
this.transcript.err(...parts);
|
|
112
|
+
}
|
|
113
|
+
/** standardized command failure (stale arguments etc.): prints the
|
|
114
|
+
* error line only. api.fail (the wrapper commands receive) additionally
|
|
115
|
+
* marks the current invocation as failed — prefer it inside commands. */
|
|
116
|
+
failInvocation(cmdName, ...reason) {
|
|
117
|
+
this.transcript.err(...reason, ` ${cmdName} aborted.`);
|
|
118
|
+
}
|
|
119
|
+
/* ------------------------------- coercions ------------------------------- */
|
|
120
|
+
defineCoercion(c) {
|
|
121
|
+
this.coercions.push(c);
|
|
122
|
+
}
|
|
123
|
+
/** turn a presentation into the ArgValue a spec wants, or null */
|
|
124
|
+
coerceFor(spec, pres) {
|
|
125
|
+
if (this.ptypes.subtypep(pres.type, spec.type)) {
|
|
126
|
+
return { type: pres.type, ref: pres.ref, label: pres.label };
|
|
127
|
+
}
|
|
128
|
+
for (const c of this.coercions) {
|
|
129
|
+
if (this.ptypes.subtypep(pres.type, c.from) &&
|
|
130
|
+
this.ptypes.subtypep(c.to, spec.type)) {
|
|
131
|
+
return c.coerce(pres);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
/* ------------------------------ eligibility ------------------------------ */
|
|
137
|
+
/** the full predicate — used to (re)build the cache and for ad-hoc
|
|
138
|
+
* (unregistered) records such as test fixtures */
|
|
139
|
+
eligibleUncached(pres) {
|
|
140
|
+
const acc = this.state.accept;
|
|
141
|
+
if (!acc)
|
|
142
|
+
return false;
|
|
143
|
+
const rec = pres;
|
|
144
|
+
const v = this.coerceFor(acc.spec, rec);
|
|
145
|
+
if (!v)
|
|
146
|
+
return false;
|
|
147
|
+
if (acc.spec.where && !acc.spec.where(rec, acc.values, this.world, this.resolveFn))
|
|
148
|
+
return false;
|
|
149
|
+
if (acc.spec.distinct) {
|
|
150
|
+
for (const prev of Object.values(acc.values))
|
|
151
|
+
if (refEquals(prev.ref, v.ref))
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
/** would clicking this presentation supply the currently wanted arg?
|
|
157
|
+
* O(1) for registered presentations via the eligible-set cache. */
|
|
158
|
+
eligible(pres) {
|
|
159
|
+
if (!this.state.accept)
|
|
160
|
+
return false;
|
|
161
|
+
if (pres.id && this.eligibleIds && this.registry.get(pres.id))
|
|
162
|
+
return this.eligibleIds.has(pres.id);
|
|
163
|
+
return this.eligibleUncached(pres);
|
|
164
|
+
}
|
|
165
|
+
/** registered presentations eligible right now (keyboard Tab-cycling) */
|
|
166
|
+
eligibleList() {
|
|
167
|
+
if (!this.eligibleIds)
|
|
168
|
+
return [];
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const id of this.eligibleIds) {
|
|
171
|
+
const r = this.registry.get(id);
|
|
172
|
+
if (r)
|
|
173
|
+
out.push(r);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
recomputeEligible() {
|
|
178
|
+
if (!this.state.accept) {
|
|
179
|
+
this.eligibleIds = null;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const ids = new Set();
|
|
183
|
+
for (const rec of this.registry.all())
|
|
184
|
+
if (this.eligibleUncached(rec))
|
|
185
|
+
ids.add(rec.id);
|
|
186
|
+
this.eligibleIds = ids;
|
|
187
|
+
}
|
|
188
|
+
/** accept transitions recompute the cache and broadcast (D3: accept
|
|
189
|
+
* changes are user-paced; hover changes are the targeted hot path) */
|
|
190
|
+
setAccept(accept) {
|
|
191
|
+
this.setState({ accept });
|
|
192
|
+
this.recomputeEligible();
|
|
193
|
+
this.registry.notifyAllPres();
|
|
194
|
+
}
|
|
195
|
+
/** an input context is active and this presentation does not match */
|
|
196
|
+
inert(pres) {
|
|
197
|
+
return this.state.accept != null && !this.eligible(pres);
|
|
198
|
+
}
|
|
199
|
+
/* ------------------------------ accept loop ------------------------------ */
|
|
200
|
+
startCommand(nameOrCmd, seed) {
|
|
201
|
+
const cmd = typeof nameOrCmd === "string" ? this.commands.get(nameOrCmd) : nameOrCmd;
|
|
202
|
+
if (!cmd) {
|
|
203
|
+
this.transcript.err(`Unknown command: ${String(nameOrCmd)}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.closeMenu();
|
|
207
|
+
// starting a new command aborts any pending context silently
|
|
208
|
+
if (this.state.accept)
|
|
209
|
+
this.abort(true);
|
|
210
|
+
const values = {};
|
|
211
|
+
const echoParts = [B("Command:"), S(" " + cmd.name)];
|
|
212
|
+
const first = cmd.args?.[0];
|
|
213
|
+
if (seed && first && (first.input ?? "presentation") === "presentation") {
|
|
214
|
+
const v = this.coerceFor(first, seed);
|
|
215
|
+
if (v) {
|
|
216
|
+
values[first.name] = v;
|
|
217
|
+
echoParts.push(S(` (${first.name}) `), {
|
|
218
|
+
t: "pres",
|
|
219
|
+
type: v.type,
|
|
220
|
+
ref: v.ref,
|
|
221
|
+
label: v.label,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
this.pendingEchoLineId = this.transcript.echo(...echoParts.map(toPart)).id;
|
|
226
|
+
this.advance(cmd, values);
|
|
227
|
+
}
|
|
228
|
+
/** run with preset values (from the command line's positional args) */
|
|
229
|
+
startCommandWithValues(cmd, values) {
|
|
230
|
+
this.closeMenu();
|
|
231
|
+
if (this.state.accept)
|
|
232
|
+
this.abort(true);
|
|
233
|
+
const parts = [B("Command:"), S(" " + cmd.name)];
|
|
234
|
+
for (const [name, v] of Object.entries(values))
|
|
235
|
+
parts.push(S(` (${name}) ${v.label}`));
|
|
236
|
+
this.pendingEchoLineId = this.transcript.echo(...parts.map(toPart)).id;
|
|
237
|
+
this.advance(cmd, values);
|
|
238
|
+
}
|
|
239
|
+
advance(cmd, values) {
|
|
240
|
+
const specs = cmd.args ?? [];
|
|
241
|
+
const next = specs.find((s) => !(s.name in values));
|
|
242
|
+
if (!next) {
|
|
243
|
+
this.setAccept(null);
|
|
244
|
+
void this.execute(cmd, values);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
this.setAccept({ cmd, values, spec: next });
|
|
248
|
+
if ((next.input ?? "presentation") === "menu")
|
|
249
|
+
this.openChoiceMenu(next, values);
|
|
250
|
+
}
|
|
251
|
+
/** supply the current argument by pointing at a presentation */
|
|
252
|
+
supply(pres) {
|
|
253
|
+
const acc = this.state.accept;
|
|
254
|
+
if (!acc)
|
|
255
|
+
return;
|
|
256
|
+
const v = this.coerceFor(acc.spec, pres);
|
|
257
|
+
if (!v)
|
|
258
|
+
return; // ineligible click — coached via the doc line, not errors
|
|
259
|
+
if (acc.spec.where && !acc.spec.where(pres, acc.values, this.world, this.resolveFn))
|
|
260
|
+
return;
|
|
261
|
+
this.supplyValue(v);
|
|
262
|
+
}
|
|
263
|
+
/** supply the current argument with an already-built value */
|
|
264
|
+
supplyValue(v) {
|
|
265
|
+
const acc = this.state.accept;
|
|
266
|
+
if (!acc)
|
|
267
|
+
return;
|
|
268
|
+
if (acc.spec.distinct) {
|
|
269
|
+
for (const prev of Object.values(acc.values)) {
|
|
270
|
+
if (refEquals(prev.ref, v.ref)) {
|
|
271
|
+
this.transcript.err(`${v.label} was already supplied — pick a distinct ${acc.spec.type.toUpperCase()}.`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (acc.spec.validate) {
|
|
277
|
+
const r = acc.spec.validate(v, acc.values, this.world, this.resolveFn);
|
|
278
|
+
if (r !== true) {
|
|
279
|
+
this.transcript.err(r);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
this.transcript.echo(S(` ${acc.spec.name} (a ${acc.spec.type.toUpperCase()}) ⇒ `), { t: "pres", type: v.type, ref: v.ref, label: v.label });
|
|
284
|
+
if (acc.resolveAdhoc) {
|
|
285
|
+
this.setAccept(null);
|
|
286
|
+
acc.resolveAdhoc(v);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (acc.cmd) {
|
|
290
|
+
const values = { ...acc.values, [acc.spec.name]: v };
|
|
291
|
+
this.advance(acc.cmd, values);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
abort(silent = false) {
|
|
295
|
+
const acc = this.state.accept;
|
|
296
|
+
this.closeMenu();
|
|
297
|
+
if (!acc)
|
|
298
|
+
return;
|
|
299
|
+
this.setAccept(null);
|
|
300
|
+
if (acc.resolveAdhoc)
|
|
301
|
+
acc.resolveAdhoc(null);
|
|
302
|
+
if (!silent)
|
|
303
|
+
this.transcript.echo("[Abort]");
|
|
304
|
+
}
|
|
305
|
+
async execute(cmd, values) {
|
|
306
|
+
const inv = this.invocations.record(cmd.name, values, this.pendingEchoLineId);
|
|
307
|
+
this.pendingEchoLineId = undefined;
|
|
308
|
+
const undoRef = {};
|
|
309
|
+
try {
|
|
310
|
+
await cmd.run(values, this.makeApi(cmd, inv.id, undoRef));
|
|
311
|
+
if (this.invocations.byId(inv.id)?.status === "executing")
|
|
312
|
+
this.invocations.complete(inv.id, undoRef.undo);
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
316
|
+
this.invocations.fail(inv.id, msg);
|
|
317
|
+
this.transcript.err(`Error in ${cmd.name}: ${msg}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
makeApi(cmd, invocationId, undoRef) {
|
|
321
|
+
return {
|
|
322
|
+
print: (...parts) => this.print(...parts),
|
|
323
|
+
printErr: (...parts) => this.printErr(...parts),
|
|
324
|
+
fail: (...parts) => {
|
|
325
|
+
this.failInvocation(cmd?.name ?? "Command", ...parts);
|
|
326
|
+
if (invocationId)
|
|
327
|
+
this.invocations.fail(invocationId, parts.map((p) => (typeof p === "string" ? p : "s" in p ? p.s : p.label)).join(""));
|
|
328
|
+
},
|
|
329
|
+
undoable: (capture) => {
|
|
330
|
+
if (undoRef)
|
|
331
|
+
undoRef.undo = capture();
|
|
332
|
+
},
|
|
333
|
+
snapshotUndo: (store) => {
|
|
334
|
+
if (undoRef) {
|
|
335
|
+
const prev = store.get();
|
|
336
|
+
undoRef.undo = () => store.set(prev);
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
world: this.world,
|
|
340
|
+
resolve: (v) => this.resolveFn(v.ref),
|
|
341
|
+
accept: (spec) => this.acceptAdhoc(spec),
|
|
342
|
+
invoke: (name, preset) => {
|
|
343
|
+
const cmd = this.commands.get(name);
|
|
344
|
+
if (!cmd) {
|
|
345
|
+
this.transcript.err(`Unknown command: ${name}`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
this.advance(cmd, preset ?? {});
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/** promise facade over the FSM (metrics(1)'s prompt(), D6) */
|
|
353
|
+
acceptAdhoc(spec) {
|
|
354
|
+
if (this.state.accept)
|
|
355
|
+
this.abort(true);
|
|
356
|
+
return new Promise((resolve) => {
|
|
357
|
+
this.setAccept({ cmd: null, values: {}, spec, resolveAdhoc: resolve });
|
|
358
|
+
if ((spec.input ?? "presentation") === "menu")
|
|
359
|
+
this.openChoiceMenu(spec, {});
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/* ------------------------------ typed input ------------------------------ */
|
|
363
|
+
/** Enter pressed in the listener input. Routes to the pending argument or
|
|
364
|
+
* to the command line. Returns true if the text was consumed. */
|
|
365
|
+
submitTyped(text) {
|
|
366
|
+
const acc = this.state.accept;
|
|
367
|
+
if (acc) {
|
|
368
|
+
const trimmed = text.trim();
|
|
369
|
+
if (!trimmed) {
|
|
370
|
+
const dflt = acc.spec.default?.(acc.values, this.world, this.resolveFn);
|
|
371
|
+
if (dflt) {
|
|
372
|
+
this.supplyValue(dflt);
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
const pt = this.ptypes.get(acc.spec.type);
|
|
378
|
+
if (!pt?.parse) {
|
|
379
|
+
this.transcript.err(`Type a value? ${acc.spec.type.toUpperCase()} arguments are supplied by pointing.`);
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
const r = pt.parse(trimmed, this.world);
|
|
383
|
+
if (!r.ok) {
|
|
384
|
+
this.transcript.err(r.err);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
this.supplyValue({ type: acc.spec.type, ref: r.ref, label: r.label });
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
return this.submitCommandLine(text);
|
|
391
|
+
}
|
|
392
|
+
/** command line: longest-prefix command match + greedy positional args */
|
|
393
|
+
submitCommandLine(text) {
|
|
394
|
+
const trimmed = text.trim();
|
|
395
|
+
if (!trimmed)
|
|
396
|
+
return false;
|
|
397
|
+
const words = trimmed.replace(/^:/, "").split(/\s+/);
|
|
398
|
+
for (let k = words.length; k >= 1; k--) {
|
|
399
|
+
const name = words.slice(0, k).join(" ");
|
|
400
|
+
const m = this.commands.match(name);
|
|
401
|
+
if (m.kind === "ambiguous" && k === words.length) {
|
|
402
|
+
this.transcript.err(`"${name}" is ambiguous: ${m.names.join(", ")}`);
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
if (m.kind === "found") {
|
|
406
|
+
const rest = words.slice(k);
|
|
407
|
+
const values = {};
|
|
408
|
+
const specs = m.cmd.args ?? [];
|
|
409
|
+
for (let i = 0; i < rest.length && i < specs.length; i++) {
|
|
410
|
+
const spec = specs[i];
|
|
411
|
+
const pt = this.ptypes.get(spec.type);
|
|
412
|
+
if (!pt?.parse)
|
|
413
|
+
break;
|
|
414
|
+
const r = pt.parse(rest[i], this.world);
|
|
415
|
+
if (!r.ok) {
|
|
416
|
+
this.transcript.err(r.err);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
const v = { type: spec.type, ref: r.ref, label: r.label };
|
|
420
|
+
if (spec.distinct) {
|
|
421
|
+
for (const prev of Object.values(values)) {
|
|
422
|
+
if (refEquals(prev.ref, v.ref)) {
|
|
423
|
+
this.transcript.err(`${v.label} was already supplied — pick a distinct ${spec.type.toUpperCase()}.`);
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (spec.validate) {
|
|
429
|
+
const ok = spec.validate(v, values, this.world, this.resolveFn);
|
|
430
|
+
if (ok !== true) {
|
|
431
|
+
this.transcript.err(ok);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
values[spec.name] = v;
|
|
436
|
+
}
|
|
437
|
+
this.startCommandWithValues(m.cmd, values);
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
this.transcript.err(`Unknown command: ${trimmed}`);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
completions(text) {
|
|
445
|
+
return this.commands.completions(text);
|
|
446
|
+
}
|
|
447
|
+
/* -------------------------------- gestures ------------------------------- */
|
|
448
|
+
notePointer(x, y) {
|
|
449
|
+
// no notify: pointer position is read lazily when menus open
|
|
450
|
+
this.state = { ...this.state, pointer: { x, y } };
|
|
451
|
+
}
|
|
452
|
+
gesture(kind, pres, x, y) {
|
|
453
|
+
if (x != null && y != null)
|
|
454
|
+
this.notePointer(x, y);
|
|
455
|
+
switch (kind) {
|
|
456
|
+
case "enter":
|
|
457
|
+
this.setHover(pres);
|
|
458
|
+
break;
|
|
459
|
+
case "leave":
|
|
460
|
+
if (this.state.hover?.id === pres.id)
|
|
461
|
+
this.setHover(null);
|
|
462
|
+
break;
|
|
463
|
+
case "click": {
|
|
464
|
+
if (this.state.accept) {
|
|
465
|
+
if (this.eligible(pres)) {
|
|
466
|
+
this.supply(pres);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// active presentations may run duringAccept-safe commands
|
|
470
|
+
// without aborting the pending context (CLIM-JSX-005 §5.2)
|
|
471
|
+
if (pres.mode === "active") {
|
|
472
|
+
const cmd = this.defaultCommandFor(pres);
|
|
473
|
+
if (cmd?.duringAccept)
|
|
474
|
+
this.executeImmediate(cmd, pres);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// gated: swallow (the doc line explains why)
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
this.defaultAction(pres);
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
case "aux":
|
|
484
|
+
this.describePres(pres);
|
|
485
|
+
break;
|
|
486
|
+
case "context": {
|
|
487
|
+
if (this.state.accept) {
|
|
488
|
+
if (pres.mode === "active") {
|
|
489
|
+
this.openDuringAcceptMenu(pres, x ?? this.state.pointer.x, y ?? this.state.pointer.y);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
this.abort();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
this.openCommandMenu(pres, x ?? this.state.pointer.x, y ?? this.state.pointer.y);
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
backgroundContext(x, y) {
|
|
501
|
+
if (this.state.accept) {
|
|
502
|
+
this.abort();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
this.openGlobalMenu(x, y);
|
|
506
|
+
}
|
|
507
|
+
escape() {
|
|
508
|
+
if (this.state.menu) {
|
|
509
|
+
this.closeMenu();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (this.state.accept)
|
|
513
|
+
this.abort();
|
|
514
|
+
}
|
|
515
|
+
/** left-click outside an input context: per-type default command, else Describe */
|
|
516
|
+
defaultAction(pres) {
|
|
517
|
+
const cmd = this.defaultCommandFor(pres);
|
|
518
|
+
if (cmd) {
|
|
519
|
+
this.startCommand(cmd, pres);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
this.describePres(pres);
|
|
523
|
+
}
|
|
524
|
+
defaultCommandFor(pres) {
|
|
525
|
+
// explicit per-command registration wins, first match (metrics(2):965)
|
|
526
|
+
for (const cmd of this.commands.all()) {
|
|
527
|
+
if (cmd.isDefaultFor?.some((t) => this.ptypes.subtypep(pres.type, t)) &&
|
|
528
|
+
this.commandApplies(cmd, pres))
|
|
529
|
+
return cmd;
|
|
530
|
+
}
|
|
531
|
+
const name = this.ptypes.get(pres.type)?.defaultCommand;
|
|
532
|
+
if (name) {
|
|
533
|
+
const cmd = this.commands.get(name);
|
|
534
|
+
if (cmd && this.commandApplies(cmd, pres))
|
|
535
|
+
return cmd;
|
|
536
|
+
}
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
/** run a seed-complete duringAccept command WITHOUT touching the pending
|
|
540
|
+
* input context; eligibility recomputes afterwards in case the command's
|
|
541
|
+
* effects changed where-clauses */
|
|
542
|
+
executeImmediate(cmd, seed) {
|
|
543
|
+
const values = {};
|
|
544
|
+
const echoParts = [B("Command:"), S(" " + cmd.name)];
|
|
545
|
+
const first = cmd.args?.[0];
|
|
546
|
+
if (first) {
|
|
547
|
+
const v = this.coerceFor(first, seed);
|
|
548
|
+
if (!v)
|
|
549
|
+
return;
|
|
550
|
+
values[first.name] = v;
|
|
551
|
+
echoParts.push(S(` (${first.name}) `), {
|
|
552
|
+
t: "pres",
|
|
553
|
+
type: v.type,
|
|
554
|
+
ref: v.ref,
|
|
555
|
+
label: v.label,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if ((cmd.args ?? []).some((a) => !(a.name in values)))
|
|
559
|
+
return; // not seed-complete
|
|
560
|
+
this.closeMenu();
|
|
561
|
+
this.pendingEchoLineId = this.transcript.echo(...echoParts.map(toPart)).id;
|
|
562
|
+
void this.execute(cmd, values).then(() => {
|
|
563
|
+
if (this.state.accept) {
|
|
564
|
+
this.recomputeEligible();
|
|
565
|
+
this.registry.notifyAllPres();
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
/** reduced menu on active presentations mid-accept: duringAccept commands
|
|
570
|
+
* only (plus the chrome's Abort footer) */
|
|
571
|
+
openDuringAcceptMenu(pres, x, y) {
|
|
572
|
+
const items = this.applicableCommands(pres)
|
|
573
|
+
.filter((c) => c.duringAccept)
|
|
574
|
+
.map((cmd) => ({
|
|
575
|
+
label: cmd.name,
|
|
576
|
+
doc: cmd.doc,
|
|
577
|
+
run: () => this.executeImmediate(cmd, pres),
|
|
578
|
+
}));
|
|
579
|
+
this.setState({
|
|
580
|
+
menu: {
|
|
581
|
+
x,
|
|
582
|
+
y,
|
|
583
|
+
title: `${this.ptypes.latticeLabel(pres.type)} ${pres.label}`,
|
|
584
|
+
items,
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/* -------------------------------- describe ------------------------------- */
|
|
589
|
+
describePres(pres) {
|
|
590
|
+
const obj = this.resolveFn(pres.ref);
|
|
591
|
+
if (obj === undefined && !("value" in pres.ref)) {
|
|
592
|
+
this.transcript.err(`${pres.label} no longer exists — presentation was stale.`);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const value = "value" in pres.ref ? pres.ref.value : obj;
|
|
596
|
+
const pt = this.ptypes.get(pres.type);
|
|
597
|
+
if (pt?.describe) {
|
|
598
|
+
this.transcript.out(...pt.describe(value, this.world).map(toPart));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
this.transcript.out(this.ptypes.print(pres.type, value, pres.label));
|
|
602
|
+
}
|
|
603
|
+
/* --------------------------------- menus --------------------------------- */
|
|
604
|
+
commandApplies(cmd, pres) {
|
|
605
|
+
if (cmd.hidden || cmd.global)
|
|
606
|
+
return false;
|
|
607
|
+
if (!takesPresentationFirst(cmd))
|
|
608
|
+
return false;
|
|
609
|
+
const first = cmd.args[0];
|
|
610
|
+
const v = this.coerceFor(first, pres);
|
|
611
|
+
if (!v)
|
|
612
|
+
return false;
|
|
613
|
+
if (first.where && !first.where(pres, {}, this.world, this.resolveFn))
|
|
614
|
+
return false;
|
|
615
|
+
if (cmd.appliesTo && !cmd.appliesTo(pres, this.world, this.resolveFn))
|
|
616
|
+
return false;
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
applicableCommands(pres) {
|
|
620
|
+
return this.commands.all().filter((c) => this.commandApplies(c, pres));
|
|
621
|
+
}
|
|
622
|
+
openCommandMenu(pres, x, y) {
|
|
623
|
+
const cmds = this.applicableCommands(pres);
|
|
624
|
+
const items = cmds.map((cmd) => ({
|
|
625
|
+
label: cmd.name + ((cmd.args?.length ?? 0) > 1 ? " …" : ""),
|
|
626
|
+
doc: cmd.doc,
|
|
627
|
+
run: () => this.startCommand(cmd, pres),
|
|
628
|
+
}));
|
|
629
|
+
if (!cmds.some((c) => c.name.toLowerCase().startsWith("describe"))) {
|
|
630
|
+
items.push({ label: "Describe", run: () => this.describePres(pres) });
|
|
631
|
+
}
|
|
632
|
+
this.setState({
|
|
633
|
+
menu: { x, y, title: `${this.ptypes.latticeLabel(pres.type)} ${pres.label}`, items },
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
openGlobalMenu(x, y) {
|
|
637
|
+
const items = this.commands
|
|
638
|
+
.all()
|
|
639
|
+
.filter((c) => c.global && !c.hidden)
|
|
640
|
+
.map((cmd) => ({
|
|
641
|
+
label: cmd.name + ((cmd.args?.length ?? 0) > 0 ? " …" : ""),
|
|
642
|
+
doc: cmd.doc,
|
|
643
|
+
run: () => this.startCommand(cmd),
|
|
644
|
+
}));
|
|
645
|
+
this.setState({ menu: { x, y, title: "Global Commands", items } });
|
|
646
|
+
}
|
|
647
|
+
openChoiceMenu(spec, soFar) {
|
|
648
|
+
const choices = spec.options?.(soFar, this.world, this.resolveFn) ?? [];
|
|
649
|
+
const { x, y } = this.state.pointer;
|
|
650
|
+
this.setState({
|
|
651
|
+
menu: {
|
|
652
|
+
x,
|
|
653
|
+
y,
|
|
654
|
+
title: spec.prompt ?? `Choose ${spec.name}`,
|
|
655
|
+
items: choices.map((ch) => ({
|
|
656
|
+
label: ch.label,
|
|
657
|
+
run: () => this.supplyValue({ type: spec.type, ref: ch.ref, label: ch.label }),
|
|
658
|
+
})),
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
closeMenu() {
|
|
663
|
+
if (this.state.menu)
|
|
664
|
+
this.setState({ menu: null });
|
|
665
|
+
}
|
|
666
|
+
/* ------------------------------ keyboard focus ---------------------------- */
|
|
667
|
+
/** set the keyboard focus cursor; targeted notification like hover */
|
|
668
|
+
setFocus(id) {
|
|
669
|
+
const prev = this.state.focus;
|
|
670
|
+
if (prev === id)
|
|
671
|
+
return;
|
|
672
|
+
this.setState({ focus: id });
|
|
673
|
+
for (const p of [prev, id])
|
|
674
|
+
if (p)
|
|
675
|
+
this.registry.notifyPres(p);
|
|
676
|
+
}
|
|
677
|
+
/** the presentation that should carry tabIndex=0: the focus cursor if it
|
|
678
|
+
* still exists, else the first registered presentation */
|
|
679
|
+
focusTarget() {
|
|
680
|
+
const f = this.state.focus;
|
|
681
|
+
if (f && this.registry.get(f))
|
|
682
|
+
return f;
|
|
683
|
+
const first = this.registry.all()[0];
|
|
684
|
+
return first ? first.id : null;
|
|
685
|
+
}
|
|
686
|
+
/** the focused presentation record, for the doc line */
|
|
687
|
+
focusRecord() {
|
|
688
|
+
const f = this.state.focus;
|
|
689
|
+
return f ? (this.registry.get(f) ?? null) : null;
|
|
690
|
+
}
|
|
691
|
+
/** move the focus cursor through registry order (arrow keys) */
|
|
692
|
+
moveFocus(dir) {
|
|
693
|
+
const all = this.registry.all();
|
|
694
|
+
if (!all.length)
|
|
695
|
+
return null;
|
|
696
|
+
const cur = this.state.focus;
|
|
697
|
+
const idx = cur ? all.findIndex((r) => r.id === cur) : -1;
|
|
698
|
+
const next = all[(idx + dir + all.length) % all.length];
|
|
699
|
+
this.setFocus(next.id);
|
|
700
|
+
return next.id;
|
|
701
|
+
}
|
|
702
|
+
/** Tab during an accept: cycle the eligible presentations */
|
|
703
|
+
moveFocusEligible(dir) {
|
|
704
|
+
const els = this.eligibleList();
|
|
705
|
+
if (!els.length)
|
|
706
|
+
return null;
|
|
707
|
+
const cur = this.state.focus;
|
|
708
|
+
const idx = cur ? els.findIndex((r) => r.id === cur) : -1;
|
|
709
|
+
const next = els[(idx + dir + els.length) % els.length];
|
|
710
|
+
this.setFocus(next.id);
|
|
711
|
+
return next.id;
|
|
712
|
+
}
|
|
713
|
+
/* ---------------------------------- undo ---------------------------------- */
|
|
714
|
+
/** Undo the most recent undoable invocation. Linear-only (D4): passing an
|
|
715
|
+
* id that is not the last undoable is refused with a coaching message. */
|
|
716
|
+
async undoInvocation(id) {
|
|
717
|
+
const last = this.invocations.lastUndoable();
|
|
718
|
+
if (!last) {
|
|
719
|
+
this.transcript.err("Nothing to undo.");
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
if (id && id !== last.id) {
|
|
723
|
+
const target = this.invocations.byId(id);
|
|
724
|
+
this.transcript.err(`Undo is linear — undo ${last.name} first${target ? ` (requested: ${target.name})` : ""}.`);
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
const undo = last.undo;
|
|
728
|
+
this.invocations.markUndone(last.id);
|
|
729
|
+
await undo();
|
|
730
|
+
this.transcript.echo(B("Undid:"), S(` ${last.name}`));
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
/* ----------------------------- prompt / status ---------------------------- */
|
|
734
|
+
promptInfo() {
|
|
735
|
+
const acc = this.state.accept;
|
|
736
|
+
if (!acc)
|
|
737
|
+
return { accepting: false, filled: [], typedInput: false };
|
|
738
|
+
const dflt = acc.spec.default?.(acc.values, this.world, this.resolveFn);
|
|
739
|
+
const pt = this.ptypes.get(acc.spec.type);
|
|
740
|
+
return {
|
|
741
|
+
accepting: true,
|
|
742
|
+
cmdName: acc.cmd?.name,
|
|
743
|
+
filled: Object.entries(acc.values).map(([name, v]) => ({ name, label: v.label })),
|
|
744
|
+
spec: acc.spec,
|
|
745
|
+
defaultLabel: dflt?.label,
|
|
746
|
+
typedInput: (acc.spec.input ?? "presentation") === "typed" || !!pt?.parse,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/* ------------------------------ undo commands ------------------------------ */
|
|
751
|
+
/** Register the builtin Undo command, the "invocation" ptype, and the
|
|
752
|
+
* per-invocation Undo command (used by transcript/activity menus). */
|
|
753
|
+
export function installUndoCommands(engine) {
|
|
754
|
+
engine.commands.define({
|
|
755
|
+
name: "Undo",
|
|
756
|
+
doc: "Undo the most recent undoable command.",
|
|
757
|
+
global: true,
|
|
758
|
+
run: async () => {
|
|
759
|
+
await engine.undoInvocation();
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
if (!engine.ptypes.get("invocation")) {
|
|
763
|
+
engine.ptypes.define({
|
|
764
|
+
name: "invocation",
|
|
765
|
+
print: (inv) => `#<INVOCATION ${inv.name} ${inv.status}>`,
|
|
766
|
+
describe: (inv) => [
|
|
767
|
+
{ t: "bold", s: inv.name },
|
|
768
|
+
{
|
|
769
|
+
t: "text",
|
|
770
|
+
s: ` — ${inv.status}${inv.error ? ` (${inv.error})` : ""}${inv.undo ? ", undoable" : ""}; args: ${Object.values(inv.argValues).map((v) => v.label).join(", ") || "none"}.`,
|
|
771
|
+
},
|
|
772
|
+
],
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
engine.commands.define({
|
|
776
|
+
name: "Undo Invocation",
|
|
777
|
+
doc: "Undo this command (must be the most recent undoable one).",
|
|
778
|
+
hidden: false,
|
|
779
|
+
args: [{ name: "invocation", type: "invocation" }],
|
|
780
|
+
appliesTo: (pres, _world, resolve) => {
|
|
781
|
+
const inv = resolve?.(pres.ref);
|
|
782
|
+
return !!inv && inv.status === "completed" && !!inv.undo;
|
|
783
|
+
},
|
|
784
|
+
run: async (args) => {
|
|
785
|
+
const ref = args["invocation"].ref;
|
|
786
|
+
if ("id" in ref)
|
|
787
|
+
await engine.undoInvocation(ref.id);
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
}
|