@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/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @go-go-golems/pbui-core
|
|
2
|
+
|
|
3
|
+
This package is the interaction engine for presentation-based UIs, and it has no dependencies — not even React. Everything that gives the paradigm its semantics lives here: the presentation-type lattice, the presentation registry, command tables, the accept loop, undo, and the transcript. The React packages are thin bindings over this one, which is why the entire engine can be unit-tested in Node and why the same engine drives HTML, SVG, and canvas renderers.
|
|
4
|
+
|
|
5
|
+
The design premise is that a UI framework's render tree records *how to draw*, not *what is meant*. This package maintains the meaning: a queryable store of which domain objects are on screen, under which types, and a state machine that interprets gestures against that store.
|
|
6
|
+
|
|
7
|
+
## The five ideas
|
|
8
|
+
|
|
9
|
+
1. A **ptype** (`PTypes.define`) is a named node in a subtype lattice carrying a printer, a keyboard parser, a describer, and optionally a default command. Printer and parser form a round-trip codec: what the system prints, the user can type back.
|
|
10
|
+
2. An **ObjectRef** (`{kind, id}` or `valueRef(x)`) is how presentations refer to domain objects without holding them. Your `Resolver` turns refs back into objects; `undefined` means the object is gone, and the engine handles that centrally.
|
|
11
|
+
3. The **PresentationRegistry** is the presentation database: every on-screen presentation registers a record, and the registry answers "which presentations of object X exist right now" (`byRef`), "which presentations of type T" (`byType`), and "what is at this point" (`at`). It is also the render-invalidation channel (`subscribePres`).
|
|
12
|
+
4. A **command** declares typed arguments. Executing one with unfilled arguments starts an **input context**: eligible presentations highlight, everything else gates, and clicking, typing, or menu-choosing supplies the value. The `commandBuilder` gives this a fully typed authoring surface — `run` receives resolved domain objects, never refs.
|
|
13
|
+
5. **Output records** are transcript lines built from typed parts; a `pres` part stays a live presentation forever, so printed objects can answer later commands' questions.
|
|
14
|
+
|
|
15
|
+
## Minimal use (no React)
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import {
|
|
19
|
+
PTypes, defineBuiltinPtypes, CommandTable, commandBuilder, arg,
|
|
20
|
+
PbuiEngine, installUndoCommands, renderTranscript, type Resolver,
|
|
21
|
+
} from "@go-go-golems/pbui-core";
|
|
22
|
+
|
|
23
|
+
const ptypes = new PTypes<World>();
|
|
24
|
+
defineBuiltinPtypes(ptypes); // "number", "string"
|
|
25
|
+
ptypes.define<Ship>({
|
|
26
|
+
name: "ship",
|
|
27
|
+
print: (s) => `#<SHIP ${s.name}>`,
|
|
28
|
+
parse: (text, w) => /* name prefix -> {ok, value, ref, label} */,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const commands = new CommandTable<World>();
|
|
32
|
+
const c = commandBuilder(commands);
|
|
33
|
+
c.define({
|
|
34
|
+
name: "Refuel Ship",
|
|
35
|
+
args: { ship: arg.presentation<Ship>("ship") },
|
|
36
|
+
run: ({ ship }, api) => {
|
|
37
|
+
api.snapshotUndo(world.store); // one-line undo opt-in
|
|
38
|
+
api.print(`Refuelled ${ship.name}.`); // ship: Ship, resolved
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const engine = new PbuiEngine({ ptypes, commands, world, resolver });
|
|
43
|
+
installUndoCommands(engine);
|
|
44
|
+
|
|
45
|
+
engine.startCommand("Refuel Ship", somePresentationRecord);
|
|
46
|
+
console.log(renderTranscript(engine.transcript.lines()));
|
|
47
|
+
// [echo] **Command:** Refuel Ship (ship) AURORA
|
|
48
|
+
// [out ] Refuelled AURORA.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The engine is driven entirely through methods (`gesture`, `startCommand`, `submitTyped`, `escape`, `undoInvocation`) and observed through subscriptions (`subscribe`, `registry.subscribePres`, `transcript.subscribe`, `invocations.subscribe`) — which is exactly how the tests exercise it and how `@go-go-golems/pbui-react` binds it.
|
|
52
|
+
|
|
53
|
+
## Key exports
|
|
54
|
+
|
|
55
|
+
| Export | Role |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `PTypes`, `defineBuiltinPtypes` | type lattice + print/parse codecs |
|
|
58
|
+
| `PresentationRegistry` | the presentation database + invalidation channel |
|
|
59
|
+
| `CommandTable`, `commandBuilder`, `arg` | commands as data; typed authoring |
|
|
60
|
+
| `PbuiEngine` | gestures, accept loop, menus, coercions, focus |
|
|
61
|
+
| `InvocationLog`, `installUndoCommands` | command history, linear undo |
|
|
62
|
+
| `Transcript`, `S`/`B`/`E`/`P`, `renderTranscript` | output records + the golden-tested text form |
|
|
63
|
+
| `pointerDoc`, `modeLabel` | pure derivations for the doc/status lines |
|
|
64
|
+
|
|
65
|
+
## Contracts worth knowing before you build
|
|
66
|
+
|
|
67
|
+
- Printers never receive `undefined`; the doc line and describe resolve first and fall back to a generic `#<TYPE label>` form.
|
|
68
|
+
- The echo grammar (`renderTranscript` output) is pinned by golden tests. Treat it as a specification.
|
|
69
|
+
- `duringAccept` commands must be seed-complete (at most one presentation argument); `CommandTable.define` throws otherwise.
|
|
70
|
+
- Undo is linear-only; `snapshotUndo` restores the *whole* store, including unrelated concurrent mutations — prefer `api.undoable` with an explicit inverse in live-ticking worlds.
|
|
71
|
+
- `where`/`validate` predicates run eagerly for every candidate presentation on each accept transition. Keep them cheap and pure.
|
|
72
|
+
|
|
73
|
+
Deeper material: `docs/getting-started.md`, `docs/user-guide.md`, `docs/api-reference.md` at the repository root.
|
package/builder.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CommandApi, CommandSpec } from "./command.js";
|
|
2
|
+
import { CommandTable } from "./command.js";
|
|
3
|
+
type SoFar<A> = Partial<ResolvedArgs<A>>;
|
|
4
|
+
export interface PresOpts<T, A, W> {
|
|
5
|
+
prompt?: string;
|
|
6
|
+
distinct?: boolean;
|
|
7
|
+
/** receive T | undefined instead of aborting on stale entities */
|
|
8
|
+
allowStale?: boolean;
|
|
9
|
+
where?: (candidate: T, soFar: SoFar<A>, world: W) => boolean;
|
|
10
|
+
validate?: (value: T, soFar: SoFar<A>, world: W) => true | string;
|
|
11
|
+
}
|
|
12
|
+
export interface TextOpts<A, W> {
|
|
13
|
+
prompt?: string;
|
|
14
|
+
default?: string | ((soFar: SoFar<A>, world: W) => string);
|
|
15
|
+
validate?: (value: string, soFar: SoFar<A>, world: W) => true | string;
|
|
16
|
+
}
|
|
17
|
+
export interface NumOpts<A, W> {
|
|
18
|
+
prompt?: string;
|
|
19
|
+
default?: number | ((soFar: SoFar<A>, world: W) => number);
|
|
20
|
+
min?: number;
|
|
21
|
+
max?: number;
|
|
22
|
+
integer?: boolean;
|
|
23
|
+
validate?: (value: number, soFar: SoFar<A>, world: W) => true | string;
|
|
24
|
+
}
|
|
25
|
+
export interface ChoiceOpts<T extends string, A, W> {
|
|
26
|
+
prompt?: string;
|
|
27
|
+
options: (soFar: SoFar<A>, world: W) => {
|
|
28
|
+
label: string;
|
|
29
|
+
value: T;
|
|
30
|
+
}[];
|
|
31
|
+
}
|
|
32
|
+
export interface ArgDesc<T = unknown> {
|
|
33
|
+
readonly ptype: string;
|
|
34
|
+
readonly kind: "presentation" | "text" | "number" | "choice";
|
|
35
|
+
/** builder-level callbacks, wrapped during compilation */
|
|
36
|
+
readonly opts: Record<string, unknown>;
|
|
37
|
+
/** phantom carrier for the resolved value type */
|
|
38
|
+
readonly __t?: T;
|
|
39
|
+
}
|
|
40
|
+
export declare const arg: {
|
|
41
|
+
/** an object supplied by pointing (or via the ptype's parse) */
|
|
42
|
+
presentation<T>(ptype: string, opts?: PresOpts<T, any, any>): ArgDesc<T>;
|
|
43
|
+
/** typed text -> string */
|
|
44
|
+
text(opts?: TextOpts<any, any>): ArgDesc<string>;
|
|
45
|
+
/** typed text -> number, with range/integer sugar */
|
|
46
|
+
number(opts?: NumOpts<any, any>): ArgDesc<number>;
|
|
47
|
+
/** menu choice over a closed string set */
|
|
48
|
+
choice<T extends string>(opts: ChoiceOpts<T, any, any>): ArgDesc<T>;
|
|
49
|
+
};
|
|
50
|
+
export type ResolvedArgs<A> = {
|
|
51
|
+
[K in keyof A]: A[K] extends ArgDesc<infer T> ? T : never;
|
|
52
|
+
};
|
|
53
|
+
export interface BuiltCommand<A extends Record<string, ArgDesc<any>>, W> {
|
|
54
|
+
name: string;
|
|
55
|
+
doc?: string;
|
|
56
|
+
/** insertion order of keys = accept order; key = display name */
|
|
57
|
+
args?: A;
|
|
58
|
+
/** applicability, with the first argument already resolved */
|
|
59
|
+
appliesTo?: (first: ResolvedArgs<A>[keyof A], world: W) => boolean;
|
|
60
|
+
isDefaultFor?: string[];
|
|
61
|
+
global?: boolean;
|
|
62
|
+
hidden?: boolean;
|
|
63
|
+
/** see CommandSpec.duringAccept (seed-complete rule applies) */
|
|
64
|
+
duringAccept?: boolean;
|
|
65
|
+
run: (args: ResolvedArgs<A>, api: CommandApi<W>) => void | Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
export declare class CommandBuilder<W> {
|
|
68
|
+
private table;
|
|
69
|
+
constructor(table: CommandTable<W>);
|
|
70
|
+
define<A extends Record<string, ArgDesc<any>>>(built: BuiltCommand<A, W>): CommandSpec<W>;
|
|
71
|
+
defineAll<A extends Record<string, ArgDesc<any>>>(builts: BuiltCommand<A, W>[]): void;
|
|
72
|
+
}
|
|
73
|
+
export declare function commandBuilder<W>(table: CommandTable<W>): CommandBuilder<W>;
|
|
74
|
+
export {};
|
package/builder.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/* The typed command builder (CLIM-JSX-004 §5).
|
|
2
|
+
*
|
|
3
|
+
* Argument descriptors carry the TypeScript type of the value a command
|
|
4
|
+
* body will receive; the builder compiles them into the existing
|
|
5
|
+
* CommandSpec/ArgSpec runtime (command.ts) and wraps `run` with
|
|
6
|
+
* resolve-then-run: entity refs are resolved through the engine's
|
|
7
|
+
* Resolver, value refs are unwrapped, and any stale entity aborts the
|
|
8
|
+
* command centrally with the standardized message — command bodies never
|
|
9
|
+
* see an ObjectRef again.
|
|
10
|
+
*
|
|
11
|
+
* Design decisions D1 (compile-to-v1) and D2 (resolve-then-run) of the
|
|
12
|
+
* CLIM-JSX-004 design doc.
|
|
13
|
+
*/
|
|
14
|
+
import { valueRef } from "./types.js";
|
|
15
|
+
/* the arg namespace — deliberately monomorphic for readable type errors */
|
|
16
|
+
export const arg = {
|
|
17
|
+
/** an object supplied by pointing (or via the ptype's parse) */
|
|
18
|
+
presentation(ptype, opts = {}) {
|
|
19
|
+
return { ptype, kind: "presentation", opts: opts };
|
|
20
|
+
},
|
|
21
|
+
/** typed text -> string */
|
|
22
|
+
text(opts = {}) {
|
|
23
|
+
return { ptype: "string", kind: "text", opts: opts };
|
|
24
|
+
},
|
|
25
|
+
/** typed text -> number, with range/integer sugar */
|
|
26
|
+
number(opts = {}) {
|
|
27
|
+
return { ptype: "number", kind: "number", opts: opts };
|
|
28
|
+
},
|
|
29
|
+
/** menu choice over a closed string set */
|
|
30
|
+
choice(opts) {
|
|
31
|
+
return { ptype: "string", kind: "choice", opts: opts };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
/* ------------------------------- resolution -------------------------------- */
|
|
35
|
+
const STALE = (label) => `${label} no longer exists — presentation was stale;`;
|
|
36
|
+
function resolveOne(desc, v, resolve) {
|
|
37
|
+
if ("value" in v.ref) {
|
|
38
|
+
// immediates: unwrap; number descriptors coerce
|
|
39
|
+
const raw = v.ref.value;
|
|
40
|
+
return { ok: true, value: desc.kind === "number" ? Number(raw) : raw };
|
|
41
|
+
}
|
|
42
|
+
const obj = resolve(v.ref);
|
|
43
|
+
if (obj === undefined && !desc.opts["allowStale"])
|
|
44
|
+
return { ok: false, staleLabel: v.label };
|
|
45
|
+
return { ok: true, value: obj };
|
|
46
|
+
}
|
|
47
|
+
function resolveAll(descs, values, resolve) {
|
|
48
|
+
const resolved = {};
|
|
49
|
+
for (const [name, desc] of descs) {
|
|
50
|
+
const v = values[name];
|
|
51
|
+
if (v === undefined)
|
|
52
|
+
continue; // partial (soFar) resolution
|
|
53
|
+
const r = resolveOne(desc, v, resolve);
|
|
54
|
+
if (!r.ok)
|
|
55
|
+
return r;
|
|
56
|
+
resolved[name] = r.value;
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, resolved };
|
|
59
|
+
}
|
|
60
|
+
/* ------------------------------- compilation ------------------------------- */
|
|
61
|
+
function compileArg(name, desc, allDescs) {
|
|
62
|
+
const opts = desc.opts;
|
|
63
|
+
const soFarOf = (values, resolve) => {
|
|
64
|
+
if (!resolve)
|
|
65
|
+
return {};
|
|
66
|
+
const r = resolveAll(allDescs, values, resolve);
|
|
67
|
+
return r.ok ? r.resolved : {};
|
|
68
|
+
};
|
|
69
|
+
const spec = { name, type: desc.ptype };
|
|
70
|
+
if (opts["prompt"])
|
|
71
|
+
spec.prompt = opts["prompt"];
|
|
72
|
+
if (opts["distinct"])
|
|
73
|
+
spec.distinct = true;
|
|
74
|
+
if (desc.kind === "presentation") {
|
|
75
|
+
if (opts["where"]) {
|
|
76
|
+
const where = opts["where"];
|
|
77
|
+
spec.where = (pres, soFar, world, resolve) => {
|
|
78
|
+
if (!resolve)
|
|
79
|
+
return true;
|
|
80
|
+
const r = resolveOne(desc, { type: pres.type, ref: pres.ref, label: pres.label }, resolve);
|
|
81
|
+
if (!r.ok)
|
|
82
|
+
return false;
|
|
83
|
+
return where(r.value, soFarOf(soFar, resolve), world);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (opts["validate"]) {
|
|
87
|
+
const validate = opts["validate"];
|
|
88
|
+
spec.validate = (v, soFar, world, resolve) => {
|
|
89
|
+
if (!resolve)
|
|
90
|
+
return true;
|
|
91
|
+
const r = resolveOne(desc, v, resolve);
|
|
92
|
+
if (!r.ok)
|
|
93
|
+
return `${v.label} no longer exists`;
|
|
94
|
+
return validate(r.value, soFarOf(soFar, resolve), world);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return spec;
|
|
98
|
+
}
|
|
99
|
+
if (desc.kind === "choice") {
|
|
100
|
+
spec.input = "menu";
|
|
101
|
+
const options = opts["options"];
|
|
102
|
+
spec.options = (soFar, world, resolve) => options(soFarOf(soFar, resolve), world).map((c) => ({
|
|
103
|
+
label: c.label,
|
|
104
|
+
ref: valueRef(c.value),
|
|
105
|
+
}));
|
|
106
|
+
return spec;
|
|
107
|
+
}
|
|
108
|
+
// text / number
|
|
109
|
+
spec.input = "typed";
|
|
110
|
+
const dflt = opts["default"];
|
|
111
|
+
if (dflt !== undefined) {
|
|
112
|
+
spec.default = (soFar, world, resolve) => {
|
|
113
|
+
const raw = typeof dflt === "function"
|
|
114
|
+
? dflt(soFarOf(soFar, resolve), world)
|
|
115
|
+
: dflt;
|
|
116
|
+
return { type: desc.ptype, ref: valueRef(raw), label: String(raw) };
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const userValidate = opts["validate"];
|
|
120
|
+
const { min, max, integer } = opts;
|
|
121
|
+
if (userValidate || min !== undefined || max !== undefined || integer) {
|
|
122
|
+
spec.validate = (v, soFar, world, resolve) => {
|
|
123
|
+
const raw = "value" in v.ref ? v.ref.value : undefined;
|
|
124
|
+
if (desc.kind === "number") {
|
|
125
|
+
const n = Number(raw);
|
|
126
|
+
if (integer && !Number.isInteger(n))
|
|
127
|
+
return `${name} must be an integer`;
|
|
128
|
+
if (min !== undefined && n < min)
|
|
129
|
+
return `${name} must be at least ${min}`;
|
|
130
|
+
if (max !== undefined && n > max)
|
|
131
|
+
return `${name} must be at most ${max}`;
|
|
132
|
+
if (userValidate)
|
|
133
|
+
return userValidate(n, soFarOf(soFar, resolve ?? (() => undefined)), world);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (userValidate)
|
|
137
|
+
return userValidate(String(raw), soFarOf(soFar, resolve ?? (() => undefined)), world);
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return spec;
|
|
142
|
+
}
|
|
143
|
+
/* -------------------------------- the builder ------------------------------ */
|
|
144
|
+
export class CommandBuilder {
|
|
145
|
+
table;
|
|
146
|
+
constructor(table) {
|
|
147
|
+
this.table = table;
|
|
148
|
+
}
|
|
149
|
+
define(built) {
|
|
150
|
+
const descs = Object.entries(built.args ?? {});
|
|
151
|
+
const argSpecs = descs.map(([name, d]) => compileArg(name, d, descs));
|
|
152
|
+
const spec = {
|
|
153
|
+
name: built.name,
|
|
154
|
+
doc: built.doc,
|
|
155
|
+
args: argSpecs,
|
|
156
|
+
isDefaultFor: built.isDefaultFor,
|
|
157
|
+
global: built.global,
|
|
158
|
+
hidden: built.hidden,
|
|
159
|
+
duringAccept: built.duringAccept,
|
|
160
|
+
run: async (values, api) => {
|
|
161
|
+
const r = resolveAll(descs, values, (ref) => api.resolve({ type: "", ref, label: "" }));
|
|
162
|
+
if (!r.ok) {
|
|
163
|
+
api.fail(STALE(r.staleLabel));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
await built.run(r.resolved, api);
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
if (built.appliesTo) {
|
|
170
|
+
const appliesTo = built.appliesTo;
|
|
171
|
+
const firstDesc = descs[0]?.[1];
|
|
172
|
+
spec.appliesTo = (pres, world, resolve) => {
|
|
173
|
+
if (!firstDesc || !resolve)
|
|
174
|
+
return true;
|
|
175
|
+
const r = resolveOne(firstDesc, { type: pres.type, ref: pres.ref, label: pres.label }, resolve);
|
|
176
|
+
if (!r.ok)
|
|
177
|
+
return false;
|
|
178
|
+
return appliesTo(r.value, world);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
this.table.define(spec);
|
|
182
|
+
return spec;
|
|
183
|
+
}
|
|
184
|
+
defineAll(builts) {
|
|
185
|
+
for (const b of builts)
|
|
186
|
+
this.define(b);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function commandBuilder(table) {
|
|
190
|
+
return new CommandBuilder(table);
|
|
191
|
+
}
|
package/command.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ArgValue, ArgValues, ObjectRef, PartLike, PresentationRecord } from "./types.js";
|
|
2
|
+
export interface Choice {
|
|
3
|
+
label: string;
|
|
4
|
+
ref: ObjectRef;
|
|
5
|
+
}
|
|
6
|
+
/** engine-supplied ref resolution, passed to spec callbacks so layered
|
|
7
|
+
* authoring APIs (the typed builder) can hand user code live objects */
|
|
8
|
+
export type ResolveFn = (ref: ObjectRef) => unknown | undefined;
|
|
9
|
+
export interface ArgSpec {
|
|
10
|
+
name: string;
|
|
11
|
+
/** ptype name; "number"/"string" use typed input by default */
|
|
12
|
+
type: string;
|
|
13
|
+
/** how the value is acquired. Default: "presentation" (supply by
|
|
14
|
+
* pointing). Typed entry at the prompt works regardless whenever the
|
|
15
|
+
* ptype has a parse function; "typed" additionally focuses the input
|
|
16
|
+
* and skips presentation supply. */
|
|
17
|
+
input?: "presentation" | "typed" | "menu";
|
|
18
|
+
prompt?: string;
|
|
19
|
+
/** dependent choices for menu-valued args (design-kit.jsx:253) */
|
|
20
|
+
options?: (soFar: ArgValues, world: unknown, resolve?: ResolveFn) => Choice[];
|
|
21
|
+
/** CLIM-style default, offered as `[default …]` and taken on empty Enter */
|
|
22
|
+
default?: (soFar: ArgValues, world: unknown, resolve?: ResolveFn) => ArgValue | undefined;
|
|
23
|
+
/** must differ from every previously collected arg (scheduler:296) */
|
|
24
|
+
distinct?: boolean;
|
|
25
|
+
/** extra predicate on candidate presentations (e.g. port direction) */
|
|
26
|
+
where?: (pres: PresentationRecord, soFar: ArgValues, world: unknown, resolve?: ResolveFn) => boolean;
|
|
27
|
+
/** validate a supplied value; return an error string to reject */
|
|
28
|
+
validate?: (v: ArgValue, soFar: ArgValues, world: unknown, resolve?: ResolveFn) => true | string;
|
|
29
|
+
}
|
|
30
|
+
export interface CommandApi<W> {
|
|
31
|
+
print: (...parts: PartLike[]) => void;
|
|
32
|
+
printErr: (...parts: PartLike[]) => void;
|
|
33
|
+
/** standardized command failure ("<reason> <Command> aborted.") */
|
|
34
|
+
fail: (...parts: PartLike[]) => void;
|
|
35
|
+
/** opt this invocation into undo: capture runs now, returns the inverse */
|
|
36
|
+
undoable: (capture: () => () => void | Promise<void>) => void;
|
|
37
|
+
/** snapshot-undo sugar for immutable stores: captures the store state AT
|
|
38
|
+
* THE MOMENT OF THE CALL (put it before mutations, after guard clauses);
|
|
39
|
+
* undo replaces the whole store with that snapshot */
|
|
40
|
+
snapshotUndo: <S>(store: {
|
|
41
|
+
get(): S;
|
|
42
|
+
set(state: S): void;
|
|
43
|
+
}) => void;
|
|
44
|
+
world: W;
|
|
45
|
+
/** resolve a collected ArgValue to the live domain object (undefined = stale) */
|
|
46
|
+
resolve: (v: ArgValue) => unknown | undefined;
|
|
47
|
+
/** mid-body accept (promise facade, D6); null on abort */
|
|
48
|
+
accept: (spec: ArgSpec) => Promise<ArgValue | null>;
|
|
49
|
+
/** invoke another command, optionally with preset args (command chaining) */
|
|
50
|
+
invoke: (name: string, preset?: ArgValues) => void;
|
|
51
|
+
}
|
|
52
|
+
export interface CommandSpec<W = unknown> {
|
|
53
|
+
name: string;
|
|
54
|
+
doc?: string;
|
|
55
|
+
args?: ArgSpec[];
|
|
56
|
+
/** extra applicability beyond first-arg type matching (metrics(2):1043) */
|
|
57
|
+
appliesTo?: (pres: PresentationRecord, world: W, resolve?: ResolveFn) => boolean;
|
|
58
|
+
/** ptypes this command is the left-click default for; first match wins */
|
|
59
|
+
isDefaultFor?: string[];
|
|
60
|
+
/** reachable from the background menu / command line only */
|
|
61
|
+
global?: boolean;
|
|
62
|
+
/** hide from menus entirely (command line only) */
|
|
63
|
+
hidden?: boolean;
|
|
64
|
+
/** may run while an input context is pending, without aborting it —
|
|
65
|
+
* must be seed-complete: at most one argument, supplied by the invoking
|
|
66
|
+
* presentation (CLIM-JSX-005 D2: one input context at a time) */
|
|
67
|
+
duringAccept?: boolean;
|
|
68
|
+
run: (args: ArgValues, api: CommandApi<W>) => void | Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
export declare class CommandTable<W = unknown> {
|
|
71
|
+
private list;
|
|
72
|
+
private byName;
|
|
73
|
+
define(spec: CommandSpec<W>): CommandSpec<W>;
|
|
74
|
+
defineAll(specs: CommandSpec<W>[]): void;
|
|
75
|
+
get(name: string): CommandSpec<W> | undefined;
|
|
76
|
+
all(): CommandSpec<W>[];
|
|
77
|
+
/** case-insensitive exact-then-prefix match; reports ambiguity */
|
|
78
|
+
match(text: string): {
|
|
79
|
+
kind: "found";
|
|
80
|
+
cmd: CommandSpec<W>;
|
|
81
|
+
} | {
|
|
82
|
+
kind: "ambiguous";
|
|
83
|
+
names: string[];
|
|
84
|
+
} | {
|
|
85
|
+
kind: "none";
|
|
86
|
+
};
|
|
87
|
+
completions(text: string): string[];
|
|
88
|
+
}
|
|
89
|
+
export declare function firstArg<W>(cmd: CommandSpec<W>): ArgSpec | undefined;
|
|
90
|
+
/** does this command's first argument accept objects supplied by pointing? */
|
|
91
|
+
export declare function takesPresentationFirst<W>(cmd: CommandSpec<W>): boolean;
|
package/command.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/* Command tables: commands as data (the converged corpus shape, §6.4).
|
|
2
|
+
*
|
|
3
|
+
* Commands declare typed arguments; menus, prompting, the mouse-doc line
|
|
4
|
+
* and the command line all derive from these declarations. Command bodies
|
|
5
|
+
* receive an injected CommandApi and never touch UI state directly
|
|
6
|
+
* (care-examiner's capability-facade rule).
|
|
7
|
+
*/
|
|
8
|
+
export class CommandTable {
|
|
9
|
+
list = [];
|
|
10
|
+
byName = new Map();
|
|
11
|
+
define(spec) {
|
|
12
|
+
if (this.byName.has(spec.name))
|
|
13
|
+
throw new Error(`duplicate command "${spec.name}"`);
|
|
14
|
+
if (spec.duringAccept) {
|
|
15
|
+
const args = spec.args ?? [];
|
|
16
|
+
const seedable = args.length === 0 ||
|
|
17
|
+
(args.length === 1 && (args[0].input ?? "presentation") === "presentation");
|
|
18
|
+
if (!seedable)
|
|
19
|
+
throw new Error(`duringAccept command "${spec.name}" must be seed-complete: at most one presentation argument (decision D2, one input context at a time)`);
|
|
20
|
+
}
|
|
21
|
+
this.list.push(spec);
|
|
22
|
+
this.byName.set(spec.name, spec);
|
|
23
|
+
return spec;
|
|
24
|
+
}
|
|
25
|
+
defineAll(specs) {
|
|
26
|
+
for (const s of specs)
|
|
27
|
+
this.define(s);
|
|
28
|
+
}
|
|
29
|
+
get(name) {
|
|
30
|
+
return this.byName.get(name);
|
|
31
|
+
}
|
|
32
|
+
all() {
|
|
33
|
+
return [...this.list];
|
|
34
|
+
}
|
|
35
|
+
/** case-insensitive exact-then-prefix match; reports ambiguity */
|
|
36
|
+
match(text) {
|
|
37
|
+
const t = text.trim().toLowerCase().replace(/^:/, "");
|
|
38
|
+
if (!t)
|
|
39
|
+
return { kind: "none" };
|
|
40
|
+
const exact = this.list.find((c) => c.name.toLowerCase() === t);
|
|
41
|
+
if (exact)
|
|
42
|
+
return { kind: "found", cmd: exact };
|
|
43
|
+
const hits = this.list.filter((c) => c.name.toLowerCase().startsWith(t));
|
|
44
|
+
if (hits.length === 1)
|
|
45
|
+
return { kind: "found", cmd: hits[0] };
|
|
46
|
+
if (hits.length > 1)
|
|
47
|
+
return { kind: "ambiguous", names: hits.map((c) => c.name) };
|
|
48
|
+
return { kind: "none" };
|
|
49
|
+
}
|
|
50
|
+
completions(text) {
|
|
51
|
+
const t = text.trim().toLowerCase().replace(/^:/, "");
|
|
52
|
+
return this.list
|
|
53
|
+
.filter((c) => c.name.toLowerCase().startsWith(t))
|
|
54
|
+
.map((c) => c.name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function firstArg(cmd) {
|
|
58
|
+
return cmd.args?.[0];
|
|
59
|
+
}
|
|
60
|
+
/** does this command's first argument accept objects supplied by pointing? */
|
|
61
|
+
export function takesPresentationFirst(cmd) {
|
|
62
|
+
const a = firstArg(cmd);
|
|
63
|
+
return !!a && (a.input ?? "presentation") === "presentation";
|
|
64
|
+
}
|
package/docline.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PbuiEngine } from "./engine.js";
|
|
2
|
+
/** the classic Genera idle line, available as a default */
|
|
3
|
+
export declare const GENERA_IDLE_DOC = "To see other commands, press Shift, Control, Meta-Shift, or Super.";
|
|
4
|
+
export declare function pointerDoc(engine: PbuiEngine<any>): string;
|
|
5
|
+
export declare function modeLabel(engine: PbuiEngine<any>): string;
|
package/docline.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* Pure derivations for the mouse-doc line and the status-line mode label
|
|
2
|
+
* (decision D8: pull, never push). */
|
|
3
|
+
/** the classic Genera idle line, available as a default */
|
|
4
|
+
export const GENERA_IDLE_DOC = "To see other commands, press Shift, Control, Meta-Shift, or Super.";
|
|
5
|
+
export function pointerDoc(engine) {
|
|
6
|
+
const { accept, menu } = engine.getState();
|
|
7
|
+
// the keyboard focus cursor documents itself exactly like hover
|
|
8
|
+
const hover = engine.getState().hover ?? engine.focusRecord();
|
|
9
|
+
if (menu)
|
|
10
|
+
return "Choose an item — Mouse-L selects; [Escape] dismisses.";
|
|
11
|
+
if (accept) {
|
|
12
|
+
const wanted = accept.spec.type.toUpperCase();
|
|
13
|
+
if (hover) {
|
|
14
|
+
if (engine.eligible(hover))
|
|
15
|
+
return `⟨${accept.spec.name}⟩${accept.cmd ? " of " + accept.cmd.name : ""} — L: use ${hover.label} Esc: abort`;
|
|
16
|
+
if (hover.mode === "active") {
|
|
17
|
+
const dflt = engine.defaultCommandFor(hover);
|
|
18
|
+
if (dflt?.duringAccept)
|
|
19
|
+
return `${hover.label} — L: ${dflt.name} (the pending ${accept.cmd?.name ?? "accept"} keeps waiting).`;
|
|
20
|
+
}
|
|
21
|
+
return `Accepting a ${wanted} — ${hover.label} is not applicable here. [Escape] aborts.`;
|
|
22
|
+
}
|
|
23
|
+
const pt = engine.ptypes.get(accept.spec.type);
|
|
24
|
+
const kbd = pt?.parse ? " or type it at the prompt" : "";
|
|
25
|
+
return `Accepting a ${wanted} — Mouse-L on a highlighted presentation supplies it${kbd}. [Escape] aborts.`;
|
|
26
|
+
}
|
|
27
|
+
if (hover) {
|
|
28
|
+
const dflt = engine.defaultCommandFor(hover);
|
|
29
|
+
const n = engine.applicableCommands(hover).length;
|
|
30
|
+
const left = dflt ? dflt.name : "Describe";
|
|
31
|
+
const obj = "value" in hover.ref ? hover.ref.value : engine.resolver.resolve(hover.ref);
|
|
32
|
+
return `${engine.ptypes.print(hover.type, obj, hover.label)} — L: ${left}; M: Describe; R: menu of ${n} command${n === 1 ? "" : "s"}.`;
|
|
33
|
+
}
|
|
34
|
+
return engine.idleDoc;
|
|
35
|
+
}
|
|
36
|
+
export function modeLabel(engine) {
|
|
37
|
+
const { accept, menu } = engine.getState();
|
|
38
|
+
if (menu)
|
|
39
|
+
return "Menu Choose";
|
|
40
|
+
if (accept)
|
|
41
|
+
return `Accept ${accept.spec.type.toUpperCase()}`;
|
|
42
|
+
return "User Input";
|
|
43
|
+
}
|