bare-tui-form 0.0.1 → 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/fields/file.js ADDED
@@ -0,0 +1,129 @@
1
+ // file — a path field you can either type or browse.
2
+ //
3
+ // It wraps bare-tui's textinput (the editable path, and the field's string
4
+ // value) and lazily opens bare-tui's filepicker as an overlay — the same
5
+ // host-the-control-and-expose-an-overlay shape as fields/select.js. The picker
6
+ // is created the first time you open it, so a form full of file fields doesn't
7
+ // pull in bare-fs until one is actually browsed.
8
+ //
9
+ // Interaction (mirrors select's "space opens, never steals enter"):
10
+ // closed — type a path; SPACE on an empty input opens the picker
11
+ // open — ↑/↓ move, →/l descend, ↵ open/select, ⌫ up a dir, esc closes
12
+ // While open the field claims enter (wantsEnter) so the form's enter reaches the
13
+ // picker instead of advancing; on a pick the chosen path fills the input.
14
+ //
15
+ // `pick: 'file' | 'dir'` chooses the picker mode. `fs`/`path`/`cwd`/`height`/
16
+ // `showHidden` are forwarded to filepicker.create — `fs`/`path` let tests inject
17
+ // filepicker.mock with no real I/O.
18
+ const { textinput, filepicker, key } = require('bare-tui')
19
+ const { Field } = require('./base')
20
+
21
+ const openKey = key.binding({ keys: ['space'] })
22
+ const closeKey = key.binding({ keys: ['esc'] })
23
+
24
+ class FileField extends Field {
25
+ constructor(opts = {}) {
26
+ super(opts)
27
+ this.pick = opts.pick === 'dir' ? 'dir' : 'file'
28
+ this.input = textinput.create({
29
+ value: opts.value || '',
30
+ placeholder:
31
+ opts.placeholder ||
32
+ (this.pick === 'dir' ? 'choose a directory…' : 'type a path or browse…'),
33
+ prompt: opts.prompt ?? '> ',
34
+ charLimit: opts.charLimit || 0
35
+ })
36
+ // The base delegates focus()/blur()/`focused` to this.control — point it at
37
+ // the text input (the picker is a transient overlay, not the resting state).
38
+ this.control = this.input
39
+ this._pickerOpts = {
40
+ pick: this.pick,
41
+ cwd: opts.cwd,
42
+ height: opts.height,
43
+ showHidden: opts.showHidden,
44
+ fs: opts.fs,
45
+ path: opts.path
46
+ }
47
+ this.picker = null
48
+ }
49
+
50
+ value() {
51
+ return this.input.value
52
+ }
53
+
54
+ setValue(v) {
55
+ this.input.setValue(v === null || v === undefined ? '' : v)
56
+ return this
57
+ }
58
+
59
+ // While the picker is open, enter belongs to it (descend/select), so the form
60
+ // must not treat enter as confirm/advance.
61
+ get isOpen() {
62
+ return this.picker !== null
63
+ }
64
+
65
+ wantsEnter() {
66
+ return this.isOpen
67
+ }
68
+
69
+ update(msg) {
70
+ if (this.picker) return this._updateOpen(msg)
71
+
72
+ // Closed: SPACE on an empty input opens the browser; otherwise the text
73
+ // input handles the key (so you can type, and type spaces in a real path).
74
+ if (msg && msg.type === 'key' && key.matches(msg, openKey) && this.input.value === '') {
75
+ return this._open()
76
+ }
77
+ const [c, cmd] = this.input.update(msg)
78
+ this.input = c
79
+ if (this.error) this.error = null
80
+ return [this, cmd]
81
+ }
82
+
83
+ _updateOpen(msg) {
84
+ if (msg && msg.type === 'filepicker.select') {
85
+ this.setValue(msg.path)
86
+ this.picker = null
87
+ this.error = null
88
+ return [this, null]
89
+ }
90
+ if (msg && msg.type === 'key' && key.matches(msg, closeKey)) {
91
+ this.picker = null
92
+ return [this, null]
93
+ }
94
+ const [p, cmd] = this.picker.update(msg)
95
+ this.picker = p
96
+ return [this, cmd]
97
+ }
98
+
99
+ _open() {
100
+ this.picker = filepicker.create(this._pickerOpts)
101
+ // The first directory read is a Cmd; thread it up so the form/program runs it.
102
+ return [this, this.picker.init()]
103
+ }
104
+
105
+ // Tab-away (or any focus change) closes the overlay so it can't linger.
106
+ blur() {
107
+ this.picker = null
108
+ return super.blur()
109
+ }
110
+
111
+ menuView() {
112
+ return this.picker ? this.picker.view() : ''
113
+ }
114
+
115
+ controlView() {
116
+ const view = this.input.view()
117
+ if (this.isOpen) return view
118
+ const hint = this.theme.help(
119
+ this.pick === 'dir' ? ' space to browse folders' : ' space to browse'
120
+ )
121
+ return view + '\n' + hint
122
+ }
123
+ }
124
+
125
+ function file(opts) {
126
+ return new FileField(opts)
127
+ }
128
+
129
+ module.exports = { file, FileField }
package/fields/list.js ADDED
@@ -0,0 +1,158 @@
1
+ // list — an editable list of scalar text values (add / remove rows).
2
+ //
3
+ // For a repeatable value with no fixed option set — tags, waypoints, a flag the
4
+ // CLI accepts many times — where multiselect (a fixed enum) and array (a
5
+ // repeatable OBJECT subform) don't fit. Each row is a bare-tui textinput; the
6
+ // field is one focus stop in the form and manages its own row cursor:
7
+ //
8
+ // ↑/↓ move between rows
9
+ // ↵ add a new row below and focus it
10
+ // ⌫ on an EMPTY row, remove it (otherwise deletes a character)
11
+ // tab leave the field (the form's focus ring)
12
+ //
13
+ // value() is a plain array of the non-empty rows, in order — so a multi-valued
14
+ // CLI flag round-trips as ["a","b"], not a single space-joined string.
15
+ const { key, textinput } = require('bare-tui')
16
+ const { Field } = require('./base')
17
+
18
+ const keys = {
19
+ up: key.binding({ keys: ['up'] }),
20
+ down: key.binding({ keys: ['down'] }),
21
+ add: key.binding({ keys: ['enter'] }),
22
+ back: key.binding({ keys: ['backspace'] })
23
+ }
24
+
25
+ class ListField extends Field {
26
+ constructor(opts = {}) {
27
+ super(opts)
28
+ this.itemPlaceholder = opts.itemPlaceholder || 'type a value…'
29
+ this.min = Math.max(0, opts.minItems || 0)
30
+ this.max = opts.maxItems > 0 ? opts.maxItems : 0 // 0 = unbounded
31
+ this.cursor = 0
32
+ this._focused = false
33
+ const seed = Array.isArray(opts.value) ? opts.value.map(String) : []
34
+ // Always keep at least one (editable) row, even when minItems is 0; empty
35
+ // rows are dropped from value().
36
+ this.rows = (seed.length ? seed : ['']).map((v) => this._mkRow(v))
37
+ // No single wrapped control — the delegating methods are overridden below.
38
+ this.control = null
39
+ }
40
+
41
+ _mkRow(value) {
42
+ return textinput.create({ value, placeholder: this.itemPlaceholder, prompt: '' })
43
+ }
44
+
45
+ _floor() {
46
+ return Math.max(this.min, 1) // never collapse below one editable row
47
+ }
48
+
49
+ focus() {
50
+ this._focused = true
51
+ this._syncFocus()
52
+ return this
53
+ }
54
+
55
+ blur() {
56
+ this._focused = false
57
+ for (const r of this.rows) r.blur()
58
+ return this
59
+ }
60
+
61
+ get focused() {
62
+ return this._focused
63
+ }
64
+
65
+ // Enter belongs to the field (it adds a row) whenever it's focused, so the form
66
+ // doesn't treat enter as confirm/advance. Tab still moves to the next field.
67
+ wantsEnter() {
68
+ return this._focused
69
+ }
70
+
71
+ value() {
72
+ return this.rows.map((r) => r.value).filter((v) => v.trim() !== '')
73
+ }
74
+
75
+ setValue(values) {
76
+ const seed = Array.isArray(values) ? values.map(String) : []
77
+ this.rows = (seed.length ? seed : ['']).map((v) => this._mkRow(v))
78
+ this.cursor = 0
79
+ this._syncFocus()
80
+ return this
81
+ }
82
+
83
+ isEmpty(v) {
84
+ return !Array.isArray(v) || v.length === 0
85
+ }
86
+
87
+ update(msg) {
88
+ if (!this._focused || !msg || msg.type !== 'key') return [this, null]
89
+ const row = this.rows[this.cursor]
90
+
91
+ if (key.matches(msg, keys.up)) {
92
+ this._move(-1)
93
+ return [this, null]
94
+ }
95
+ if (key.matches(msg, keys.down)) {
96
+ this._move(1)
97
+ return [this, null]
98
+ }
99
+ if (key.matches(msg, keys.add)) {
100
+ this._addRow()
101
+ return [this, null]
102
+ }
103
+ // Backspace on an EMPTY row removes it; otherwise it edits (deletes a char).
104
+ if (
105
+ key.matches(msg, keys.back) &&
106
+ row &&
107
+ row.value === '' &&
108
+ this.rows.length > this._floor()
109
+ ) {
110
+ this._removeRow()
111
+ return [this, null]
112
+ }
113
+
114
+ const [c] = row.update(msg)
115
+ this.rows[this.cursor] = c
116
+ if (this.error) this.error = null
117
+ return [this, null]
118
+ }
119
+
120
+ _move(delta) {
121
+ this.cursor = Math.max(0, Math.min(this.cursor + delta, this.rows.length - 1))
122
+ this._syncFocus()
123
+ }
124
+
125
+ _addRow() {
126
+ if (this.max && this.rows.length >= this.max) return
127
+ this.rows.splice(this.cursor + 1, 0, this._mkRow(''))
128
+ this.cursor++
129
+ this._syncFocus()
130
+ }
131
+
132
+ _removeRow() {
133
+ this.rows.splice(this.cursor, 1)
134
+ this.cursor = Math.max(0, Math.min(this.cursor, this.rows.length - 1))
135
+ this._syncFocus()
136
+ if (this.error) this.error = null
137
+ }
138
+
139
+ _syncFocus() {
140
+ this.rows.forEach((r, i) => (this._focused && i === this.cursor ? r.focus() : r.blur()))
141
+ }
142
+
143
+ controlView() {
144
+ const t = this.theme
145
+ const lines = this.rows.map((r, i) => {
146
+ const pointer = this._focused && i === this.cursor ? '› ' : ' '
147
+ return pointer + r.view()
148
+ })
149
+ if (this._focused) lines.push(t.help(' ↵ add · ⌫ on an empty row removes'))
150
+ return lines.join('\n')
151
+ }
152
+ }
153
+
154
+ function list(opts) {
155
+ return new ListField(opts)
156
+ }
157
+
158
+ module.exports = { list, ListField, keys }
package/form.js CHANGED
@@ -47,6 +47,8 @@ const { radio } = require('./fields/radio')
47
47
  const { confirm } = require('./fields/confirm')
48
48
  const { multiselect } = require('./fields/multiselect')
49
49
  const { constant } = require('./fields/constant')
50
+ const { file } = require('./fields/file')
51
+ const { list } = require('./fields/list')
50
52
  const { action } = require('./fields/action')
51
53
  const { SectionToggleField } = require('./fields/section')
52
54
  const { Field } = require('./fields/base')
@@ -83,7 +85,18 @@ function mergeKeys(user) {
83
85
  }
84
86
  const SEP = '\u0000' // path/cache-key segment separator: a NUL, which never appears in a JSON key
85
87
 
86
- const builders = { text, textarea, number, select, radio, confirm, multiselect, constant }
88
+ const builders = {
89
+ text,
90
+ textarea,
91
+ number,
92
+ select,
93
+ radio,
94
+ confirm,
95
+ multiselect,
96
+ constant,
97
+ file,
98
+ list
99
+ }
87
100
 
88
101
  // Turn a leaf field definition object into a field instance; pass instances
89
102
  // through untouched. Object/variant/conditional/array groups are handled by
package/index.js CHANGED
@@ -32,6 +32,8 @@ const { select, SelectField } = require('./fields/select')
32
32
  const { radio, RadioField } = require('./fields/radio')
33
33
  const { confirm, ConfirmField } = require('./fields/confirm')
34
34
  const { multiselect, MultiSelectField } = require('./fields/multiselect')
35
+ const { file, FileField } = require('./fields/file')
36
+ const { list, ListField } = require('./fields/list')
35
37
  const { section, SectionToggleField } = require('./fields/section')
36
38
  const { constant, ConstField } = require('./fields/constant')
37
39
  const { action, ActionField } = require('./fields/action')
@@ -57,6 +59,8 @@ module.exports = {
57
59
  radio,
58
60
  confirm,
59
61
  multiselect,
62
+ file, // a type-or-browse path field (filepicker overlay); ui:widget 'file' / 'directory'
63
+ list, // an editable list of scalar text rows (array of string items)
60
64
  section, // the optional-section gate (usually produced by fromSchema, not hand-written)
61
65
  constant, // a fixed value (JSON Schema const); usually produced by fromSchema
62
66
  action, // a focusable button (array add/remove); usually produced by fromSchema
@@ -74,6 +78,8 @@ module.exports = {
74
78
  RadioField,
75
79
  ConfirmField,
76
80
  MultiSelectField,
81
+ FileField,
82
+ ListField,
77
83
  SectionToggleField,
78
84
  ConstField,
79
85
  ActionField
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-tui-form",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "A declarative form builder for bare-tui",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -23,7 +23,7 @@
23
23
  "README.md"
24
24
  ],
25
25
  "dependencies": {
26
- "bare-tui": "^0.0.2"
26
+ "bare-tui": "^0.0.3"
27
27
  },
28
28
  "devDependencies": {
29
29
  "bare-fs": "^4.7.1",
package/schema.js CHANGED
@@ -18,6 +18,7 @@
18
18
  // integer / boolean
19
19
  // enum → select (single choice)
20
20
  // array + items.enum → multiselect (choose many)
21
+ // array + items: string → an editable list of scalar text rows (add/remove)
21
22
  // array + items: object → a repeatable subform the user grows/shrinks
22
23
  // (minItems / maxItems) (each entry is the item object's fields)
23
24
  // required[] → required fields
@@ -28,9 +29,9 @@
28
29
  // pattern / format
29
30
  // minimum / maximum → number bounds
30
31
  //
31
- // Not yet: arrays of primitives or free-form items, nested arrays, allOf (schema
32
- // merge), $ref. Unsupported shapes throw a clear error rather than silently
33
- // producing the wrong form.
32
+ // Not yet: arrays of non-string primitives (number/boolean items), nested arrays,
33
+ // allOf (schema merge), $ref. Unsupported shapes throw a clear error rather than
34
+ // silently producing the wrong form.
34
35
  //
35
36
  // SECURITY: a JSON Schema is frequently untrusted input — produced by an LLM or
36
37
  // received from a peer — and this function is the boundary where it becomes
@@ -201,6 +202,18 @@ function applyWidget(def, ui, ctx, name, prop) {
201
202
  if (def.type !== 'select') return widgetMismatch(def, ctx, name, widget, 'enum')
202
203
  def.type = 'radio' // radio takes the same options/selected as select
203
204
  return def
205
+ case 'file':
206
+ case 'directory': {
207
+ if (def.type !== 'text') return widgetMismatch(def, ctx, name, widget, 'string')
208
+ def.type = 'file' // a type-or-browse path field; opens a filepicker overlay
209
+ def.pick = widget === 'directory' ? 'dir' : 'file'
210
+ const cwd = uiGet(ui, 'cwd')
211
+ if (typeof cwd === 'string') def.cwd = harden.cleanText(cwd, ctx.limits.maxTextLength)
212
+ const height = harden.safeNumber(uiGet(ui, 'height'))
213
+ if (height) def.height = Math.min(height, 100)
214
+ if (uiGet(ui, 'showHidden')) def.showHidden = true
215
+ return def
216
+ }
204
217
  case 'select':
205
218
  case 'checkbox':
206
219
  case 'checkboxes':
@@ -445,8 +458,8 @@ function withoutNested(sub) {
445
458
  return { properties: sub.properties || {}, required: sub.required || [] }
446
459
  }
447
460
 
448
- // array → multiselect (items.enum) or a repeatable object subform (items is an
449
- // object). Other array shapes (primitive items, nested arrays) are refused.
461
+ // array → multiselect (items.enum), an editable scalar list (string items), or a
462
+ // repeatable object subform (object items). Nested arrays are refused.
450
463
  // `ui` supplies item presentation (ui.items) and add/remove gating.
451
464
  function arrayProperty(name, base, prop, ctx, depth, ui = {}) {
452
465
  const items = prop.items || {}
@@ -462,6 +475,27 @@ function arrayProperty(name, base, prop, ctx, depth, ui = {}) {
462
475
  }
463
476
  }
464
477
 
478
+ // string (or untyped) primitive items → an editable list of scalar text rows.
479
+ if (!items.properties && (typeOf(items) === 'string' || typeOf(items) === undefined)) {
480
+ countLeaf(ctx)
481
+ const cap = ctx.limits.maxArrayItems
482
+ const maxItems = Math.min(harden.safeNumber(prop.maxItems) ?? cap, cap)
483
+ const minItems = Math.min(Math.max(harden.safeNumber(prop.minItems) ?? 0, 0), maxItems)
484
+ const def = Array.isArray(prop.default) ? prop.default.slice(0, cap) : []
485
+ const placeholder = uiGet(ui, 'placeholder')
486
+ return {
487
+ ...base,
488
+ type: 'list',
489
+ value: def.map((v) => harden.cleanText(String(v), ctx.limits.maxTextLength)),
490
+ minItems,
491
+ maxItems,
492
+ itemPlaceholder:
493
+ typeof placeholder === 'string'
494
+ ? harden.cleanText(placeholder, ctx.limits.maxTextLength)
495
+ : undefined
496
+ }
497
+ }
498
+
465
499
  if (items && typeof items === 'object' && (items.properties || typeOf(items) === 'object')) {
466
500
  if (ctx.inArray) {
467
501
  throw new Error(`fromSchema: nested arrays ("${name}") are not supported yet`)