bare-tui-form 0.0.0 → 0.0.2

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