bare-tui 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.
Files changed (48) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +13 -0
  3. package/README.md +261 -0
  4. package/ansi.js +34 -0
  5. package/commands.js +67 -0
  6. package/components/autocomplete.js +208 -0
  7. package/components/checkbox.js +68 -0
  8. package/components/filepicker.js +232 -0
  9. package/components/focus.js +129 -0
  10. package/components/help.js +117 -0
  11. package/components/list.js +172 -0
  12. package/components/paginator.js +111 -0
  13. package/components/progress.js +78 -0
  14. package/components/radio.js +113 -0
  15. package/components/select.js +165 -0
  16. package/components/spinner.js +70 -0
  17. package/components/stopwatch.js +88 -0
  18. package/components/table.js +130 -0
  19. package/components/textarea.js +279 -0
  20. package/components/textinput.js +135 -0
  21. package/components/timer.js +91 -0
  22. package/components/viewport.js +104 -0
  23. package/docs/autocomplete.md +80 -0
  24. package/docs/checkbox.md +43 -0
  25. package/docs/filepicker.md +80 -0
  26. package/docs/focus.md +68 -0
  27. package/docs/help.md +45 -0
  28. package/docs/list.md +47 -0
  29. package/docs/paginator.md +43 -0
  30. package/docs/progress.md +36 -0
  31. package/docs/radio.md +49 -0
  32. package/docs/select.md +54 -0
  33. package/docs/spinner.md +54 -0
  34. package/docs/stopwatch.md +52 -0
  35. package/docs/table.md +52 -0
  36. package/docs/textarea.md +43 -0
  37. package/docs/textinput.md +51 -0
  38. package/docs/timer.md +57 -0
  39. package/docs/viewport.md +39 -0
  40. package/index.d.ts +1 -0
  41. package/index.js +71 -0
  42. package/key.js +29 -0
  43. package/messages.js +62 -0
  44. package/mouse.js +102 -0
  45. package/package.json +59 -1
  46. package/program.js +387 -0
  47. package/renderer.js +67 -0
  48. package/style.js +473 -0
@@ -0,0 +1,279 @@
1
+ // textarea — a multi-line editable field.
2
+ //
3
+ // The sibling of textinput: it holds logical lines and a (row, col) cursor,
4
+ // soft-wraps each line to `width` for display, and scrolls a `height`-row
5
+ // window. Editing splits/merges lines (enter/backspace); arrows move by
6
+ // character horizontally and by *visual* row vertically (so up/down feel right
7
+ // inside wrapped text). Like textinput it only consumes keys when focused and
8
+ // draws its own reverse-video cursor.
9
+ //
10
+ // const ta = textarea.create({ width: 60, height: 10, placeholder: '…' }).focus()
11
+ const ansi = require('../ansi')
12
+
13
+ const dim = (s) => ansi.modifierDim + s + ansi.modifierReset
14
+ const reverse = (s) => ansi.modifierReverse + s + ansi.modifierNotReverse
15
+
16
+ class TextArea {
17
+ constructor(opts = {}) {
18
+ this.width = Math.max(1, opts.width || 40)
19
+ this.height = Math.max(1, opts.height || 6)
20
+ this.placeholder = opts.placeholder || ''
21
+ this.charLimit = opts.charLimit || 0 // 0 = unlimited
22
+ this.focused = !!opts.focused
23
+
24
+ this.lines = String(opts.value || '').split('\n')
25
+ this.row = this.lines.length - 1
26
+ this.col = this.lines[this.row].length
27
+ this.yOffset = 0
28
+ }
29
+
30
+ get value() {
31
+ return this.lines.join('\n')
32
+ }
33
+
34
+ // Total character count including the newlines between lines.
35
+ get length() {
36
+ let n = this.lines.length - 1
37
+ for (const line of this.lines) n += line.length
38
+ return n
39
+ }
40
+
41
+ focus() {
42
+ this.focused = true
43
+ return this
44
+ }
45
+
46
+ blur() {
47
+ this.focused = false
48
+ return this
49
+ }
50
+
51
+ setValue(v) {
52
+ this.lines = String(v).split('\n')
53
+ if (this.lines.length === 0) this.lines = ['']
54
+ this.row = this.lines.length - 1
55
+ this.col = this.lines[this.row].length
56
+ this._clamp()
57
+ return this
58
+ }
59
+
60
+ setSize(width, height) {
61
+ if (width) this.width = Math.max(1, width)
62
+ if (height) this.height = Math.max(1, height)
63
+ return this
64
+ }
65
+
66
+ reset() {
67
+ this.lines = ['']
68
+ this.row = 0
69
+ this.col = 0
70
+ this.yOffset = 0
71
+ return this
72
+ }
73
+
74
+ update(msg) {
75
+ if (!this.focused || !msg || msg.type !== 'key') return [this, null]
76
+
77
+ if (msg.is('left', 'ctrl+b')) this._left()
78
+ else if (msg.is('right', 'ctrl+f')) this._right()
79
+ else if (msg.is('up')) this._vertical(-1)
80
+ else if (msg.is('down')) this._vertical(1)
81
+ else if (msg.is('home', 'ctrl+a')) this._home()
82
+ else if (msg.is('end', 'ctrl+e')) this._end()
83
+ else if (msg.is('enter')) this._newline()
84
+ else if (msg.is('backspace')) this._backspace()
85
+ else if (msg.is('delete')) this._delete()
86
+ else this._insert(msg)
87
+
88
+ return [this, null]
89
+ }
90
+
91
+ // ── editing ────────────────────────────────────────────────────────────
92
+
93
+ _line() {
94
+ return this.lines[this.row]
95
+ }
96
+
97
+ _left() {
98
+ if (this.col > 0) this.col--
99
+ else if (this.row > 0) {
100
+ this.row--
101
+ this.col = this._line().length
102
+ }
103
+ }
104
+
105
+ _right() {
106
+ if (this.col < this._line().length) this.col++
107
+ else if (this.row < this.lines.length - 1) {
108
+ this.row++
109
+ this.col = 0
110
+ }
111
+ }
112
+
113
+ _home() {
114
+ const { vrow, rows } = this._cursor()
115
+ this.col = rows[vrow].start
116
+ }
117
+
118
+ _end() {
119
+ const { vrow, rows } = this._cursor()
120
+ this.col = rows[vrow].start + rows[vrow].text.length
121
+ }
122
+
123
+ _newline() {
124
+ if (this.charLimit && this.length >= this.charLimit) return
125
+ const line = this._line()
126
+ this.lines.splice(this.row, 1, line.slice(0, this.col), line.slice(this.col))
127
+ this.row++
128
+ this.col = 0
129
+ }
130
+
131
+ _backspace() {
132
+ const line = this._line()
133
+ if (this.col > 0) {
134
+ this.lines[this.row] = line.slice(0, this.col - 1) + line.slice(this.col)
135
+ this.col--
136
+ } else if (this.row > 0) {
137
+ const prev = this.lines[this.row - 1]
138
+ this.col = prev.length
139
+ this.lines[this.row - 1] = prev + line
140
+ this.lines.splice(this.row, 1)
141
+ this.row--
142
+ }
143
+ }
144
+
145
+ _delete() {
146
+ const line = this._line()
147
+ if (this.col < line.length) {
148
+ this.lines[this.row] = line.slice(0, this.col) + line.slice(this.col + 1)
149
+ } else if (this.row < this.lines.length - 1) {
150
+ this.lines[this.row] = line + this.lines[this.row + 1]
151
+ this.lines.splice(this.row + 1, 1)
152
+ }
153
+ }
154
+
155
+ _insert(msg) {
156
+ const ch = msg.sequence
157
+ const printable =
158
+ !msg.ctrl &&
159
+ !msg.meta &&
160
+ typeof ch === 'string' &&
161
+ ch.length === 1 &&
162
+ ch >= ' ' &&
163
+ ch !== '\x7f'
164
+ if (!printable) return
165
+ if (this.charLimit && this.length >= this.charLimit) return
166
+
167
+ const line = this._line()
168
+ this.lines[this.row] = line.slice(0, this.col) + ch + line.slice(this.col)
169
+ this.col++
170
+ }
171
+
172
+ _vertical(delta) {
173
+ const { vrow, vcol, rows } = this._cursor()
174
+ const target = Math.max(0, Math.min(vrow + delta, rows.length - 1))
175
+ if (target === vrow) return
176
+ const r = rows[target]
177
+ this.row = r.line
178
+ this.col = r.start + Math.min(vcol, r.text.length)
179
+ }
180
+
181
+ _clamp() {
182
+ this.row = Math.max(0, Math.min(this.row, this.lines.length - 1))
183
+ this.col = Math.max(0, Math.min(this.col, this._line().length))
184
+ }
185
+
186
+ // ── wrapping / cursor mapping ────────────────────────────────────────────
187
+
188
+ // Visual rows: each logical line char-wrapped to width. A line whose length
189
+ // is an exact multiple of width gets a trailing empty row so the cursor has
190
+ // somewhere to sit at the wrap boundary.
191
+ _visualRows() {
192
+ const w = this.width
193
+ const rows = []
194
+ for (let l = 0; l < this.lines.length; l++) {
195
+ const line = this.lines[l]
196
+ if (line.length === 0) {
197
+ rows.push({ line: l, start: 0, text: '' })
198
+ continue
199
+ }
200
+ for (let s = 0; s < line.length; s += w) {
201
+ rows.push({ line: l, start: s, text: line.slice(s, s + w) })
202
+ }
203
+ if (line.length % w === 0) rows.push({ line: l, start: line.length, text: '' })
204
+ }
205
+ return rows
206
+ }
207
+
208
+ // Locate the cursor among the visual rows.
209
+ _cursor() {
210
+ const rows = this._visualRows()
211
+ let last = 0
212
+ for (let i = 0; i < rows.length; i++) {
213
+ const r = rows[i]
214
+ if (r.line !== this.row) continue
215
+ last = i
216
+ if (this.col >= r.start && this.col < r.start + r.text.length) {
217
+ return { vrow: i, vcol: this.col - r.start, rows }
218
+ }
219
+ if (r.text.length === 0 && this.col === r.start) {
220
+ return { vrow: i, vcol: 0, rows }
221
+ }
222
+ }
223
+ const r = rows[last]
224
+ return { vrow: last, vcol: this.col - r.start, rows }
225
+ }
226
+
227
+ // ── rendering ────────────────────────────────────────────────────────────
228
+
229
+ view() {
230
+ const empty = this.lines.length === 1 && this.lines[0] === ''
231
+ const { vrow, vcol, rows } = this._cursor()
232
+
233
+ // Scroll the window to keep the cursor visible.
234
+ if (vrow < this.yOffset) this.yOffset = vrow
235
+ else if (vrow >= this.yOffset + this.height) this.yOffset = vrow - this.height + 1
236
+ const maxOffset = Math.max(0, rows.length - this.height)
237
+ this.yOffset = Math.max(0, Math.min(this.yOffset, maxOffset))
238
+
239
+ const out = []
240
+ for (let i = 0; i < this.height; i++) {
241
+ const idx = this.yOffset + i
242
+ if (empty && idx === 0) {
243
+ out.push(this._placeholderRow())
244
+ } else if (!rows[idx]) {
245
+ out.push(' '.repeat(this.width))
246
+ } else {
247
+ const onCursor = this.focused && idx === vrow
248
+ out.push(this._renderRow(rows[idx].text, onCursor ? vcol : -1))
249
+ }
250
+ }
251
+ return out.join('\n')
252
+ }
253
+
254
+ _renderRow(text, cursorCol) {
255
+ if (cursorCol < 0) return text.padEnd(this.width)
256
+ const at = text[cursorCol] ?? ' '
257
+ const line = text.slice(0, cursorCol) + reverse(at) + text.slice(cursorCol + 1)
258
+ const visible = Math.max(text.length, cursorCol + 1)
259
+ return line + ' '.repeat(Math.max(0, this.width - visible))
260
+ }
261
+
262
+ _placeholderRow() {
263
+ const ph = this.placeholder
264
+ if (!this.focused) return this._pad(dim(ph), ph.length)
265
+ const head = ph.slice(0, 1) || ' '
266
+ const body = reverse(head) + dim(ph.slice(1))
267
+ return this._pad(body, Math.max(ph.length, 1))
268
+ }
269
+
270
+ _pad(styled, visibleLen) {
271
+ return styled + ' '.repeat(Math.max(0, this.width - visibleLen))
272
+ }
273
+ }
274
+
275
+ function create(opts) {
276
+ return new TextArea(opts)
277
+ }
278
+
279
+ module.exports = { create, TextArea }
@@ -0,0 +1,135 @@
1
+ // textinput — a single-line editable field.
2
+ //
3
+ // This is the reference for an *input-driven* component: no Cmds, just state
4
+ // folded from key Msgs. It only reacts when focused, so a parent can host
5
+ // several and route keys to whichever has focus:
6
+ //
7
+ // class Form {
8
+ // constructor () {
9
+ // this.name = textinput.create({ placeholder: 'name' }).focus()
10
+ // }
11
+ // update (msg) {
12
+ // const [f, cmd] = this.name.update(msg)
13
+ // this.name = f
14
+ // return [this, cmd]
15
+ // }
16
+ // view () { return this.name.view() }
17
+ // }
18
+ //
19
+ // Because the Program hides the real terminal cursor, the field draws its own
20
+ // as a reverse-video cell.
21
+ const ansi = require('../ansi')
22
+
23
+ const dim = (s) => ansi.modifierDim + s + ansi.modifierReset
24
+ const reverse = (s) => ansi.modifierReverse + s + ansi.modifierNotReverse
25
+
26
+ class TextInput {
27
+ constructor(opts = {}) {
28
+ this.value = opts.value || ''
29
+ this.placeholder = opts.placeholder || ''
30
+ this.prompt = opts.prompt || ''
31
+ this.charLimit = opts.charLimit || 0 // 0 = unlimited
32
+ this.echoMode = opts.echoMode || 'normal' // 'normal' | 'password'
33
+ this.maskChar = opts.maskChar || '•'
34
+ this.focused = !!opts.focused
35
+ this.cursor = this.value.length // insertion point, 0..value.length
36
+ }
37
+
38
+ focus() {
39
+ this.focused = true
40
+ return this
41
+ }
42
+
43
+ blur() {
44
+ this.focused = false
45
+ return this
46
+ }
47
+
48
+ setValue(v) {
49
+ v = String(v)
50
+ this.value = this.charLimit ? v.slice(0, this.charLimit) : v
51
+ this.cursor = Math.min(this.cursor, this.value.length)
52
+ return this
53
+ }
54
+
55
+ reset() {
56
+ this.value = ''
57
+ this.cursor = 0
58
+ return this
59
+ }
60
+
61
+ update(msg) {
62
+ // Only a focused field consumes keys; everything else is a no-op so the
63
+ // parent can broadcast Msgs freely.
64
+ if (!this.focused || !msg || msg.type !== 'key') return [this, null]
65
+
66
+ if (msg.is('left', 'ctrl+b')) {
67
+ this.cursor = Math.max(0, this.cursor - 1)
68
+ } else if (msg.is('right', 'ctrl+f')) {
69
+ this.cursor = Math.min(this.value.length, this.cursor + 1)
70
+ } else if (msg.is('home', 'ctrl+a')) {
71
+ this.cursor = 0
72
+ } else if (msg.is('end', 'ctrl+e')) {
73
+ this.cursor = this.value.length
74
+ } else if (msg.is('backspace')) {
75
+ if (this.cursor > 0) {
76
+ this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor)
77
+ this.cursor--
78
+ }
79
+ } else if (msg.is('delete')) {
80
+ if (this.cursor < this.value.length) {
81
+ this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1)
82
+ }
83
+ } else {
84
+ this._insert(msg)
85
+ }
86
+
87
+ return [this, null]
88
+ }
89
+
90
+ // Insert a single printable character at the cursor. We key off the decoded
91
+ // sequence (not name) so case and punctuation come through verbatim, and skip
92
+ // control bytes, chorded keys, and DEL.
93
+ _insert(msg) {
94
+ const ch = msg.sequence
95
+ const printable =
96
+ !msg.ctrl &&
97
+ !msg.meta &&
98
+ typeof ch === 'string' &&
99
+ ch.length === 1 &&
100
+ ch >= ' ' &&
101
+ ch !== '\x7f'
102
+ if (!printable) return
103
+ if (this.charLimit && this.value.length >= this.charLimit) return
104
+
105
+ this.value = this.value.slice(0, this.cursor) + ch + this.value.slice(this.cursor)
106
+ this.cursor++
107
+ }
108
+
109
+ _display() {
110
+ if (this.echoMode === 'password') return this.maskChar.repeat(this.value.length)
111
+ return this.value
112
+ }
113
+
114
+ view() {
115
+ // Empty: dim placeholder, with the cursor over its first cell when focused.
116
+ if (this.value.length === 0) {
117
+ if (!this.focused) return this.prompt + dim(this.placeholder)
118
+ const head = this.placeholder.slice(0, 1) || ' '
119
+ return this.prompt + reverse(head) + dim(this.placeholder.slice(1))
120
+ }
121
+
122
+ const text = this._display()
123
+ if (!this.focused) return this.prompt + text
124
+
125
+ // Draw the cursor as a reverse cell; a trailing space when at end-of-line.
126
+ const at = text.slice(this.cursor, this.cursor + 1) || ' '
127
+ return this.prompt + text.slice(0, this.cursor) + reverse(at) + text.slice(this.cursor + 1)
128
+ }
129
+ }
130
+
131
+ function create(opts) {
132
+ return new TextInput(opts)
133
+ }
134
+
135
+ module.exports = { create, TextInput }
@@ -0,0 +1,91 @@
1
+ // timer — counts a duration down to zero.
2
+ //
3
+ // Cmd-driven like the stopwatch. Each accepted 'timer.tick' decrements
4
+ // `timeout`; when it hits zero the timer stops and emits a one-shot
5
+ // { type: 'timer.timeout', id } Msg so the app can react.
6
+ //
7
+ // this.timer = timer.create({ timeout: 10000 }) // 10s
8
+ // init() { return this.timer.start() }
9
+ // update(msg) {
10
+ // if (msg.type === 'timer.timeout') { ...done... }
11
+ // const [t, cmd] = this.timer.update(msg); this.timer = t; return [this, cmd]
12
+ // }
13
+ // view() { return this.timer.view() } // "00:09"
14
+ const { tick } = require('../commands')
15
+ const { format } = require('./stopwatch')
16
+
17
+ let nextId = 1
18
+
19
+ class Timer {
20
+ constructor(opts = {}) {
21
+ this.interval = opts.interval || 1000
22
+ this.timeout = opts.timeout ?? 0 // ms remaining
23
+ this.initial = this.timeout
24
+ this.running = false
25
+ this.id = nextId++
26
+ this.tag = 0
27
+ }
28
+
29
+ get timedOut() {
30
+ return this.timeout <= 0
31
+ }
32
+
33
+ _tick() {
34
+ const id = this.id
35
+ const tag = this.tag
36
+ return tick(this.interval, () => ({ type: 'timer.tick', id, tag }))
37
+ }
38
+
39
+ start() {
40
+ if (this.running || this.timeout <= 0) return null
41
+ this.running = true
42
+ return this._tick()
43
+ }
44
+
45
+ stop() {
46
+ this.running = false
47
+ this.tag++
48
+ return null
49
+ }
50
+
51
+ toggle() {
52
+ return this.running ? this.stop() : this.start()
53
+ }
54
+
55
+ reset() {
56
+ this.timeout = this.initial
57
+ return null // a running timer keeps counting, now from the initial duration
58
+ }
59
+
60
+ update(msg) {
61
+ if (
62
+ !msg ||
63
+ msg.type !== 'timer.tick' ||
64
+ msg.id !== this.id ||
65
+ msg.tag !== this.tag ||
66
+ !this.running
67
+ ) {
68
+ return [this, null]
69
+ }
70
+
71
+ this.timeout = Math.max(0, this.timeout - this.interval)
72
+ this.tag++
73
+
74
+ if (this.timeout === 0) {
75
+ this.running = false
76
+ const id = this.id
77
+ return [this, () => ({ type: 'timer.timeout', id })]
78
+ }
79
+ return [this, this._tick()]
80
+ }
81
+
82
+ view() {
83
+ return format(this.timeout)
84
+ }
85
+ }
86
+
87
+ function create(opts) {
88
+ return new Timer(opts)
89
+ }
90
+
91
+ module.exports = { create, Timer }
@@ -0,0 +1,104 @@
1
+ // viewport — a scrollable window over content taller than the available space.
2
+ //
3
+ // Holds its content as lines and renders a fixed `height`-row window starting
4
+ // at `yOffset`. It always emits exactly `height` lines (padding short content),
5
+ // which keeps the surrounding layout stable for the diff renderer. Long lines
6
+ // are truncated to `width`.
7
+ //
8
+ // Useful on its own (a pager, a scrollable log/help panel) and as the scrolling
9
+ // concept the list component mirrors.
10
+ const key = require('../key')
11
+
12
+ // Scroll keymap, expressed as reusable bindings.
13
+ const keys = {
14
+ up: key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } }),
15
+ down: key.binding({ keys: ['down', 'j'], help: { key: '↓/j', desc: 'down' } }),
16
+ pageUp: key.binding({ keys: ['pageup', 'b'], help: { key: 'pgup', desc: 'page up' } }),
17
+ pageDown: key.binding({ keys: ['pagedown', 'f'], help: { key: 'pgdn', desc: 'page down' } }),
18
+ halfUp: key.binding({ keys: ['ctrl+u'] }),
19
+ halfDown: key.binding({ keys: ['ctrl+d'] }),
20
+ top: key.binding({ keys: ['home'], help: { key: 'home', desc: 'top' } }),
21
+ bottom: key.binding({ keys: ['end'], help: { key: 'end', desc: 'bottom' } })
22
+ }
23
+
24
+ class Viewport {
25
+ constructor(opts = {}) {
26
+ this.width = opts.width || 0 // 0 = no horizontal truncation
27
+ this.height = opts.height || 0
28
+ this.yOffset = 0
29
+ this.lines = []
30
+ }
31
+
32
+ setContent(content) {
33
+ this.lines = String(content).split('\n')
34
+ this._clamp()
35
+ return this
36
+ }
37
+
38
+ get maxOffset() {
39
+ return Math.max(0, this.lines.length - this.height)
40
+ }
41
+ get atTop() {
42
+ return this.yOffset <= 0
43
+ }
44
+ get atBottom() {
45
+ return this.yOffset >= this.maxOffset
46
+ }
47
+ get scrollPercent() {
48
+ if (this.lines.length <= this.height) return 1
49
+ return this.yOffset / this.maxOffset
50
+ }
51
+
52
+ setYOffset(n) {
53
+ this.yOffset = n
54
+ this._clamp()
55
+ return this
56
+ }
57
+ scrollUp(n = 1) {
58
+ return this.setYOffset(this.yOffset - n)
59
+ }
60
+ scrollDown(n = 1) {
61
+ return this.setYOffset(this.yOffset + n)
62
+ }
63
+ gotoTop() {
64
+ return this.setYOffset(0)
65
+ }
66
+ gotoBottom() {
67
+ return this.setYOffset(this.maxOffset)
68
+ }
69
+
70
+ _clamp() {
71
+ this.yOffset = Math.max(0, Math.min(this.yOffset, this.maxOffset))
72
+ }
73
+
74
+ update(msg) {
75
+ if (!msg || msg.type !== 'key') return [this, null]
76
+ const h = this.height || 1
77
+
78
+ if (key.matches(msg, keys.up)) this.scrollUp(1)
79
+ else if (key.matches(msg, keys.down)) this.scrollDown(1)
80
+ else if (key.matches(msg, keys.pageUp)) this.scrollUp(h)
81
+ else if (key.matches(msg, keys.pageDown)) this.scrollDown(h)
82
+ else if (key.matches(msg, keys.halfUp)) this.scrollUp(Math.ceil(h / 2))
83
+ else if (key.matches(msg, keys.halfDown)) this.scrollDown(Math.ceil(h / 2))
84
+ else if (key.matches(msg, keys.top)) this.gotoTop()
85
+ else if (key.matches(msg, keys.bottom)) this.gotoBottom()
86
+
87
+ return [this, null]
88
+ }
89
+
90
+ view() {
91
+ const out = []
92
+ for (let i = 0; i < this.height; i++) {
93
+ const line = this.lines[this.yOffset + i] ?? ''
94
+ out.push(this.width > 0 ? line.slice(0, this.width) : line)
95
+ }
96
+ return out.join('\n')
97
+ }
98
+ }
99
+
100
+ function create(opts) {
101
+ return new Viewport(opts)
102
+ }
103
+
104
+ module.exports = { create, Viewport, keys }
@@ -0,0 +1,80 @@
1
+ # autocomplete
2
+
3
+ A single-line input with a filtered suggestion menu — a slash-command palette.
4
+ Input-driven (no commands), built as a composition over
5
+ [textinput](textinput.md): keys fold into an embedded field, and a dropdown
6
+ offers the suggestions that match what's been typed. The menu opens only once
7
+ the line begins with a **trigger** character (default `/`), so ordinary prose in
8
+ the same field never pops a menu.
9
+
10
+ [← all components](../README.md#components)
11
+
12
+ ## Usage
13
+
14
+ ```js
15
+ const { autocomplete, key } = require('bare-tui')
16
+
17
+ class Prompt {
18
+ constructor() {
19
+ this.input = autocomplete
20
+ .create({
21
+ prompt: '> ',
22
+ placeholder: 'message, or / for commands',
23
+ suggestions: [
24
+ { name: 'help', desc: 'show help' },
25
+ { name: 'clear', desc: 'clear the screen' },
26
+ { name: 'quit', desc: 'exit' }
27
+ ]
28
+ })
29
+ .focus()
30
+ }
31
+ update(msg) {
32
+ // Enter submits — the highlighted command when the menu is open (so it
33
+ // both completes and runs), otherwise the typed line. Tab just completes.
34
+ if (key.matches(msg, 'enter')) {
35
+ const s = this.input.open && this.input.selectedSuggestion()
36
+ this.submit(s ? '/' + s.name : this.input.value)
37
+ this.input.reset()
38
+ return [this, null]
39
+ }
40
+ const [f, cmd] = this.input.update(msg)
41
+ this.input = f
42
+ return [this, cmd]
43
+ }
44
+ view() {
45
+ // Render the menu wherever it fits — above a bottom-anchored prompt here.
46
+ return [this.input.menuView(), this.input.view()].filter(Boolean).join('\n')
47
+ }
48
+ }
49
+ ```
50
+
51
+ ## Options
52
+
53
+ | Option | Default | Description |
54
+ | ------------- | ------- | ------------------------------------------------------ |
55
+ | `value` | `''` | Initial text |
56
+ | `placeholder` | `''` | Dim text shown when empty |
57
+ | `prompt` | `''` | Prefix drawn before the value (e.g. `'> '`) |
58
+ | `suggestions` | `[]` | `{ name, desc }` objects (or bare strings) |
59
+ | `trigger` | `'/'` | The menu opens only when the line starts with this |
60
+ | `maxVisible` | `6` | Rows shown before the menu scrolls and notes "…N more" |
61
+ | `focused` | `false` | Start focused |
62
+
63
+ ## API
64
+
65
+ - `focus()` / `blur()` — toggle whether keys are consumed.
66
+ - `setValue(v)` / `reset()` — set or clear the text.
67
+ - `setSuggestions(list)` — replace the suggestion set.
68
+ - `accept()` — complete the line to the highlighted suggestion.
69
+ - `.value` — the current string.
70
+ - `.open` — whether the dropdown is currently showing.
71
+ - `selectedSuggestion()` — the highlighted `{ name, desc }`, or `null`.
72
+ - `matches()` — the suggestions matching the current text.
73
+ - `view()` — the input line. `menuView()` — the dropdown (or `''` when closed).
74
+
75
+ ## Keys
76
+
77
+ While the menu is open: `↑`/`↓` (or `ctrl+p`/`ctrl+n`) move the highlight, `tab`
78
+ accepts it (completing the line), `esc` dismisses it until the text changes.
79
+ Every other key edits the field — see [textinput](textinput.md). The component
80
+ never consumes `enter`, leaving "submit" entirely to the parent (see above).