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.
- package/LICENSE +201 -0
- package/NOTICE +13 -0
- package/README.md +261 -0
- package/ansi.js +34 -0
- package/commands.js +67 -0
- package/components/autocomplete.js +208 -0
- package/components/checkbox.js +68 -0
- package/components/filepicker.js +232 -0
- package/components/focus.js +129 -0
- package/components/help.js +117 -0
- package/components/list.js +172 -0
- package/components/paginator.js +111 -0
- package/components/progress.js +78 -0
- package/components/radio.js +113 -0
- package/components/select.js +165 -0
- package/components/spinner.js +70 -0
- package/components/stopwatch.js +88 -0
- package/components/table.js +130 -0
- package/components/textarea.js +279 -0
- package/components/textinput.js +135 -0
- package/components/timer.js +91 -0
- package/components/viewport.js +104 -0
- package/docs/autocomplete.md +80 -0
- package/docs/checkbox.md +43 -0
- package/docs/filepicker.md +80 -0
- package/docs/focus.md +68 -0
- package/docs/help.md +45 -0
- package/docs/list.md +47 -0
- package/docs/paginator.md +43 -0
- package/docs/progress.md +36 -0
- package/docs/radio.md +49 -0
- package/docs/select.md +54 -0
- package/docs/spinner.md +54 -0
- package/docs/stopwatch.md +52 -0
- package/docs/table.md +52 -0
- package/docs/textarea.md +43 -0
- package/docs/textinput.md +51 -0
- package/docs/timer.md +57 -0
- package/docs/viewport.md +39 -0
- package/index.d.ts +1 -0
- package/index.js +71 -0
- package/key.js +29 -0
- package/messages.js +62 -0
- package/mouse.js +102 -0
- package/package.json +59 -1
- package/program.js +387 -0
- package/renderer.js +67 -0
- 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).
|