bare-tui-form 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/form.js ADDED
@@ -0,0 +1,1015 @@
1
+ // Form — a declarative form built from fields.
2
+ //
3
+ // Give it an ordered list of fields; it owns focus movement between them (via
4
+ // bare-tui's focus ring), validation, and submission, and renders the whole
5
+ // thing. Fields may be field instances (form.text({…})) or plain definition
6
+ // objects with a `type` (`{ type: 'text', name: 'email' }`) — the latter is the
7
+ // shape the JSON-Schema mapper emits, so both paths converge here.
8
+ //
9
+ // NESTED OBJECTS. A field def may be an object section
10
+ // ({ type: 'object', name, optional, fields: [...] }); the form *flattens* these
11
+ // into a flat, depth-first list of focusable fields — each carrying its `path`
12
+ // (['address','street']) — plus a render `layout` with section headers and
13
+ // indentation. value()/setValues()/errors() walk the paths to produce/consume
14
+ // the nested object. A non-required (`optional`) section gets a checkbox gate.
15
+ //
16
+ // DYNAMIC FIELD SETS (oneOf/anyOf/if-then-else). A field (and its layout item)
17
+ // may carry an `_activeWhen()` predicate — a closure over controlling field
18
+ // instances. Inactive fields are hidden, unfocusable, uncollected, unvalidated.
19
+ // The form recomputes the active set on every keystroke and re-drives the ring.
20
+ //
21
+ // ARRAYS OF OBJECTS ({ type: 'array', itemFields, minItems, maxItems }). The set
22
+ // of fields can change *size* at runtime. Each array tracks an ordered list of
23
+ // entry *ids* (stable identity, independent of position); positions derive paths
24
+ // (contacts.0.name). _build() expands every array's entries and reuses field
25
+ // instances from a cache keyed by (arrayPath, entryId, subPath), so add / remove
26
+ // preserve each entry's values and shift the rest cleanly. Add/remove are
27
+ // focusable buttons (fields/action.js); the form mutates the id list and
28
+ // rebuilds. value() materializes real arrays.
29
+ //
30
+ // Keys (all configurable via the `keys` option — see defaultKeys): enter
31
+ // confirms the focused field and advances / activates a button — it never
32
+ // submits. A dedicated `submit` key (default ctrl+s) commits the form, so a form
33
+ // ending in an array's "+ Add" button can't be submitted by accident. When the
34
+ // body is taller than the terminal the form scrolls, keeping the focused field
35
+ // in view. The form never calls quit; it emits 'form.submit' / 'form.cancel' and
36
+ // the host (or run()) decides what they mean.
37
+ const { Program, quit, batch, key, spinner } = require('bare-tui')
38
+ const focus = require('bare-tui').focus
39
+
40
+ const FRAME_SETS = { dots: spinner.dots, line: spinner.line, points: spinner.points }
41
+
42
+ const { text } = require('./fields/text')
43
+ const { textarea } = require('./fields/textarea')
44
+ const { number } = require('./fields/number')
45
+ const { select } = require('./fields/select')
46
+ const { radio } = require('./fields/radio')
47
+ const { confirm } = require('./fields/confirm')
48
+ const { multiselect } = require('./fields/multiselect')
49
+ const { constant } = require('./fields/constant')
50
+ const { action } = require('./fields/action')
51
+ const { SectionToggleField } = require('./fields/section')
52
+ const { Field } = require('./fields/base')
53
+ const theme = require('./theme')
54
+
55
+ const emit = (msg) => () => msg
56
+ const always = () => true
57
+
58
+ // Form-level keybindings. Every action is an array of chords; pass a partial
59
+ // `keys` to form.create() to override individual ones (merged over these).
60
+ //
61
+ // Submit is deliberately NOT enter: enter confirms a field / activates a button
62
+ // / advances, and a separate `submit` chord commits the form — so a form that
63
+ // ends in an array's "+ Add" button can't be submitted by accident. The default
64
+ // submit is ctrl+s rather than a modifier+enter because most terminals can't
65
+ // tell ctrl/shift+enter from a plain enter (all arrive as \r); bind it to
66
+ // ['alt+enter'] etc. if your terminal supports distinguishing it.
67
+ const defaultKeys = {
68
+ submit: ['ctrl+s'], // commit the whole form
69
+ cancel: ['ctrl+c'], // emit form.cancel
70
+ confirm: ['enter'], // confirm the focused field / activate a button / advance
71
+ next: ['tab'], // focus the next field
72
+ prev: ['shift+tab'], // focus the previous field
73
+ scrollUp: ['pageup'], // scroll the body up a page (when it doesn't all fit)
74
+ scrollDown: ['pagedown'] // scroll the body down a page
75
+ }
76
+
77
+ function mergeKeys(user) {
78
+ const out = {}
79
+ for (const k of Object.keys(defaultKeys)) {
80
+ out[k] = user && user[k] ? [].concat(user[k]) : defaultKeys[k].slice()
81
+ }
82
+ return out
83
+ }
84
+ const SEP = '\u0000' // path/cache-key segment separator: a NUL, which never appears in a JSON key
85
+
86
+ const builders = { text, textarea, number, select, radio, confirm, multiselect, constant }
87
+
88
+ // Turn a leaf field definition object into a field instance; pass instances
89
+ // through untouched. Object/variant/conditional/array groups are handled by
90
+ // flatten(), not here.
91
+ function fromDef(def) {
92
+ if (def instanceof Field) return def
93
+ if (def && typeof def === 'object' && def.type) {
94
+ const build = builders[def.type]
95
+ if (!build) throw new Error('unknown field type: ' + def.type)
96
+ return build(def)
97
+ }
98
+ throw new Error('form field must be a field instance or a { type, … } object')
99
+ }
100
+
101
+ // Fetch-or-create a field instance by cache key, so rebuilds preserve state.
102
+ function getOrCreate(ctx, keySegs, factory) {
103
+ const k = keySegs.join(SEP)
104
+ let f = ctx.cache.get(k)
105
+ if (!f) {
106
+ f = factory()
107
+ ctx.cache.set(k, f)
108
+ }
109
+ return f
110
+ }
111
+
112
+ // Read `path` from `obj`. Returns { found, value } so an explicit `undefined`
113
+ // is distinguishable from "absent" (uses hasOwnProperty — pollution-safe; works
114
+ // on arrays since numeric string indices are own keys).
115
+ function getIn(obj, path) {
116
+ let node = obj
117
+ for (let i = 0; i < path.length; i++) {
118
+ if (!node || typeof node !== 'object') return { found: false }
119
+ const k = path[i]
120
+ if (!Object.prototype.hasOwnProperty.call(node, k)) return { found: false }
121
+ node = node[k]
122
+ }
123
+ return { found: true, value: node }
124
+ }
125
+
126
+ // Is `prefix` a (strict or equal) ancestor path of `path`?
127
+ function isAncestor(prefix, path) {
128
+ if (prefix.length >= path.length) return false
129
+ for (let i = 0; i < prefix.length; i++) if (prefix[i] !== path[i]) return false
130
+ return true
131
+ }
132
+ function startsWith(path, prefix) {
133
+ if (path.length < prefix.length) return false
134
+ for (let i = 0; i < prefix.length; i++) if (path[i] !== prefix[i]) return false
135
+ return true
136
+ }
137
+
138
+ // Flatten (possibly nested / dynamic / repeating) field defs into { fields,
139
+ // layout }. `parentPath` is the value path; `parentCache` is the parallel cache-
140
+ // key prefix (identical to parentPath except inside an array entry, where the
141
+ // position index in the path is an entry id in the cache — so values follow an
142
+ // entry across reorders). `active` is the inherited activeness predicate. `ctx`
143
+ // carries the instance cache, the array-state registry, and the set of array
144
+ // paths (for array-aware value()).
145
+ function flatten(defs, parentPath, parentCache, active, ctx) {
146
+ const fields = []
147
+ const layout = []
148
+ const locals = {} // local name → field, for conditional guards
149
+
150
+ const add = (field, activeWhen, indent) => {
151
+ field._activeWhen = activeWhen
152
+ fields.push(field)
153
+ layout.push({ type: 'field', field, indent, activeWhen })
154
+ }
155
+
156
+ for (const def of defs) {
157
+ // --- nested object: subform or optional gate -----------------------------
158
+ if (def && !(def instanceof Field) && def.type === 'object') {
159
+ const path = parentPath.concat(def.name)
160
+ const cache = parentCache.concat(def.name)
161
+ const indent = path.length - 1
162
+ if (def.optional) {
163
+ const toggle = getOrCreate(
164
+ ctx,
165
+ cache,
166
+ () =>
167
+ new SectionToggleField({
168
+ name: def.name,
169
+ title: def.title || def.name,
170
+ description: def.description || '',
171
+ value: !!def.value
172
+ })
173
+ )
174
+ toggle.path = path
175
+ locals[def.name] = toggle
176
+ add(toggle, active, indent)
177
+ } else {
178
+ layout.push({
179
+ type: 'header',
180
+ title: def.title || def.name,
181
+ description: def.description || '',
182
+ indent,
183
+ activeWhen: active
184
+ })
185
+ }
186
+ const child = flatten(def.fields || [], path, cache, active, ctx)
187
+ fields.push(...child.fields)
188
+ layout.push(...child.layout)
189
+ continue
190
+ }
191
+
192
+ // --- oneOf/anyOf variant -------------------------------------------------
193
+ if (def && def.type === 'variant') {
194
+ const path = parentPath.concat(def.name)
195
+ const cache = parentCache.concat(def.name)
196
+ const selector = getOrCreate(ctx, cache.concat('$select'), () =>
197
+ select({
198
+ name: def.name,
199
+ label: def.selectorLabel || def.title || def.name,
200
+ description: def.description || '',
201
+ options: def.branches.map((b, i) => ({ label: b.title || `Option ${i + 1}`, value: i })),
202
+ selected: 0
203
+ })
204
+ )
205
+ selector.path = path
206
+ selector.isSelector = true
207
+ selector._branchKeys = def.branches.map((b) =>
208
+ (b.fields || []).map((x) => x && x.name).filter(Boolean)
209
+ )
210
+ locals[def.name] = selector
211
+ add(selector, active, path.length - 1)
212
+
213
+ def.branches.forEach((branch, i) => {
214
+ const branchActive = () => active() && selector.value() === i
215
+ // Branches share the value path but are mutually exclusive, so give each
216
+ // its own cache prefix — otherwise two branches' same-named fields would
217
+ // collide on one cached instance.
218
+ const child = flatten(branch.fields || [], path, cache.concat('~' + i), branchActive, ctx)
219
+ fields.push(...child.fields)
220
+ layout.push(...child.layout)
221
+ })
222
+ continue
223
+ }
224
+
225
+ // --- if/then/else conditional --------------------------------------------
226
+ if (def && def.type === 'conditional') {
227
+ const controllers = (def.when || []).map((w) => ({ name: w.name, allowed: w.allowed }))
228
+ const guard = () =>
229
+ controllers.every((c) => {
230
+ const f = locals[c.name]
231
+ return f ? c.allowed.has(f.value()) : false
232
+ })
233
+ const thenActive = () => active() && guard()
234
+ const elseActive = () => active() && !guard()
235
+ // Distinct cache prefixes so a then field and an else field of the same
236
+ // name don't share one cached instance (they're mutually exclusive).
237
+ const t = flatten(def.then || [], parentPath, parentCache.concat('~then'), thenActive, ctx)
238
+ const e = flatten(def.else || [], parentPath, parentCache.concat('~else'), elseActive, ctx)
239
+ fields.push(...t.fields, ...e.fields)
240
+ layout.push(...t.layout, ...e.layout)
241
+ continue
242
+ }
243
+
244
+ // --- array of objects ----------------------------------------------------
245
+ if (def && def.type === 'array') {
246
+ const path = parentPath.concat(def.name)
247
+ const cache = parentCache.concat(def.name)
248
+ const stateKey = path.join(SEP)
249
+ ctx.arrayPaths.set(stateKey, path)
250
+ let state = ctx.arrays.get(stateKey)
251
+ if (!state) {
252
+ state = { ids: [], nextId: 1, minItems: def.minItems || 0, maxItems: def.maxItems || 0 }
253
+ for (let i = 0; i < state.minItems; i++) state.ids.push(state.nextId++)
254
+ ctx.arrays.set(stateKey, state)
255
+ }
256
+ state.path = path
257
+ state.cache = cache
258
+ state.activeWhen = active
259
+ const addable = def.addable !== false // uiSchema ui:options.addable
260
+ const removable = def.removable !== false // uiSchema ui:options.removable
261
+
262
+ layout.push({
263
+ type: 'arrayHeader',
264
+ title: def.title || def.name,
265
+ description: def.description || '',
266
+ indent: path.length - 1,
267
+ arrayKey: stateKey,
268
+ activeWhen: active
269
+ })
270
+
271
+ state.ids.forEach((id, pos) => {
272
+ const entryPath = path.concat(String(pos))
273
+ const entryCache = cache.concat('@' + id)
274
+ layout.push({
275
+ type: 'entryHeader',
276
+ title: def.itemTitle || 'Item',
277
+ index: pos,
278
+ indent: path.length,
279
+ activeWhen: active
280
+ })
281
+ const child = flatten(def.itemFields || [], entryPath, entryCache, active, ctx)
282
+ fields.push(...child.fields)
283
+ layout.push(...child.layout)
284
+ if (removable && state.ids.length > state.minItems) {
285
+ const btn = getOrCreate(ctx, entryCache.concat('$remove'), () =>
286
+ action({
287
+ name: '$remove',
288
+ buttonLabel: '✕ Remove',
289
+ action: { kind: 'array.remove', arrayKey: stateKey, id }
290
+ })
291
+ )
292
+ btn.path = entryPath.concat('$remove')
293
+ add(btn, active, path.length)
294
+ }
295
+ })
296
+
297
+ if (addable && state.ids.length < state.maxItems) {
298
+ const btn = getOrCreate(ctx, cache.concat('$add'), () =>
299
+ action({
300
+ name: '$add',
301
+ buttonLabel: def.addLabel || '+ Add',
302
+ action: { kind: 'array.add', arrayKey: stateKey }
303
+ })
304
+ )
305
+ btn.path = path.concat('$add')
306
+ add(btn, active, path.length - 1)
307
+ }
308
+ continue
309
+ }
310
+
311
+ // --- leaf ----------------------------------------------------------------
312
+ const key = def instanceof Field ? def.key : def.name
313
+ const field = getOrCreate(ctx, parentCache.concat(key), () => fromDef(def))
314
+ field.path = parentPath.concat(field.key)
315
+ if (field.key) locals[field.key] = field
316
+ field._activeWhen = active
317
+ fields.push(field)
318
+ // A hidden field (uiSchema `ui:widget: 'hidden'`) is collected but never
319
+ // rendered or focused.
320
+ if (!field.hidden) {
321
+ layout.push({ type: 'field', field, indent: field.path.length - 1, activeWhen: active })
322
+ }
323
+ }
324
+
325
+ return { fields, layout }
326
+ }
327
+
328
+ class Form {
329
+ constructor(opts = {}) {
330
+ this.title = opts.title || ''
331
+ this.description = opts.description || ''
332
+ this.theme = theme.merge(opts.theme)
333
+ this.keys = mergeKeys(opts.keys)
334
+ // Re-run dirty async validators on submit (serially, behind one spinner).
335
+ // Default on; pass false to opt out — then YOU are responsible for ensuring
336
+ // async checks ran (the form will submit whatever's there).
337
+ this.validateAsyncOnSubmit = opts.validateAsyncOnSubmit !== false
338
+ this._formValidating = false // true while the submit-time async pass runs
339
+ this._formSpinner = null
340
+ this._queue = [] // async fields still to check this submit
341
+ this._queueAt = 0
342
+ this._submitToken = 0 // generation: discards stale/cancelled check results
343
+ this._defs = opts.fields || []
344
+ this._cache = new Map()
345
+ this._arrays = new Map()
346
+ this._arrayErrors = {}
347
+ this.width = 0
348
+ this.height = 0 // 0 = unknown → render everything inline (no scrolling)
349
+ this._scroll = 0 // body scroll offset (lines), used once height is known
350
+ this.ring = focus.create({
351
+ items: [],
352
+ keys: {
353
+ next: key.binding({ keys: this.keys.next }),
354
+ prev: key.binding({ keys: this.keys.prev })
355
+ }
356
+ })
357
+ this.submitted = false
358
+ this.cancelled = false
359
+ this.busy = false // true while an async validation is in flight
360
+ this._build()
361
+ this._recompute()
362
+ this._applyAutofocus()
363
+ }
364
+
365
+ // uiSchema `ui:autofocus`: start focus on the first field that asked for it.
366
+ _applyAutofocus() {
367
+ const i = this.ring.items.findIndex((f) => f.autofocus)
368
+ if (i >= 0) this.ring.focus(i)
369
+ }
370
+
371
+ // (Re)expand defs into the current flat field list + render layout, reusing
372
+ // cached instances so values survive structural changes (array add/remove).
373
+ _build() {
374
+ const ctx = { cache: this._cache, arrays: this._arrays, arrayPaths: new Map() }
375
+ const { fields, layout } = flatten(this._defs, [], [], always, ctx)
376
+ this.fields = fields
377
+ this.layout = layout
378
+ this._arrayPaths = ctx.arrayPaths
379
+ this._arrayKeySet = new Set(ctx.arrayPaths.keys())
380
+ this.fields.forEach((f) => f.setTheme(this.theme))
381
+ return this
382
+ }
383
+
384
+ init() {
385
+ const cmds = this.fields
386
+ .map((f) => (typeof f.init === 'function' ? f.init() : null))
387
+ .filter(Boolean)
388
+ return cmds.length ? batch(...cmds) : null
389
+ }
390
+
391
+ _isActive(field) {
392
+ return field._activeWhen ? !!field._activeWhen() : true
393
+ }
394
+
395
+ // Recompute the live fields and re-drive the focus ring with the active,
396
+ // focusable subset — preserving focus on the current field when it survives.
397
+ _recompute() {
398
+ const focused = this.ring.focused()
399
+ this.activeFields = this.fields.filter((f) => this._isActive(f))
400
+ const items = this.activeFields.filter((f) => f.focusable !== false)
401
+ this.ring.setItems(items)
402
+ if (focused) {
403
+ const i = items.indexOf(focused)
404
+ if (i >= 0) this.ring.focus(i)
405
+ }
406
+ return this
407
+ }
408
+
409
+ // Build `value` at `path`, creating arrays at array paths and objects elsewhere.
410
+ _setIn(obj, path, value) {
411
+ let node = obj
412
+ for (let i = 0; i < path.length - 1; i++) {
413
+ const k = path[i]
414
+ if (node[k] === null || node[k] === undefined || typeof node[k] !== 'object') {
415
+ node[k] = this._arrayKeySet.has(path.slice(0, i + 1).join(SEP)) ? [] : {}
416
+ }
417
+ node = node[k]
418
+ }
419
+ node[path[path.length - 1]] = value
420
+ }
421
+
422
+ // The collected values, as a (possibly nested, possibly array-bearing) object.
423
+ // Selectors, section gates, and buttons contribute no value; inactive fields
424
+ // and the subtree of an unchecked optional section are omitted. Active arrays
425
+ // always appear (as [] when empty).
426
+ value() {
427
+ const out = {}
428
+ for (const f of this.activeFields) {
429
+ if (f.isSection || f.isSelector || f.isButton) continue
430
+ if (this._gatedOff(f)) continue
431
+ this._setIn(out, f.path, f.value())
432
+ }
433
+ for (const state of this._arrays.values()) {
434
+ if (state.activeWhen && !state.activeWhen()) continue
435
+ if (!getIn(out, state.path).found) this._setIn(out, state.path, [])
436
+ }
437
+ return out
438
+ }
439
+
440
+ // Rehydrate from an existing object: size arrays to match the data (then
441
+ // rebuild), point variant selectors at the matching branch, arm present
442
+ // optional sections, and set every leaf by path.
443
+ setValues(values) {
444
+ if (!values || typeof values !== 'object') return this
445
+ let resized = false
446
+ for (const state of this._arrays.values()) {
447
+ const res = getIn(values, state.path)
448
+ if (!res.found || !Array.isArray(res.value)) continue
449
+ const target = Math.max(Math.min(res.value.length, state.maxItems), state.minItems)
450
+ if (state.ids.length === target) continue
451
+ while (state.ids.length > target) this._evictEntry(state, state.ids.pop())
452
+ while (state.ids.length < target) state.ids.push(state.nextId++)
453
+ resized = true
454
+ }
455
+ if (resized) this._build()
456
+
457
+ for (const f of this.fields) {
458
+ if (f.isButton) continue
459
+ const res = getIn(values, f.path)
460
+ if (f.isSelector) {
461
+ this._pickBranch(f, res)
462
+ continue
463
+ }
464
+ if (f.isSection) {
465
+ f.setValue(res.found && res.value !== null && res.value !== undefined)
466
+ continue
467
+ }
468
+ if (!res.found || res.value === undefined) continue
469
+ if (typeof f.setValue === 'function') f.setValue(res.value)
470
+ f.error = null
471
+ }
472
+ return this._recompute()
473
+ }
474
+
475
+ _pickBranch(selector, res) {
476
+ if (!res.found || !res.value || typeof res.value !== 'object') return
477
+ const obj = res.value
478
+ let best = -1
479
+ let bestScore = 0
480
+ selector._branchKeys.forEach((keys, i) => {
481
+ const score = keys.reduce(
482
+ (n, k) => n + (Object.prototype.hasOwnProperty.call(obj, k) ? 1 : 0),
483
+ 0
484
+ )
485
+ if (score > bestScore) {
486
+ bestScore = score
487
+ best = i
488
+ }
489
+ })
490
+ if (best >= 0) selector.setValue(best)
491
+ }
492
+
493
+ // Current errors, keyed by dotted path (live fields + array-level errors).
494
+ errors() {
495
+ const out = {}
496
+ for (const f of this.activeFields) if (f.error) out[f.path.join('.')] = f.error
497
+ for (const k of Object.keys(this._arrayErrors)) out[k] = this._arrayErrors[k]
498
+ return out
499
+ }
500
+
501
+ _gatedOff(field) {
502
+ for (const f of this.fields) {
503
+ if (f.isSection && !f.checked && isAncestor(f.path, field.path)) return true
504
+ }
505
+ return false
506
+ }
507
+
508
+ _autoCheckSection() {
509
+ const cur = this.ring.focused()
510
+ if (!cur || cur.isSection || cur.isButton || cur.isEmpty(cur.value())) return
511
+ for (const f of this.fields) {
512
+ if (f.isSection && !f.checked && isAncestor(f.path, cur.path)) f.setValue(true)
513
+ }
514
+ }
515
+
516
+ // Drop a removed entry's cached field instances so its values don't linger.
517
+ _evictEntry(state, id) {
518
+ const prefix = state.cache.concat('@' + id).join(SEP)
519
+ for (const k of this._cache.keys()) {
520
+ if (k === prefix || k.startsWith(prefix + SEP)) this._cache.delete(k)
521
+ }
522
+ }
523
+
524
+ _k(name, msg) {
525
+ return key.matches(msg, ...this.keys[name])
526
+ }
527
+
528
+ update(msg) {
529
+ if (msg && msg.type === 'form.validated') return this._onValidated(msg)
530
+ if (msg && msg.type === 'form.checkResult') return this._onCheckResult(msg)
531
+
532
+ // The terminal size: switches on scrolling and keeps focus visible.
533
+ if (msg && msg.type === 'resize') {
534
+ this.width = msg.width
535
+ this.height = msg.height
536
+ this._scrollToFocused()
537
+ return [this, null]
538
+ }
539
+
540
+ if (msg && msg.type === 'spinner.tick') {
541
+ // The submit-time form spinner takes precedence over a field spinner;
542
+ // they never run at once.
543
+ if (this._formValidating && this._formSpinner) {
544
+ const [, cmd] = this._formSpinner.update(msg)
545
+ return [this, this._formValidating ? cmd : null]
546
+ }
547
+ const f = this._validatingField()
548
+ if (f && f.spinner) {
549
+ const [, cmd] = f.spinner.update(msg)
550
+ return [this, f.validating ? cmd : null]
551
+ }
552
+ return [this, null]
553
+ }
554
+
555
+ if (msg && msg.type === 'key') {
556
+ // Cancel works even mid-validation; everything else waits.
557
+ if (this._k('cancel', msg)) {
558
+ this._cancelValidation()
559
+ this.cancelled = true
560
+ return [this, emit({ type: 'form.cancel' })]
561
+ }
562
+ if (this.busy) return [this, null]
563
+
564
+ // Deliberate submit (never plain enter — see defaultKeys).
565
+ if (this._k('submit', msg)) return this.submit()
566
+
567
+ // Manual scrolling when the body is taller than the screen.
568
+ if (this._k('scrollUp', msg)) return this._scrollBy(-this._bodyHeight())
569
+ if (this._k('scrollDown', msg)) return this._scrollBy(this._bodyHeight())
570
+
571
+ const cur = this.ring.focused()
572
+ // Enter confirms the focused field / activates a button / advances — it
573
+ // does NOT submit. (A control that wants enter itself keeps it.)
574
+ if (this._k('confirm', msg) && (!cur || !cur.wantsEnter())) {
575
+ return this._confirm()
576
+ }
577
+ }
578
+
579
+ if (this.busy) return [this, null]
580
+
581
+ const [ring, cmd] = this.ring.update(msg)
582
+ this.ring = ring
583
+ if (msg && msg.type === 'key') {
584
+ if (!this._k('next', msg) && !this._k('prev', msg)) this._autoCheckSection()
585
+ this._recompute() // a selector/controller may have changed
586
+ this._scrollToFocused() // keep the (possibly moved) focus in view
587
+ }
588
+ return [this, cmd]
589
+ }
590
+
591
+ _confirm() {
592
+ const cur = this.ring.focused()
593
+ if (!cur) return this._advance()
594
+ if (cur.isButton) return this._activate(cur)
595
+ if (cur.isSection || this._gatedOff(cur)) {
596
+ cur.error = null
597
+ return this._advance()
598
+ }
599
+
600
+ const v = cur.value()
601
+ const reqErr = cur.requiredError(v)
602
+ if (reqErr) {
603
+ cur.error = reqErr
604
+ return [this, null]
605
+ }
606
+ if (cur.isEmpty(v)) {
607
+ cur.error = null
608
+ return this._advance()
609
+ }
610
+ if (cur.isAsync()) {
611
+ return this._beginAsync(cur, cur.runValidator(v))
612
+ }
613
+ cur.error = cur.syncValidate(v)
614
+ if (cur.error) return [this, null]
615
+ return this._advance()
616
+ }
617
+
618
+ // Run an array add/remove button, then rebuild and refocus sensibly.
619
+ _activate(button) {
620
+ const a = button.action
621
+ if (!a) return [this, null]
622
+ const state = this._arrays.get(a.arrayKey)
623
+ if (!state) return [this, null]
624
+
625
+ if (a.kind === 'array.add') {
626
+ if (state.ids.length >= state.maxItems) return [this, null]
627
+ state.ids.push(state.nextId++)
628
+ this._build()
629
+ this._recompute()
630
+ this._focusPrefix(state.path.concat(String(state.ids.length - 1)))
631
+ } else if (a.kind === 'array.remove') {
632
+ const idx = state.ids.indexOf(a.id)
633
+ if (idx >= 0) {
634
+ this._evictEntry(state, a.id)
635
+ state.ids.splice(idx, 1)
636
+ }
637
+ this._build()
638
+ this._recompute()
639
+ this._focusAddOrPrefix(state)
640
+ }
641
+ this._scrollToFocused()
642
+ return [this, null]
643
+ }
644
+
645
+ _focusPrefix(prefix) {
646
+ const i = this.ring.items.findIndex((f) => startsWith(f.path, prefix))
647
+ if (i >= 0) this.ring.focus(i)
648
+ }
649
+
650
+ _focusAddOrPrefix(state) {
651
+ const add = this.ring.items.findIndex(
652
+ (f) =>
653
+ f.isButton &&
654
+ f.action &&
655
+ f.action.kind === 'array.add' &&
656
+ f.action.arrayKey === state.path.join(SEP)
657
+ )
658
+ if (add >= 0) this.ring.focus(add)
659
+ else this._focusPrefix(state.path)
660
+ }
661
+
662
+ _beginAsync(field, promise) {
663
+ field.error = null
664
+ field.validating = true
665
+ this.busy = true
666
+ const id = ++field._runId
667
+ const fieldKey = field.key
668
+
669
+ const tickCmd = field.startSpinner()
670
+ const checkCmd = () =>
671
+ Promise.resolve()
672
+ .then(() => promise)
673
+ .then((err) => ({ type: 'form.validated', id, key: fieldKey, error: err || null }))
674
+ .catch((e) => ({
675
+ type: 'form.validated',
676
+ id,
677
+ key: fieldKey,
678
+ error: (e && e.message) || 'check failed'
679
+ }))
680
+
681
+ return [this, batch(tickCmd, checkCmd)]
682
+ }
683
+
684
+ _onValidated(msg) {
685
+ const field = this.fields.find((f) => f.key === msg.key)
686
+ if (!field || !field.validating || msg.id !== field._runId) return [this, null]
687
+
688
+ field.validating = false
689
+ field.stopSpinner()
690
+ field.error = msg.error || null
691
+ this.busy = false
692
+
693
+ if (field.error) return [this, null]
694
+ field._asyncCheckedValue = field.value() // remember it passed; submit won't redo it
695
+ return this._advance()
696
+ }
697
+
698
+ _cancelValidation() {
699
+ const field = this._validatingField()
700
+ if (field) {
701
+ field._runId++
702
+ field.validating = false
703
+ field.stopSpinner()
704
+ }
705
+ if (this._formValidating) {
706
+ this._submitToken++ // strand any in-flight submit check
707
+ this._endFormValidation()
708
+ }
709
+ this.busy = false
710
+ }
711
+
712
+ _validatingField() {
713
+ return this.fields.find((f) => f.validating) || null
714
+ }
715
+
716
+ // Move focus to the next field (never submits — submit is its own key). Stays
717
+ // put on the last field; the user reaches buttons with `next`/tab and commits
718
+ // with the `submit` key.
719
+ _advance() {
720
+ const next = this.ring.index + 1
721
+ if (next < this.ring.items.length) this.ring.focus(next)
722
+ this._scrollToFocused()
723
+ return [this, null]
724
+ }
725
+
726
+ // Validate every live, focusable, non-button field plus array min-item counts;
727
+ // jump to the first error, else complete and emit form.submit with the values.
728
+ submit() {
729
+ this._arrayErrors = {}
730
+ let firstErr = null
731
+ for (const f of this.activeFields) {
732
+ if (f.isSection || f.isSelector || f.isButton || f.focusable === false || this._gatedOff(f)) {
733
+ f.error = null
734
+ continue
735
+ }
736
+ const e = f.syncValidate(f.value())
737
+ f.error = e
738
+ if (e && !firstErr) firstErr = f
739
+ }
740
+ if (firstErr) {
741
+ const i = this.ring.items.indexOf(firstErr)
742
+ if (i >= 0) this.ring.focus(i)
743
+ this._scrollToFocused()
744
+ return [this, null]
745
+ }
746
+ // Array minItems: only for live arrays.
747
+ for (const state of this._arrays.values()) {
748
+ if (state.activeWhen && !state.activeWhen()) continue
749
+ if (state.ids.length < state.minItems) {
750
+ const n = state.minItems
751
+ this._arrayErrors[state.path.join('.')] = `add at least ${n} item${n === 1 ? '' : 's'}`
752
+ }
753
+ }
754
+ if (Object.keys(this._arrayErrors).length) return [this, null]
755
+
756
+ // Async pass: re-run any dirty async validators that never ran (or whose
757
+ // value changed since). Serial, behind one form spinner — unless opted out.
758
+ if (this.validateAsyncOnSubmit) {
759
+ const queue = this.activeFields.filter(
760
+ (f) =>
761
+ !f.isSection &&
762
+ !f.isSelector &&
763
+ !f.isButton &&
764
+ f.focusable !== false &&
765
+ !this._gatedOff(f) &&
766
+ f.needsAsyncCheck()
767
+ )
768
+ if (queue.length) return this._beginFormValidation(queue)
769
+ }
770
+
771
+ this.submitted = true
772
+ return [this, emit({ type: 'form.submit', values: this.value() })]
773
+ }
774
+
775
+ // Build the form-level validating spinner from the theme (mirrors a field's).
776
+ _makeSpinner() {
777
+ const cfg = (this.theme && this.theme.spinner) || {}
778
+ const frames = typeof cfg.frames === 'string' ? FRAME_SETS[cfg.frames] : cfg.frames
779
+ return spinner.create({ frames, fps: cfg.fps })
780
+ }
781
+
782
+ // Enter the submit-time async pass: gate input, show the form spinner, and
783
+ // kick off the first check. Each check resolves to a 'form.checkResult' Msg.
784
+ _beginFormValidation(queue) {
785
+ this._formValidating = true
786
+ this.busy = true
787
+ this._queue = queue
788
+ this._queueAt = 0
789
+ this._submitToken++
790
+ this._formSpinner = this._makeSpinner()
791
+ return [this, batch(this._formSpinner.init(), this._checkCmd(0))]
792
+ }
793
+
794
+ // A Cmd that runs queue[i]'s async validator and reports the verdict. The
795
+ // generation token is captured at issue time so a cancel makes it stale.
796
+ _checkCmd(i) {
797
+ const field = this._queue[i]
798
+ const token = this._submitToken
799
+ const value = field.value()
800
+ const result = field.runValidator(value)
801
+ return () =>
802
+ Promise.resolve()
803
+ .then(() => result)
804
+ .then((err) => ({ type: 'form.checkResult', token, index: i, value, error: err || null }))
805
+ .catch((e) => ({
806
+ type: 'form.checkResult',
807
+ token,
808
+ index: i,
809
+ value,
810
+ error: (e && e.message) || 'check failed'
811
+ }))
812
+ }
813
+
814
+ _onCheckResult(msg) {
815
+ // Drop results from a cancelled or superseded submit.
816
+ if (!this._formValidating || msg.token !== this._submitToken) return [this, null]
817
+ const field = this._queue[msg.index]
818
+
819
+ if (msg.error) {
820
+ field.error = msg.error
821
+ this._endFormValidation()
822
+ const ri = this.ring.items.indexOf(field)
823
+ if (ri >= 0) this.ring.focus(ri)
824
+ this._scrollToFocused()
825
+ return [this, null]
826
+ }
827
+
828
+ field._asyncCheckedValue = msg.value // passed for this value
829
+ field.error = null
830
+ const next = msg.index + 1
831
+ if (next < this._queue.length) {
832
+ this._queueAt = next
833
+ return [this, this._checkCmd(next)] // spinner keeps ticking on its own
834
+ }
835
+
836
+ // All clear → submit.
837
+ this._endFormValidation()
838
+ this.submitted = true
839
+ return [this, emit({ type: 'form.submit', values: this.value() })]
840
+ }
841
+
842
+ _endFormValidation() {
843
+ this._formValidating = false
844
+ this.busy = false
845
+ this._formSpinner = null
846
+ this._queue = []
847
+ this._queueAt = 0
848
+ }
849
+
850
+ // The fixed top block (title + description), as lines.
851
+ _renderHead() {
852
+ const t = this.theme
853
+ const lines = []
854
+ if (this.title) lines.push(t.title(this.title), '')
855
+ if (this.description) lines.push(t.description(this.description), '')
856
+ return lines
857
+ }
858
+
859
+ // The scrollable body: every active layout item rendered to lines, plus a Map
860
+ // from each field to its [startLine, endLine] range (for follow-the-focus).
861
+ _renderBody() {
862
+ const t = this.theme
863
+ const lines = []
864
+ const range = new Map()
865
+ for (const item of this.layout) {
866
+ if (item.activeWhen && !item.activeWhen()) continue
867
+ const pad = ' '.repeat(item.indent || 0)
868
+ if (item.type === 'header') {
869
+ lines.push(pad + t.sectionTitle(item.title))
870
+ if (item.description) lines.push(pad + t.description(item.description))
871
+ continue
872
+ }
873
+ if (item.type === 'arrayHeader') {
874
+ lines.push(pad + t.sectionTitle(item.title))
875
+ if (item.description) lines.push(pad + t.description(item.description))
876
+ const err = this._arrayErrors[item.arrayKey.split(SEP).join('.')]
877
+ if (err) lines.push(pad + t.error(t.errorPrefix + err))
878
+ continue
879
+ }
880
+ if (item.type === 'entryHeader') {
881
+ lines.push(pad + t.help(`— ${item.title} ${item.index + 1} —`))
882
+ continue
883
+ }
884
+ const f = item.field
885
+ const start = lines.length
886
+ for (const ln of f.view().split('\n')) lines.push(pad + ln)
887
+ const menu = typeof f.menuView === 'function' ? f.menuView() : ''
888
+ if (menu) for (const ln of menu.split('\n')) lines.push(pad + ln)
889
+ range.set(f, [start, lines.length - 1])
890
+ lines.push('')
891
+ }
892
+ return { lines, range }
893
+ }
894
+
895
+ _renderFooter(scrollable) {
896
+ const t = this.theme
897
+ if (this._formValidating) {
898
+ const frame = this._formSpinner ? this._formSpinner.view() : ''
899
+ const n = this._queue.length
900
+ const k = Math.min(this._queueAt + 1, n)
901
+ return [t.validating(` ${frame ? frame + ' ' : ''}validating… ${k}/${n}`)]
902
+ }
903
+ if (this.submitted) return [t.help(' ✓ submitted')]
904
+ const chord = (a) => (a && a[0]) || ''
905
+ const parts = [
906
+ `${chord(this.keys.next)} move`,
907
+ `${chord(this.keys.confirm)} confirm`,
908
+ `${chord(this.keys.submit)} submit`,
909
+ `${chord(this.keys.cancel)} cancel`
910
+ ]
911
+ if (scrollable) parts.push('↕ scroll')
912
+ return [t.help(' ' + parts.join(' · '))]
913
+ }
914
+
915
+ // Extra rows a theme.frame() adds (border + vertical padding), measured by
916
+ // framing a one-line probe. 0 when there's no frame.
917
+ _frameRows() {
918
+ const frame = this.theme && this.theme.frame
919
+ if (typeof frame !== 'function') return 0
920
+ return Math.max(0, frame('x', { width: this.width }).split('\n').length - 1)
921
+ }
922
+
923
+ // Visible body height, or 0 when the size is unknown (render inline, no scroll).
924
+ // Reserves rows for the head, the footer, and any frame chrome.
925
+ _bodyHeight() {
926
+ if (!this.height) return 0
927
+ return Math.max(1, this.height - this._renderHead().length - 1 - this._frameRows())
928
+ }
929
+
930
+ // Scroll so the focused field's line range is visible, given the body height.
931
+ _scrollToFocused() {
932
+ const avail = this._bodyHeight()
933
+ if (!avail) return
934
+ const { lines, range } = this._renderBody()
935
+ const max = Math.max(0, lines.length - avail)
936
+ const cur = this.ring.focused()
937
+ const r = cur && range.get(cur)
938
+ if (r) {
939
+ if (r[0] < this._scroll) this._scroll = r[0]
940
+ else if (r[1] > this._scroll + avail - 1) this._scroll = r[1] - avail + 1
941
+ }
942
+ this._scroll = Math.min(Math.max(0, this._scroll), max)
943
+ }
944
+
945
+ _scrollBy(n) {
946
+ const avail = this._bodyHeight()
947
+ if (avail) {
948
+ const { lines } = this._renderBody()
949
+ const max = Math.max(0, lines.length - avail)
950
+ this._scroll = Math.min(Math.max(0, this._scroll + n), max)
951
+ }
952
+ return [this, null]
953
+ }
954
+
955
+ // Wrap the composed view in the theme's frame (border/background/padding), if
956
+ // any. Kept separate so the scroll math (which reserves the frame's rows) and
957
+ // the final paint agree.
958
+ _frame(inner) {
959
+ const frame = this.theme && this.theme.frame
960
+ return typeof frame === 'function'
961
+ ? frame(inner, { width: this.width, title: this.title })
962
+ : inner
963
+ }
964
+
965
+ view() {
966
+ const head = this._renderHead()
967
+ const { lines: body } = this._renderBody()
968
+ const avail = this._bodyHeight()
969
+
970
+ // Unknown size, or it all fits: render inline (footer not scrollable).
971
+ if (!avail || body.length <= avail) {
972
+ const footer = this._renderFooter(false)
973
+ return this._frame(head.concat(body, footer).join('\n'))
974
+ }
975
+
976
+ // Scroll: a fixed-height window of the body keeps the layout stable.
977
+ const max = Math.max(0, body.length - avail)
978
+ const start = Math.min(Math.max(0, this._scroll), max)
979
+ const window = body.slice(start, start + avail)
980
+ return this._frame(head.concat(window, this._renderFooter(true)).join('\n'))
981
+ }
982
+ }
983
+
984
+ function create(opts) {
985
+ return new Form(opts)
986
+ }
987
+
988
+ async function run(form, opts = {}) {
989
+ let result = null
990
+ class Runner {
991
+ init() {
992
+ return form.init()
993
+ }
994
+ update(msg) {
995
+ if (msg && msg.type === 'form.submit') {
996
+ result = msg.values
997
+ return [this, quit]
998
+ }
999
+ if (msg && msg.type === 'form.cancel') {
1000
+ result = null
1001
+ return [this, quit]
1002
+ }
1003
+ const [f, cmd] = form.update(msg)
1004
+ form = f
1005
+ return [this, cmd]
1006
+ }
1007
+ view() {
1008
+ return form.view()
1009
+ }
1010
+ }
1011
+ await new Program(new Runner(), opts).run()
1012
+ return result
1013
+ }
1014
+
1015
+ module.exports = { create, run, Form, fromDef }