@sigrun/tui 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/actions.d.ts +43 -2
  3. package/dist/api.d.ts +5 -0
  4. package/dist/api.js +5 -1
  5. package/dist/blocks/braille.d.ts +41 -0
  6. package/dist/blocks/braille.js +131 -0
  7. package/dist/blocks/chart.d.ts +20 -0
  8. package/dist/blocks/chart.js +397 -0
  9. package/dist/blocks/collection/calendar.d.ts +28 -0
  10. package/dist/blocks/collection/calendar.js +232 -0
  11. package/dist/blocks/collection/card.d.ts +36 -0
  12. package/dist/blocks/collection/card.js +128 -0
  13. package/dist/blocks/collection/collection.d.ts +9 -0
  14. package/dist/blocks/collection/collection.js +26 -0
  15. package/dist/blocks/collection/grid.d.ts +18 -0
  16. package/dist/blocks/collection/grid.js +72 -0
  17. package/dist/blocks/collection/lanes.d.ts +32 -0
  18. package/dist/blocks/collection/lanes.js +320 -0
  19. package/dist/blocks/collection/list.d.ts +30 -0
  20. package/dist/blocks/collection/list.js +273 -0
  21. package/dist/blocks/collection/move.d.ts +67 -0
  22. package/dist/blocks/collection/move.js +96 -0
  23. package/dist/blocks/collection/shared.d.ts +87 -0
  24. package/dist/blocks/collection/shared.js +177 -0
  25. package/dist/blocks/collection/table.d.ts +26 -0
  26. package/dist/blocks/collection/table.js +221 -0
  27. package/dist/blocks/content.d.ts +48 -0
  28. package/dist/blocks/content.js +253 -0
  29. package/dist/blocks/context.d.ts +96 -0
  30. package/dist/blocks/context.js +65 -0
  31. package/dist/blocks/diff.d.ts +21 -0
  32. package/dist/blocks/diff.js +25 -0
  33. package/dist/blocks/ext-transport.d.ts +70 -0
  34. package/dist/blocks/ext-transport.js +165 -0
  35. package/dist/blocks/form/choice.d.ts +54 -0
  36. package/dist/blocks/form/choice.js +175 -0
  37. package/dist/blocks/form/fields.d.ts +56 -0
  38. package/dist/blocks/form/fields.js +136 -0
  39. package/dist/blocks/form/file.d.ts +61 -0
  40. package/dist/blocks/form/file.js +121 -0
  41. package/dist/blocks/form/form.d.ts +53 -0
  42. package/dist/blocks/form/form.js +703 -0
  43. package/dist/blocks/form/list.d.ts +37 -0
  44. package/dist/blocks/form/list.js +70 -0
  45. package/dist/blocks/form/values.d.ts +20 -0
  46. package/dist/blocks/form/values.js +104 -0
  47. package/dist/blocks/icons.d.ts +14 -0
  48. package/dist/blocks/icons.js +74 -0
  49. package/dist/blocks/image.d.ts +39 -0
  50. package/dist/blocks/image.js +82 -0
  51. package/dist/blocks/layout.d.ts +31 -0
  52. package/dist/blocks/layout.js +155 -0
  53. package/dist/blocks/log.d.ts +24 -0
  54. package/dist/blocks/log.js +111 -0
  55. package/dist/blocks/meter.d.ts +16 -0
  56. package/dist/blocks/meter.js +50 -0
  57. package/dist/blocks/page.d.ts +54 -0
  58. package/dist/blocks/page.js +355 -0
  59. package/dist/blocks/render.d.ts +31 -0
  60. package/dist/blocks/render.js +145 -0
  61. package/dist/blocks/surface-view.d.ts +33 -0
  62. package/dist/blocks/surface-view.js +86 -0
  63. package/dist/blocks/surface.d.ts +75 -0
  64. package/dist/blocks/surface.js +468 -0
  65. package/dist/blocks/tones.d.ts +23 -0
  66. package/dist/blocks/tones.js +63 -0
  67. package/dist/blocks/treeview.d.ts +22 -0
  68. package/dist/blocks/treeview.js +121 -0
  69. package/dist/blocks/width.d.ts +32 -0
  70. package/dist/blocks/width.js +70 -0
  71. package/dist/keymaps/emacs.js +39 -0
  72. package/dist/keymaps/standard.js +45 -1
  73. package/dist/keymaps/vim.js +37 -0
  74. package/dist/launch.js +13 -0
  75. package/dist/sse.d.ts +8 -0
  76. package/dist/sse.js +27 -0
  77. package/dist/theme.d.ts +2 -2
  78. package/dist/theme.js +16 -2
  79. package/dist/ui/app.d.ts +36 -0
  80. package/dist/ui/app.js +84 -0
  81. package/dist/ui/interactions.js +2 -7
  82. package/dist/ui/sidebar.d.ts +25 -2
  83. package/dist/ui/sidebar.js +64 -0
  84. package/package.json +3 -3
@@ -0,0 +1,703 @@
1
+ /**
2
+ * The `form` block (UI-blocks U2 Task 5, spec §5.6/§6.2/§9.1 — the TUI
3
+ * port of U1's `Form.tsx`): modes (submit / live / inline), client
4
+ * validation before dispatch, the live-mode 300 ms debounce with the
5
+ * `/view/` view-only branch, and the interaction stepper — several
6
+ * sections each holding a `choice` in an interaction placement present
7
+ * one question at a time with a review step, submitting ONCE with every
8
+ * field's value.
9
+ *
10
+ * TUI editing model (the app owns keymaps; the form owns text editing):
11
+ * Up/Down move between fields (the stepper moves BETWEEN sections);
12
+ * the focused field edits inline — characters insert, Backspace
13
+ * deletes, Enter submits (text/number/date/secret), toggles (toggle)
14
+ * or activates an option (choice); Tab moves to the next field.
15
+ * `syntax: "code"` longtext opens the host's $EDITOR seam with `e`.
16
+ * Editing is per-mode: `submit` edits locally until Enter on the button
17
+ * row; `live` dispatches every change after 300 ms (a `/view/`-bound
18
+ * live form writes through ctx.setView and never dispatches); `inline`
19
+ * renders one row (field plus the submit label) and clears on success.
20
+ *
21
+ * The focused field survives a data update (Review Focus 4): values
22
+ * and focus live in the component, not the render; a re-render after a
23
+ * server data change keeps both.
24
+ */
25
+ import { resolvePointer } from "@sigrun/shared/blocks";
26
+ import { bindText } from "../context.js";
27
+ import { truncate, widthClassOf } from "../width.js";
28
+ import { ChoiceState, choiceActivate, choiceLines, choiceRowCount, choiceSetOther, choiceValueText, clampChoiceCursor, otherText, } from "./choice.js";
29
+ import { EditBuffer, fieldLabel, fieldRowLines, valueText } from "./fields.js";
30
+ import { FileFieldState, fileStatusLine, maskText, pickFile, secretStatusLine, } from "./file.js";
31
+ import { listAdd, listCanAdd, listCanRemove, listEntries, listLines, listMove, listRemove, listSet, ListFieldState } from "./list.js";
32
+ import { collectFields, initialValue, validateField } from "./values.js";
33
+ /** The live-mode debounce (spec §5.6: "every change is applied after
34
+ * 300 ms"). */
35
+ export const LIVE_DEBOUNCE_MS = 300;
36
+ /** The choice value's display for the stepper review (ids label back). */
37
+ function reviewText(field, ctx, value) {
38
+ if (field.type === "choice")
39
+ return choiceValueText(field, ctx, value);
40
+ if (typeof value === "string")
41
+ return value;
42
+ if (typeof value === "number" || typeof value === "boolean")
43
+ return String(value);
44
+ if (value === null || value === undefined)
45
+ return "";
46
+ return JSON.stringify(value);
47
+ }
48
+ /** The stepper: does this form qualify (several sections each holding
49
+ * a choice, interaction placement)? (§9.1) */
50
+ export function stepperSections(block, interaction) {
51
+ if (!interaction)
52
+ return null;
53
+ const sections = block.children.filter((child) => typeof child === "object" &&
54
+ child !== null &&
55
+ child.type === "section");
56
+ if (sections.length < 2)
57
+ return null;
58
+ const allChoice = sections.every((section) => section.children.some((child) => typeof child === "object" &&
59
+ child !== null &&
60
+ child.type === "choice"));
61
+ return allChoice ? sections : null;
62
+ }
63
+ /** The answerable fields of a stepper section (every field the review
64
+ * summarizes — §9.1's review step covers ALL of a section's fields,
65
+ * not only the choice). */
66
+ function sectionFields(section) {
67
+ return section.children.filter((child) => typeof child === "object" &&
68
+ child !== null &&
69
+ typeof child.name === "string" &&
70
+ typeof child.type === "string");
71
+ }
72
+ /** The form component. */
73
+ export class FormComponent {
74
+ #block;
75
+ #ctx;
76
+ #interaction;
77
+ /** The flattened fields (sections nest; editing order is depth-first). */
78
+ #fields;
79
+ #values;
80
+ #errors = {};
81
+ /** The focused field index (the submit button row sits after the
82
+ * last field for submit-mode forms). */
83
+ #focus = 0;
84
+ #step = 0;
85
+ #text = new Map();
86
+ #choice = new Map();
87
+ #list = new Map();
88
+ #file = new Map();
89
+ #debounce = null;
90
+ /** The stepper's submitted flag (the review step locks after submit). */
91
+ #submitted = false;
92
+ constructor(block, ctx, opts = {}) {
93
+ this.#block = block;
94
+ this.#ctx = ctx;
95
+ this.#interaction = opts.interaction === true;
96
+ this.#fields = collectFields(block);
97
+ const boundObject = this.#boundObject();
98
+ const init = {};
99
+ for (const field of this.#fields)
100
+ init[field.name] = initialValue(field, boundObject);
101
+ this.#values = init;
102
+ }
103
+ /** The object the fields read (the `/view/` branch for a view-bound
104
+ * form, the surface data otherwise — §6.2). */
105
+ #boundObject() {
106
+ const bind = this.#block.bind;
107
+ if (bind.startsWith("/view/")) {
108
+ const pointer = bind.replace(/^\/view\/?/, "/");
109
+ const resolved = resolvePointer(this.#ctx.view.values, pointer);
110
+ if (typeof resolved === "object" && resolved !== null && !Array.isArray(resolved)) {
111
+ return resolved;
112
+ }
113
+ return {};
114
+ }
115
+ const resolved = resolvePointer(this.#ctx.data, bind);
116
+ if (typeof resolved === "object" && resolved !== null && !Array.isArray(resolved)) {
117
+ return resolved;
118
+ }
119
+ return {};
120
+ }
121
+ #textState(field) {
122
+ let state = this.#text.get(field.name);
123
+ if (state === undefined) {
124
+ state = { buffer: new EditBuffer(valueText(this.#values[field.name])) };
125
+ this.#text.set(field.name, state);
126
+ }
127
+ return state;
128
+ }
129
+ #choiceState(field) {
130
+ let state = this.#choice.get(field.name);
131
+ if (state === undefined) {
132
+ state = new ChoiceState();
133
+ const other = otherText(this.#values[field.name] ?? "");
134
+ state.other = new EditBuffer(other);
135
+ this.#choice.set(field.name, state);
136
+ }
137
+ return state;
138
+ }
139
+ #listState(field) {
140
+ let state = this.#list.get(field.name);
141
+ if (state === undefined) {
142
+ state = new ListFieldState();
143
+ this.#list.set(field.name, state);
144
+ }
145
+ return state;
146
+ }
147
+ #fileState(field) {
148
+ let state = this.#file.get(field.name);
149
+ if (state === undefined) {
150
+ state = new FileFieldState();
151
+ this.#file.set(field.name, state);
152
+ }
153
+ return state;
154
+ }
155
+ /** Validate the form; returns true when every field passes. */
156
+ #validate() {
157
+ const next = {};
158
+ for (const field of this.#fields) {
159
+ const message = validateField(field, this.#values[field.name], fieldLabel(field, this.#ctx));
160
+ if (message !== null)
161
+ next[field.name] = message;
162
+ }
163
+ this.#errors = next;
164
+ return Object.keys(next).length === 0;
165
+ }
166
+ /** The error under a field (a client error wins; else the server
167
+ * field error, §5.6). */
168
+ #errorOf(field) {
169
+ // A file field's pick failure (accept/maxBytes/read) renders under
170
+ // the field too (§5.6) — it lives in the field state, not the
171
+ // submit validation map.
172
+ return (this.#errors[field.name] ??
173
+ this.#file.get(field.name)?.error ??
174
+ this.#ctx.serverErrors?.[field.name]);
175
+ }
176
+ /** Change a field's value; live mode debounces the dispatch. */
177
+ #change(name, value) {
178
+ this.#values = { ...this.#values, [name]: value };
179
+ // A local edit clears the field's errors (the next validate or
180
+ // server answer re-populates).
181
+ delete this.#errors[name];
182
+ if (this.#block.mode !== "live")
183
+ return;
184
+ if (this.#debounce !== null)
185
+ clearTimeout(this.#debounce);
186
+ this.#debounce = setTimeout(() => {
187
+ this.#debounce = null;
188
+ const flushed = this.#values;
189
+ if (this.#block.bind.startsWith("/view/")) {
190
+ // §6.2: view-only — the channel owns /view/. Write the branch
191
+ // so sibling blocks reading /view/... update; never dispatch.
192
+ const viewPath = this.#block.bind.replace(/^\/view\/?/, "");
193
+ this.#ctx.setView?.(viewPath, { ...flushed });
194
+ return;
195
+ }
196
+ this.#ctx.dispatch({
197
+ type: "edit",
198
+ name: this.#block.onSubmit ?? "edit",
199
+ ...(this.#block.turn === undefined ? {} : { turn: this.#block.turn }),
200
+ }, { values: { ...flushed } });
201
+ }, LIVE_DEBOUNCE_MS);
202
+ }
203
+ /** Submit (validate first; a failed submit dispatches nothing). */
204
+ #submit() {
205
+ if (!this.#validate())
206
+ return false;
207
+ this.#ctx.dispatch({
208
+ type: "submit",
209
+ name: this.#block.onSubmit ?? "submit",
210
+ ...(this.#block.turn === undefined ? {} : { turn: this.#block.turn }),
211
+ }, { values: { ...this.#values } });
212
+ return true;
213
+ }
214
+ /** The read-only gate (access read disables editing, §6.5). */
215
+ get #readOnly() {
216
+ return this.#ctx.access !== "write";
217
+ }
218
+ /** The submit label (spec §5.6's default). */
219
+ get #submitLabel() {
220
+ return bindText(this.#block.submitLabel, this.#ctx) || "Submit";
221
+ }
222
+ /** The total focusable rows: fields (+ a submit row for submit mode). */
223
+ #focusableCount() {
224
+ const submitRow = this.#block.mode === "submit" && !this.#readOnly ? 1 : 0;
225
+ return this.#fields.length + submitRow;
226
+ }
227
+ /** The fields visible in the current step (the stepper shows one
228
+ * section; a flat form shows them all). */
229
+ #visibleFields() {
230
+ const sections = stepperSections(this.#block, this.#interaction);
231
+ if (sections === null)
232
+ return this.#fields;
233
+ if (this.#step >= sections.length)
234
+ return []; // the review step
235
+ const section = sections[this.#step];
236
+ if (section === undefined)
237
+ return [];
238
+ const fields = [];
239
+ for (const child of section.children) {
240
+ if (typeof child === "object" &&
241
+ child !== null &&
242
+ typeof child.name === "string" &&
243
+ typeof child.type === "string") {
244
+ fields.push(child);
245
+ }
246
+ }
247
+ return fields;
248
+ }
249
+ /** Render (the form re-renders against the current ctx data — the
250
+ * host re-creates or re-renders on SurfaceState changes). */
251
+ render(width) {
252
+ const sections = stepperSections(this.#block, this.#interaction);
253
+ if (sections !== null)
254
+ return this.#renderStepper(width, sections);
255
+ if (this.#block.mode === "inline")
256
+ return this.#renderInline(width);
257
+ const lines = [];
258
+ this.#visibleFields().forEach((field) => {
259
+ const focused = this.#fields[this.#focus] === field;
260
+ lines.push(...this.#renderField(field, width, focused));
261
+ });
262
+ if (this.#block.mode === "submit" && !this.#readOnly) {
263
+ const cursor = this.#focus === this.#fields.length ? "> " : " ";
264
+ lines.push(truncate(`${cursor}[ ${this.#submitLabel} ]`, width));
265
+ }
266
+ return lines;
267
+ }
268
+ #renderInline(width) {
269
+ // Inline (§5.6): one row — the field editor plus the submit label.
270
+ const field = this.#fields[0];
271
+ if (field === undefined)
272
+ return [];
273
+ const state = this.#textState(field);
274
+ const editor = state.buffer.render(this.#ctx.unicode);
275
+ return [
276
+ truncate(`${fieldLabel(field, this.#ctx)}: ${editor} [ ${this.#submitLabel} ]`, width),
277
+ ];
278
+ }
279
+ #renderStepper(width, sections) {
280
+ const total = sections.length;
281
+ const lines = [];
282
+ if (this.#step >= total) {
283
+ // The review step (§9.1): every section's answerable fields, then
284
+ // submit. A section title line leads its field answers.
285
+ lines.push(this.#ctx.theme.bold(truncate("Review", width)));
286
+ sections.forEach((section) => {
287
+ const title = bindText(section.title, this.#ctx);
288
+ const answers = sectionFields(section).map((field) => reviewText(field, this.#ctx, this.#values[field.name] ?? null));
289
+ const summary = answers.filter((text) => text !== "").join(", ");
290
+ lines.push(truncate(` ${title}: ${summary}`, width));
291
+ });
292
+ if (!this.#readOnly && !this.#submitted) {
293
+ lines.push(truncate(`> [ ${this.#submitLabel} ]`, width));
294
+ }
295
+ lines.push(this.#ctx.theme.dim(truncate("Up/Back returns · Enter submits", width)));
296
+ return lines;
297
+ }
298
+ const section = sections[this.#step];
299
+ lines.push(this.#ctx.theme.dim(truncate(`Step ${this.#step + 1} of ${total}`, width)));
300
+ if (section !== undefined) {
301
+ const title = bindText(section.title, this.#ctx);
302
+ if (title !== "")
303
+ lines.push(this.#ctx.theme.bold(truncate(title, width)));
304
+ // Only the section's FIRST field takes focus chrome and input;
305
+ // the rest render read-only (the stepper's contract is one
306
+ // answer per step — §9.1).
307
+ const visible = this.#visibleFields();
308
+ const primary = visible[0];
309
+ for (const field of visible) {
310
+ lines.push(...this.#renderField(field, width, field === primary));
311
+ }
312
+ }
313
+ lines.push(this.#ctx.theme.dim(truncate(this.#step + 1 === total
314
+ ? "Enter selects · Tab reviews"
315
+ : "Enter selects · Tab continues", width)));
316
+ return lines;
317
+ }
318
+ /** One field's rows (the chrome plus its editor). */
319
+ #renderField(field, width, focused) {
320
+ const error = this.#errorOf(field);
321
+ const editor = this.#editorLines(field, width, focused);
322
+ return fieldRowLines({ field, ctx: this.#ctx, width, editor, error });
323
+ }
324
+ /** The editor lines of a field (per type). */
325
+ #editorLines(field, width, focused) {
326
+ const value = this.#values[field.name];
327
+ const compact = widthClassOf(width) === "compact";
328
+ const indent = compact && fieldLabel(field, this.#ctx) !== "" ? " " : "";
329
+ switch (field.type) {
330
+ case "toggle": {
331
+ const box = value === true ? (this.#ctx.unicode ? "[x]" : "[x]") : "[ ]";
332
+ return [`${indent}${focused ? "> " : " "}${box} ${value === true ? "on" : "off"}`];
333
+ }
334
+ case "choice":
335
+ return choiceLines({
336
+ field,
337
+ ctx: this.#ctx,
338
+ width,
339
+ value,
340
+ state: this.#choiceState(field),
341
+ focused,
342
+ }).map((line) => (indent === "" ? line : `${indent}${line}`));
343
+ case "secret": {
344
+ const status = secretStatusLine(field, this.#block.bind, this.#ctx);
345
+ const state = this.#textState(field);
346
+ const masked = maskText(state.buffer.text, this.#ctx.unicode);
347
+ const editor = focused
348
+ ? `${masked}${this.#ctx.unicode ? "▏" : "|"}`
349
+ : masked;
350
+ return [`${indent}${editor}`, this.#ctx.theme.dim(status)];
351
+ }
352
+ case "file": {
353
+ const state = this.#fileState(field);
354
+ const buffer = this.#textState(field).buffer;
355
+ const lines = [`${indent}${focused ? buffer.render(this.#ctx.unicode) : buffer.text}`];
356
+ const status = fileStatusLine(state, width);
357
+ if (status !== undefined)
358
+ lines.push(this.#ctx.theme.dim(status));
359
+ return lines;
360
+ }
361
+ case "list": {
362
+ const state = this.#listState(field);
363
+ return listLines({
364
+ field,
365
+ width,
366
+ value,
367
+ state,
368
+ focused,
369
+ entryLine: (index, entry, entryFocused) => {
370
+ const key = `${field.name}.${index}`;
371
+ let buffer = this.#text.get(key)?.buffer;
372
+ if (buffer === undefined) {
373
+ buffer = new EditBuffer(valueText(entry));
374
+ this.#text.set(key, { buffer });
375
+ }
376
+ return entryFocused ? buffer.render(this.#ctx.unicode) : buffer.text;
377
+ },
378
+ }).map((line) => (indent === "" ? line : `${indent}${line}`));
379
+ }
380
+ default: {
381
+ // text / number / date / datetime / longtext: the one-line editor.
382
+ const state = this.#textState(field);
383
+ const line = focused
384
+ ? state.buffer.render(this.#ctx.unicode)
385
+ : state.buffer.text;
386
+ const editorHint = field.type === "longtext" && field.syntax === "code" && focused
387
+ ? this.#ctx.theme.dim(" (e: edit in $EDITOR)")
388
+ : "";
389
+ return [`${indent}${line}${editorHint}`];
390
+ }
391
+ }
392
+ }
393
+ /** Input handling (the host routes keys while the form has focus). */
394
+ handleInput(data) {
395
+ if (this.#readOnly)
396
+ return;
397
+ const sections = stepperSections(this.#block, this.#interaction);
398
+ if (sections !== null) {
399
+ this.#inputStepper(data, sections);
400
+ return;
401
+ }
402
+ if (this.#block.mode === "inline") {
403
+ this.#inputInline(data);
404
+ return;
405
+ }
406
+ this.#inputForm(data);
407
+ }
408
+ #inputStepper(data, sections) {
409
+ const total = sections.length;
410
+ if (this.#step >= total) {
411
+ // The review step: Up/Back returns, Enter submits once.
412
+ if (data === "\x1b[A") {
413
+ this.#step = total - 1;
414
+ return;
415
+ }
416
+ if (data === "\r" && !this.#submitted) {
417
+ if (this.#submit())
418
+ this.#submitted = true;
419
+ }
420
+ return;
421
+ }
422
+ const visible = this.#visibleFields();
423
+ const field = visible[0];
424
+ if (data === "\x1b[A") {
425
+ if (this.#step > 0)
426
+ this.#step -= 1;
427
+ return;
428
+ }
429
+ if (data === "\t") {
430
+ this.#step = Math.min(total, this.#step + 1);
431
+ return;
432
+ }
433
+ if (field !== undefined && field.type === "choice") {
434
+ const state = this.#choiceState(field);
435
+ if (data === "\x1b[B") {
436
+ state.cursor = Math.min(choiceRowCount(field, this.#ctx) - 1, state.cursor + 1);
437
+ return;
438
+ }
439
+ if (data === "\x1b[A") {
440
+ state.cursor = Math.max(0, state.cursor - 1);
441
+ return;
442
+ }
443
+ if (data === "\r" || data === " ") {
444
+ const next = choiceActivate(field, this.#ctx, this.#values[field.name], state.cursor);
445
+ if (next !== undefined) {
446
+ this.#change(field.name, next);
447
+ return;
448
+ }
449
+ // The other row: Enter selects the typed text as the value.
450
+ const typed = state.other.text;
451
+ if (typed !== "") {
452
+ this.#change(field.name, choiceSetOther(field, this.#values[field.name], typed));
453
+ }
454
+ return;
455
+ }
456
+ // Typing edits the other row's text (when the cursor sits on it).
457
+ if (state.cursor >= choiceRowCount(field, this.#ctx) - 1 && field.allowOther !== undefined) {
458
+ if (state.other.input(data)) {
459
+ this.#change(field.name, choiceSetOther(field, this.#values[field.name], state.other.text));
460
+ }
461
+ }
462
+ }
463
+ }
464
+ #inputInline(data) {
465
+ const field = this.#fields[0];
466
+ if (field === undefined)
467
+ return;
468
+ if (data === "\r") {
469
+ if (this.#submit()) {
470
+ // Inline clears on success (the dispatch did not throw).
471
+ const cleared = {};
472
+ for (const item of this.#fields) {
473
+ cleared[item.name] = initialValue(item, {});
474
+ this.#text.set(item.name, { buffer: new EditBuffer("") });
475
+ }
476
+ this.#values = cleared;
477
+ }
478
+ return;
479
+ }
480
+ const state = this.#textState(field);
481
+ if (state.buffer.input(data)) {
482
+ this.#values = { ...this.#values, [field.name]: state.buffer.text };
483
+ }
484
+ }
485
+ #inputForm(data) {
486
+ const count = this.#focusableCount();
487
+ const focused = this.#fields[this.#focus];
488
+ if (data === "\t") {
489
+ this.#focus = Math.min(count - 1, this.#focus + 1);
490
+ return;
491
+ }
492
+ // Up/Down move between fields EXCEPT when the focused field owns
493
+ // a vertical cursor (choice/list): their rows consume the arrows.
494
+ const ownsArrows = focused !== undefined && (focused.type === "choice" || focused.type === "list");
495
+ if (data === "\x1b[A") {
496
+ if (ownsArrows) {
497
+ this.#inputField(focused, data);
498
+ }
499
+ else {
500
+ this.#focus = Math.max(0, this.#focus - 1);
501
+ }
502
+ return;
503
+ }
504
+ if (data === "\x1b[B") {
505
+ if (ownsArrows) {
506
+ this.#inputField(focused, data);
507
+ }
508
+ else {
509
+ this.#focus = Math.min(count - 1, this.#focus + 1);
510
+ }
511
+ return;
512
+ }
513
+ const field = this.#fields[this.#focus];
514
+ if (field === undefined) {
515
+ // The submit row.
516
+ if (data === "\r")
517
+ this.#submit();
518
+ return;
519
+ }
520
+ if (field.disabled === true)
521
+ return;
522
+ this.#inputField(field, data);
523
+ }
524
+ /** Route input to the focused field's editor. */
525
+ #inputField(field, data) {
526
+ const value = this.#values[field.name];
527
+ switch (field.type) {
528
+ case "toggle":
529
+ if (data === "\r" || data === " ")
530
+ this.#change(field.name, value !== true);
531
+ return;
532
+ case "choice": {
533
+ const state = this.#choiceState(field);
534
+ clampChoiceCursor(field, this.#ctx, state);
535
+ if (data === "\x1b[A" && state.cursor > 0) {
536
+ state.cursor -= 1;
537
+ return;
538
+ }
539
+ if (data === "\x1b[B" && state.cursor < choiceRowCount(field, this.#ctx) - 1) {
540
+ state.cursor += 1;
541
+ return;
542
+ }
543
+ if (data === "\r" || data === " ") {
544
+ const next = choiceActivate(field, this.#ctx, value, state.cursor);
545
+ if (next !== undefined) {
546
+ this.#change(field.name, next);
547
+ return;
548
+ }
549
+ const typed = state.other.text;
550
+ if (typed !== "") {
551
+ this.#change(field.name, choiceSetOther(field, value, typed));
552
+ }
553
+ return;
554
+ }
555
+ if (state.cursor >= choiceRowCount(field, this.#ctx) - 1 && field.allowOther !== undefined) {
556
+ if (state.other.input(data)) {
557
+ this.#change(field.name, choiceSetOther(field, value, state.other.text));
558
+ }
559
+ }
560
+ return;
561
+ }
562
+ case "list": {
563
+ const state = this.#listState(field);
564
+ const entries = listEntries(value);
565
+ if (data === "\x1b[A" && state.cursor > 0) {
566
+ state.cursor -= 1;
567
+ return;
568
+ }
569
+ if (data === "\x1b[B" && state.cursor < Math.max(0, entries.length - 1)) {
570
+ state.cursor += 1;
571
+ return;
572
+ }
573
+ if (data === "a" && listCanAdd(field, value)) {
574
+ const next = listAdd(value);
575
+ this.#change(field.name, next);
576
+ // The cursor lands on the new entry so typing edits it.
577
+ state.cursor = listEntries(next).length - 1;
578
+ return;
579
+ }
580
+ if (data === "d" && entries.length > 0 && listCanRemove(field, value)) {
581
+ this.#change(field.name, listRemove(value, state.cursor));
582
+ state.cursor = Math.max(0, state.cursor - 1);
583
+ return;
584
+ }
585
+ if (field.reorder === true) {
586
+ if (data === "u") {
587
+ this.#change(field.name, listMove(value, state.cursor, -1));
588
+ state.cursor = Math.max(0, state.cursor - 1);
589
+ return;
590
+ }
591
+ if (data === "j") {
592
+ this.#change(field.name, listMove(value, state.cursor, 1));
593
+ state.cursor = Math.min(entries.length - 1, state.cursor + 1);
594
+ return;
595
+ }
596
+ }
597
+ const key = `${field.name}.${state.cursor}`;
598
+ let entryState = this.#text.get(key);
599
+ if (entryState === undefined) {
600
+ entryState = { buffer: new EditBuffer(valueText(entries[state.cursor])) };
601
+ this.#text.set(key, entryState);
602
+ }
603
+ if (entryState.buffer.input(data)) {
604
+ this.#change(field.name, listSet(value, state.cursor, entryState.buffer.text));
605
+ }
606
+ return;
607
+ }
608
+ case "file": {
609
+ if (data !== "\r") {
610
+ const state = this.#textState(field);
611
+ if (state.buffer.input(data)) {
612
+ // The typed path is editing scratch, not the value (the
613
+ // value becomes the descriptor(s) on a successful pick).
614
+ }
615
+ return;
616
+ }
617
+ // Enter picks the typed path: check, read, dispatch onUpload.
618
+ const path = this.#textState(field).buffer.text;
619
+ if (path === "")
620
+ return;
621
+ const picked = pickFile(field, path);
622
+ const state = this.#fileState(field);
623
+ if (!picked.ok) {
624
+ state.error = picked.error;
625
+ return;
626
+ }
627
+ state.error = undefined;
628
+ state.uploaded = [...state.uploaded, picked.descriptor.name];
629
+ // A multi file field APPENDS picks; single replaces.
630
+ const prior = this.#values[field.name];
631
+ this.#values = {
632
+ ...this.#values,
633
+ [field.name]: field.multi === true
634
+ ? [...(Array.isArray(prior) ? prior : []), picked.descriptor]
635
+ : picked.descriptor,
636
+ };
637
+ this.#ctx.dispatch({ type: "submit", name: field.onUpload, turn: false }, { field: field.name, files: [picked.payloadFile] });
638
+ this.#ctx.uploadBytes?.(field.name, picked.descriptor.name, picked.bytes);
639
+ // The pick consumed the path; clear the editor so the next
640
+ // pick types fresh (a multi field appends its descriptors).
641
+ this.#text.set(field.name, { buffer: new EditBuffer("") });
642
+ return;
643
+ }
644
+ case "longtext":
645
+ if (field.syntax === "code" && data === "e" && this.#ctx.openEditor !== undefined) {
646
+ const state = this.#textState(field);
647
+ const result = this.#ctx.openEditor(state.buffer.text);
648
+ if (result.ok) {
649
+ state.buffer = new EditBuffer(result.text);
650
+ this.#text.set(field.name, state);
651
+ this.#change(field.name, result.text);
652
+ }
653
+ return;
654
+ }
655
+ // fall through to the text editor
656
+ default: {
657
+ const state = this.#textState(field);
658
+ if (data === "\r") {
659
+ // Enter on a text-shaped field moves to the next row (the
660
+ // submit row's Enter sends the form).
661
+ this.#focus = Math.min(this.#focusableCount() - 1, this.#focus + 1);
662
+ return;
663
+ }
664
+ if (state.buffer.input(data)) {
665
+ if (field.type === "number") {
666
+ const raw = state.buffer.text;
667
+ const parsed = Number(raw);
668
+ // Never NaN into the values (NaN serializes to null and
669
+ // defeats min/max validation): non-numeric text holds the
670
+ // empty value until it parses.
671
+ this.#change(field.name, raw === "" || Number.isNaN(parsed) ? "" : parsed);
672
+ return;
673
+ }
674
+ this.#change(field.name, state.buffer.text);
675
+ }
676
+ }
677
+ }
678
+ }
679
+ invalidate() {
680
+ // The form's values/focus live in the component; a re-render
681
+ // re-reads ctx.data for the bound object (data updates never
682
+ // touch the editing state — Review Focus 4).
683
+ }
684
+ /** Cancel any pending live debounce (the host's dispose seam). */
685
+ dispose() {
686
+ if (this.#debounce !== null)
687
+ clearTimeout(this.#debounce);
688
+ this.#debounce = null;
689
+ }
690
+ /** Test/host introspection: the current values and focus. */
691
+ get values() {
692
+ return this.#values;
693
+ }
694
+ get focusIndex() {
695
+ return this.#focus;
696
+ }
697
+ get step() {
698
+ return this.#step;
699
+ }
700
+ get submittedOnce() {
701
+ return this.#submitted;
702
+ }
703
+ }