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,208 @@
|
|
|
1
|
+
// autocomplete — a single-line input with a filtered suggestion menu.
|
|
2
|
+
//
|
|
3
|
+
// A composition over textinput (the same pattern list uses for its filter): it
|
|
4
|
+
// folds keys into an embedded field, then offers a menu of suggestions that
|
|
5
|
+
// match what's been typed. Typical use is a slash-command palette — the menu
|
|
6
|
+
// opens only once the line begins with a trigger character (default '/'), so
|
|
7
|
+
// ordinary prose typed into the same field never pops a menu.
|
|
8
|
+
//
|
|
9
|
+
// const ac = autocomplete.create({
|
|
10
|
+
// prompt: '> ',
|
|
11
|
+
// placeholder: 'message, or / for commands',
|
|
12
|
+
// suggestions: [
|
|
13
|
+
// { name: 'help', desc: 'show help' },
|
|
14
|
+
// { name: 'clear', desc: 'clear the screen' }
|
|
15
|
+
// ]
|
|
16
|
+
// }).focus()
|
|
17
|
+
//
|
|
18
|
+
// The component never consumes enter — the parent decides what it means. A
|
|
19
|
+
// common pattern is "enter submits the line, but the highlighted command when
|
|
20
|
+
// the menu is open", which both completes and runs in one keystroke:
|
|
21
|
+
//
|
|
22
|
+
// if (key.matches(msg, 'enter')) {
|
|
23
|
+
// const s = ac.open && ac.selectedSuggestion()
|
|
24
|
+
// return [this, submit(s ? '/' + s.name : ac.value)]
|
|
25
|
+
// }
|
|
26
|
+
// const [f, cmd] = ac.update(msg); this.input = f; return [this, cmd]
|
|
27
|
+
//
|
|
28
|
+
// While the menu is open ↑/↓ move the highlight, tab accepts it (completing the
|
|
29
|
+
// line), and esc dismisses it; every other key edits the field. The view()
|
|
30
|
+
// draws the input line only — call menuView() to render the dropdown wherever
|
|
31
|
+
// it fits (above a bottom-anchored prompt, below a top one).
|
|
32
|
+
const key = require('../key')
|
|
33
|
+
const ansi = require('../ansi')
|
|
34
|
+
const { style } = require('../style')
|
|
35
|
+
const textinput = require('./textinput')
|
|
36
|
+
|
|
37
|
+
const dim = (s) => ansi.modifierDim + s + ansi.modifierReset
|
|
38
|
+
|
|
39
|
+
const keys = {
|
|
40
|
+
up: key.binding({ keys: ['up', 'ctrl+p'], help: { key: '↑', desc: 'prev' } }),
|
|
41
|
+
down: key.binding({ keys: ['down', 'ctrl+n'], help: { key: '↓', desc: 'next' } }),
|
|
42
|
+
accept: key.binding({ keys: ['tab'], help: { key: 'tab', desc: 'accept' } }),
|
|
43
|
+
dismiss: key.binding({ keys: ['esc'], help: { key: 'esc', desc: 'dismiss' } })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Normalise a suggestion to { name, desc }. Bare strings become name-only.
|
|
47
|
+
function normalize(s) {
|
|
48
|
+
if (typeof s === 'string') return { name: s, desc: '' }
|
|
49
|
+
return { name: String(s.name ?? ''), desc: String(s.desc ?? '') }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class Autocomplete {
|
|
53
|
+
constructor(opts = {}) {
|
|
54
|
+
this.trigger = opts.trigger ?? '/'
|
|
55
|
+
this.suggestions = (opts.suggestions || []).map(normalize)
|
|
56
|
+
this.maxVisible = opts.maxVisible || 6
|
|
57
|
+
this.width = opts.width || 0 // 0 = size the menu to its content
|
|
58
|
+
|
|
59
|
+
this.input = textinput.create({
|
|
60
|
+
value: opts.value || '',
|
|
61
|
+
placeholder: opts.placeholder || '',
|
|
62
|
+
prompt: opts.prompt || '',
|
|
63
|
+
focused: !!opts.focused
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
this.selected = 0 // highlight within the current matches
|
|
67
|
+
this.dismissed = false // esc / accept hides the menu until the value changes
|
|
68
|
+
this._lastValue = this.input.value
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get value() {
|
|
72
|
+
return this.input.value
|
|
73
|
+
}
|
|
74
|
+
get focused() {
|
|
75
|
+
return this.input.focused
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
focus() {
|
|
79
|
+
this.input.focus()
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
blur() {
|
|
83
|
+
this.input.blur()
|
|
84
|
+
return this
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setValue(v) {
|
|
88
|
+
this.input.setValue(v)
|
|
89
|
+
this._lastValue = this.input.value
|
|
90
|
+
return this
|
|
91
|
+
}
|
|
92
|
+
reset() {
|
|
93
|
+
this.input.reset()
|
|
94
|
+
this.selected = 0
|
|
95
|
+
this.dismissed = false
|
|
96
|
+
this._lastValue = ''
|
|
97
|
+
return this
|
|
98
|
+
}
|
|
99
|
+
setSuggestions(list) {
|
|
100
|
+
this.suggestions = (list || []).map(normalize)
|
|
101
|
+
return this
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The suggestions matching the typed text, or [] when no menu is warranted.
|
|
105
|
+
// A menu is warranted once the line begins with the trigger; the text after
|
|
106
|
+
// it is a case-insensitive prefix filter over suggestion names.
|
|
107
|
+
matches() {
|
|
108
|
+
const v = this.input.value
|
|
109
|
+
if (!v.startsWith(this.trigger)) return []
|
|
110
|
+
const typed = v.slice(this.trigger.length).toLowerCase()
|
|
111
|
+
return this.suggestions.filter((s) => s.name.toLowerCase().startsWith(typed))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Whether the dropdown is currently showing.
|
|
115
|
+
get open() {
|
|
116
|
+
return this.input.focused && !this.dismissed && this.matches().length > 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The highlighted suggestion, or null.
|
|
120
|
+
selectedSuggestion() {
|
|
121
|
+
const m = this.matches()
|
|
122
|
+
return m.length ? m[Math.min(this.selected, m.length - 1)] : null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Complete the line to the highlighted suggestion and close the menu.
|
|
126
|
+
accept() {
|
|
127
|
+
const s = this.selectedSuggestion()
|
|
128
|
+
if (!s) return this
|
|
129
|
+
this.input.setValue(this.trigger + s.name + ' ')
|
|
130
|
+
this.input.cursor = this.input.value.length
|
|
131
|
+
this.dismissed = true
|
|
132
|
+
this._lastValue = this.input.value
|
|
133
|
+
return this
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
update(msg) {
|
|
137
|
+
if (!msg || msg.type !== 'key' || !this.input.focused) return [this, null]
|
|
138
|
+
|
|
139
|
+
// Menu navigation takes priority over field editing while it's open.
|
|
140
|
+
if (this.open) {
|
|
141
|
+
if (key.matches(msg, keys.up)) return [this._move(-1), null]
|
|
142
|
+
if (key.matches(msg, keys.down)) return [this._move(1), null]
|
|
143
|
+
if (key.matches(msg, keys.accept)) return [this.accept(), null]
|
|
144
|
+
if (key.matches(msg, keys.dismiss)) {
|
|
145
|
+
this.dismissed = true
|
|
146
|
+
return [this, null]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const [input, cmd] = this.input.update(msg)
|
|
151
|
+
this.input = input
|
|
152
|
+
// Any edit to the text re-opens the menu and re-clamps the highlight.
|
|
153
|
+
if (this.input.value !== this._lastValue) {
|
|
154
|
+
this.dismissed = false
|
|
155
|
+
this._lastValue = this.input.value
|
|
156
|
+
this.selected = 0
|
|
157
|
+
}
|
|
158
|
+
return [this, cmd]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_move(dir) {
|
|
162
|
+
const n = this.matches().length
|
|
163
|
+
if (n) this.selected = (this.selected + dir + n) % n
|
|
164
|
+
return this
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
view() {
|
|
168
|
+
return this.input.view()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The dropdown, or '' when closed. Rows are `/name desc`, the highlight in
|
|
172
|
+
// reverse video; a footer notes any matches scrolled out of view.
|
|
173
|
+
menuView() {
|
|
174
|
+
if (!this.open) return ''
|
|
175
|
+
const all = this.matches()
|
|
176
|
+
const sel = Math.min(this.selected, all.length - 1)
|
|
177
|
+
|
|
178
|
+
// Keep the highlight inside a maxVisible-row window.
|
|
179
|
+
const start = Math.max(0, Math.min(sel - this.maxVisible + 1, all.length - this.maxVisible))
|
|
180
|
+
const top = all.length > this.maxVisible ? Math.max(0, start) : 0
|
|
181
|
+
const shown = all.slice(top, top + this.maxVisible)
|
|
182
|
+
|
|
183
|
+
const nameW = Math.max(...all.map((s) => (this.trigger + s.name).length))
|
|
184
|
+
const rows = shown.map((s, i) => {
|
|
185
|
+
const isSel = top + i === sel
|
|
186
|
+
const label = (this.trigger + s.name).padEnd(nameW)
|
|
187
|
+
const text = s.desc ? label + ' ' + s.desc : label
|
|
188
|
+
if (isSel) {
|
|
189
|
+
return style()
|
|
190
|
+
.foreground('black')
|
|
191
|
+
.background('magenta')
|
|
192
|
+
.render(' ' + text + ' ')
|
|
193
|
+
}
|
|
194
|
+
return ' ' + style().foreground('magenta').render(label) + (s.desc ? ' ' + dim(s.desc) : '')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
if (all.length > shown.length) {
|
|
198
|
+
rows.push(dim(` …${all.length - shown.length} more`))
|
|
199
|
+
}
|
|
200
|
+
return rows.join('\n')
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function create(opts) {
|
|
205
|
+
return new Autocomplete(opts)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = { create, Autocomplete, keys }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// checkbox — a single boolean toggle.
|
|
2
|
+
//
|
|
3
|
+
// A leaf input in the textinput mould: it only reacts when focused, so a parent
|
|
4
|
+
// can broadcast keys to several controls and only the focused one moves. Space
|
|
5
|
+
// toggles; it deliberately never consumes enter, so a parent form can keep enter
|
|
6
|
+
// for "submit" while a checkbox has focus.
|
|
7
|
+
//
|
|
8
|
+
// const agree = checkbox.create({ label: 'I agree', checked: false }).focus()
|
|
9
|
+
// // in update: const [c] = agree.update(msg); this.agree = c
|
|
10
|
+
// // in view: agree.view() // "› [x] I agree"
|
|
11
|
+
//
|
|
12
|
+
// Focus is shown with a leading '›' pointer (a blank when blurred), the same
|
|
13
|
+
// idiom radio and select use, so a stack of controls reads consistently and the
|
|
14
|
+
// pointer never shifts the line width.
|
|
15
|
+
const key = require('../key')
|
|
16
|
+
|
|
17
|
+
const keys = {
|
|
18
|
+
// Space only — enter is left for the parent (e.g. submit).
|
|
19
|
+
toggle: key.binding({ keys: ['space'], help: { key: 'space', desc: 'toggle' } })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class Checkbox {
|
|
23
|
+
constructor(opts = {}) {
|
|
24
|
+
this.label = opts.label || ''
|
|
25
|
+
this.checked = !!opts.checked
|
|
26
|
+
this.focused = !!opts.focused
|
|
27
|
+
this.checkedGlyph = opts.checkedGlyph || '[x]'
|
|
28
|
+
this.uncheckedGlyph = opts.uncheckedGlyph || '[ ]'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
focus() {
|
|
32
|
+
this.focused = true
|
|
33
|
+
return this
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
blur() {
|
|
37
|
+
this.focused = false
|
|
38
|
+
return this
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setChecked(v) {
|
|
42
|
+
this.checked = !!v
|
|
43
|
+
return this
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
toggle() {
|
|
47
|
+
this.checked = !this.checked
|
|
48
|
+
return this
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
update(msg) {
|
|
52
|
+
if (!this.focused || !msg || msg.type !== 'key') return [this, null]
|
|
53
|
+
if (key.matches(msg, keys.toggle)) this.toggle()
|
|
54
|
+
return [this, null]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
view() {
|
|
58
|
+
const pointer = this.focused ? '› ' : ' '
|
|
59
|
+
const box = this.checked ? this.checkedGlyph : this.uncheckedGlyph
|
|
60
|
+
return pointer + (this.label ? box + ' ' + this.label : box)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function create(opts) {
|
|
65
|
+
return new Checkbox(opts)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { create, Checkbox, keys }
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// filepicker — browse a filesystem and pick a file.
|
|
2
|
+
//
|
|
3
|
+
// Dependency-injected so the framework core stays filesystem-free: pass your
|
|
4
|
+
// own { fs, path } to create(), or let it lazily require('bare-fs') /
|
|
5
|
+
// require('bare-path') — that require only runs when you actually construct a
|
|
6
|
+
// filepicker, so consumers who don't use it never pull those modules in.
|
|
7
|
+
//
|
|
8
|
+
// const fp = filepicker.create({ height: 12 }) // real fs
|
|
9
|
+
// const fp = filepicker.create({ ...filepicker.mock(tree), cwd: '/' }) // tests
|
|
10
|
+
//
|
|
11
|
+
// Directory reads happen through Cmds (async), surfacing as Msgs:
|
|
12
|
+
// { type: 'filepicker.entries', dir, entries }
|
|
13
|
+
// { type: 'filepicker.error', dir, error }
|
|
14
|
+
// { type: 'filepicker.select', path } // a file was chosen
|
|
15
|
+
//
|
|
16
|
+
// The only fs surface used is fs.readdir(dir, { withFileTypes: true }, cb); the
|
|
17
|
+
// only path surface is path.join and path.dirname. The mock implements exactly
|
|
18
|
+
// that, so tests need no real I/O.
|
|
19
|
+
const key = require('../key')
|
|
20
|
+
const { style } = require('../style')
|
|
21
|
+
|
|
22
|
+
const dirStyle = (s) => style().foreground('cyan').render(s)
|
|
23
|
+
const selectedStyle = (s) => style().reverse(true).render(s)
|
|
24
|
+
const dim = (s) => style().faint(true).render(s)
|
|
25
|
+
|
|
26
|
+
const keys = {
|
|
27
|
+
up: key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } }),
|
|
28
|
+
down: key.binding({ keys: ['down', 'j'], help: { key: '↓/j', desc: 'down' } }),
|
|
29
|
+
open: key.binding({ keys: ['enter', 'right', 'l'], help: { key: '↵', desc: 'open' } }),
|
|
30
|
+
back: key.binding({ keys: ['backspace', 'left', 'h'], help: { key: '⌫', desc: 'up dir' } })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function listDir(fs, dir) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
fs.readdir(dir, { withFileTypes: true }, (err, ents) => {
|
|
36
|
+
if (err) return reject(err)
|
|
37
|
+
resolve(ents.map((e) => ({ name: e.name, directory: e.isDirectory() })))
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sortEntries(entries, showHidden) {
|
|
43
|
+
return entries
|
|
44
|
+
.filter((e) => showHidden || !e.name.startsWith('.'))
|
|
45
|
+
.sort((a, b) => {
|
|
46
|
+
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
|
47
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class FilePicker {
|
|
52
|
+
constructor(opts = {}) {
|
|
53
|
+
this.fs = opts.fs
|
|
54
|
+
this.path = opts.path
|
|
55
|
+
this.cwd = opts.cwd
|
|
56
|
+
this.height = opts.height || 12
|
|
57
|
+
this.showHidden = !!opts.showHidden
|
|
58
|
+
|
|
59
|
+
this.entries = []
|
|
60
|
+
this.cursor = 0
|
|
61
|
+
this.offset = 0
|
|
62
|
+
this.selected = null
|
|
63
|
+
this.error = null
|
|
64
|
+
this.loading = true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
init() {
|
|
68
|
+
return this._read(this.cwd)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
selectedPath() {
|
|
72
|
+
return this.selected
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A Cmd that reads `dir` and resolves to an entries (or error) Msg.
|
|
76
|
+
_read(dir) {
|
|
77
|
+
const fs = this.fs
|
|
78
|
+
const showHidden = this.showHidden
|
|
79
|
+
return () =>
|
|
80
|
+
listDir(fs, dir).then(
|
|
81
|
+
(entries) => ({
|
|
82
|
+
type: 'filepicker.entries',
|
|
83
|
+
dir,
|
|
84
|
+
entries: sortEntries(entries, showHidden)
|
|
85
|
+
}),
|
|
86
|
+
(err) => ({
|
|
87
|
+
type: 'filepicker.error',
|
|
88
|
+
dir,
|
|
89
|
+
error: (err && err.message) || String(err)
|
|
90
|
+
})
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
update(msg) {
|
|
95
|
+
if (!msg) return [this, null]
|
|
96
|
+
|
|
97
|
+
if (msg.type === 'filepicker.entries' && msg.dir === this.cwd) {
|
|
98
|
+
this.entries = msg.entries
|
|
99
|
+
this.cursor = 0
|
|
100
|
+
this.offset = 0
|
|
101
|
+
this.loading = false
|
|
102
|
+
this.error = null
|
|
103
|
+
return [this, null]
|
|
104
|
+
}
|
|
105
|
+
if (msg.type === 'filepicker.error' && msg.dir === this.cwd) {
|
|
106
|
+
this.error = msg.error
|
|
107
|
+
this.entries = []
|
|
108
|
+
this.loading = false
|
|
109
|
+
return [this, null]
|
|
110
|
+
}
|
|
111
|
+
if (msg.type === 'key') return this._key(msg)
|
|
112
|
+
return [this, null]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_key(msg) {
|
|
116
|
+
if (key.matches(msg, keys.up)) this._move(-1)
|
|
117
|
+
else if (key.matches(msg, keys.down)) this._move(1)
|
|
118
|
+
else if (key.matches(msg, keys.back)) return this._open(this.path.dirname(this.cwd))
|
|
119
|
+
else if (key.matches(msg, keys.open)) {
|
|
120
|
+
const entry = this.entries[this.cursor]
|
|
121
|
+
if (!entry) return [this, null]
|
|
122
|
+
const full = this.path.join(this.cwd, entry.name)
|
|
123
|
+
if (entry.directory) return this._open(full)
|
|
124
|
+
this.selected = full
|
|
125
|
+
return [this, () => ({ type: 'filepicker.select', path: full })]
|
|
126
|
+
}
|
|
127
|
+
return [this, null]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_open(dir) {
|
|
131
|
+
this.cwd = dir
|
|
132
|
+
this.loading = true
|
|
133
|
+
return [this, this._read(dir)]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_move(delta) {
|
|
137
|
+
if (!this.entries.length) return
|
|
138
|
+
this.cursor = Math.max(0, Math.min(this.cursor + delta, this.entries.length - 1))
|
|
139
|
+
if (this.cursor < this.offset) this.offset = this.cursor
|
|
140
|
+
else if (this.cursor >= this.offset + this.height) {
|
|
141
|
+
this.offset = this.cursor - this.height + 1
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
view() {
|
|
146
|
+
const out = [
|
|
147
|
+
style()
|
|
148
|
+
.bold(true)
|
|
149
|
+
.render(this.cwd || '')
|
|
150
|
+
]
|
|
151
|
+
const rows = []
|
|
152
|
+
|
|
153
|
+
if (this.loading) rows.push(dim(' loading…'))
|
|
154
|
+
else if (this.error) rows.push(dim(' ⚠ ' + this.error))
|
|
155
|
+
else if (!this.entries.length) rows.push(dim(' (empty)'))
|
|
156
|
+
else {
|
|
157
|
+
const end = Math.min(this.offset + this.height, this.entries.length)
|
|
158
|
+
for (let p = this.offset; p < end; p++) {
|
|
159
|
+
const entry = this.entries[p]
|
|
160
|
+
const label = entry.directory ? entry.name + '/' : entry.name
|
|
161
|
+
const text = (p === this.cursor ? '› ' : ' ') + label
|
|
162
|
+
rows.push(p === this.cursor ? selectedStyle(text) : entry.directory ? dirStyle(text) : text)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
while (rows.length < this.height) rows.push('')
|
|
166
|
+
|
|
167
|
+
return out.concat(rows).join('\n')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function create(opts = {}) {
|
|
172
|
+
// Lazy require: only consumers that build a filepicker load bare-fs/bare-path.
|
|
173
|
+
const fs = opts.fs || require('bare-fs')
|
|
174
|
+
const path = opts.path || require('bare-path')
|
|
175
|
+
const cwd = opts.cwd || path.resolve('.')
|
|
176
|
+
return new FilePicker({ ...opts, fs, path, cwd })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── mock filesystem ──────────────────────────────────────────────────────
|
|
180
|
+
//
|
|
181
|
+
// filepicker.mock(tree) returns { fs, path, root } backed by a plain object:
|
|
182
|
+
// keys are entry names, an object value is a directory, anything else a file.
|
|
183
|
+
//
|
|
184
|
+
// const m = filepicker.mock({ docs: { 'a.md': null }, 'readme.txt': null })
|
|
185
|
+
// const fp = filepicker.create({ fs: m.fs, path: m.path, cwd: m.root })
|
|
186
|
+
|
|
187
|
+
const mockPath = {
|
|
188
|
+
sep: '/',
|
|
189
|
+
join: (...parts) => parts.join('/').replace(/\/{2,}/g, '/') || '/',
|
|
190
|
+
dirname: (p) => {
|
|
191
|
+
const segs = p.replace(/\/+$/, '').split('/')
|
|
192
|
+
segs.pop()
|
|
193
|
+
const d = segs.join('/')
|
|
194
|
+
return d === '' ? '/' : d
|
|
195
|
+
},
|
|
196
|
+
basename: (p) => p.replace(/\/+$/, '').split('/').pop() || '/',
|
|
197
|
+
resolve: (p) => p
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveNode(tree, root, p) {
|
|
201
|
+
if (p === root) return tree
|
|
202
|
+
let rel = p
|
|
203
|
+
if (root !== '/' && p.startsWith(root)) rel = p.slice(root.length)
|
|
204
|
+
const segs = rel.split('/').filter(Boolean)
|
|
205
|
+
let node = tree
|
|
206
|
+
for (const s of segs) {
|
|
207
|
+
if (node && typeof node === 'object' && s in node) node = node[s]
|
|
208
|
+
else return undefined
|
|
209
|
+
}
|
|
210
|
+
return node
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function mock(tree, opts = {}) {
|
|
214
|
+
const root = opts.root || '/'
|
|
215
|
+
const fs = {
|
|
216
|
+
readdir(dir, options, cb) {
|
|
217
|
+
if (typeof options === 'function') cb = options
|
|
218
|
+
const node = resolveNode(tree, root, dir)
|
|
219
|
+
if (!node || typeof node !== 'object') {
|
|
220
|
+
return cb(new Error('ENOTDIR: ' + dir))
|
|
221
|
+
}
|
|
222
|
+
const ents = Object.keys(node).map((name) => {
|
|
223
|
+
const isDir = !!(node[name] && typeof node[name] === 'object')
|
|
224
|
+
return { name, isDirectory: () => isDir }
|
|
225
|
+
})
|
|
226
|
+
cb(null, ents)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { fs, path: mockPath, root }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { create, FilePicker, mock }
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// focus — an ordered focus ring across a set of child components.
|
|
2
|
+
//
|
|
3
|
+
// This is connective tissue, not a widget: it has no view(). It holds an
|
|
4
|
+
// ordered list of focusable children and owns the one job every multi-field
|
|
5
|
+
// screen reimplements — moving focus between them with tab/shift+tab, blurring
|
|
6
|
+
// the old and focusing the new. It relies only on the component contract the
|
|
7
|
+
// built-ins already follow (`focus()` / `blur()` and `update(msg) → [m, cmd]`).
|
|
8
|
+
//
|
|
9
|
+
// this.ring = focus.create({
|
|
10
|
+
// items: [
|
|
11
|
+
// textinput.create({ prompt: '> ' }),
|
|
12
|
+
// radio.create({ options: ['a', 'b'] }),
|
|
13
|
+
// checkbox.create({ label: 'ok' })
|
|
14
|
+
// ]
|
|
15
|
+
// })
|
|
16
|
+
//
|
|
17
|
+
// update(msg) {
|
|
18
|
+
// // Handle global / submit keys FIRST so a focused child can't swallow them.
|
|
19
|
+
// if (key.matches(msg, 'ctrl+c')) return [this, quit]
|
|
20
|
+
// if (key.matches(msg, 'enter')) return [this, this._submit()]
|
|
21
|
+
// // Then let the ring move focus and route the rest to the focused child,
|
|
22
|
+
// // threading its Cmd back up.
|
|
23
|
+
// const [ring, cmd] = this.ring.update(msg)
|
|
24
|
+
// this.ring = ring
|
|
25
|
+
// return [this, cmd]
|
|
26
|
+
// }
|
|
27
|
+
// view() { return this.ring.items.map((it) => it.view()).join('\n') }
|
|
28
|
+
//
|
|
29
|
+
// Navigation defaults to tab/shift+tab ONLY — deliberately not up/down, because
|
|
30
|
+
// the focusable children (radio, select, list, textarea) use the arrows
|
|
31
|
+
// internally. Pass `keys` to override if your children don't. focus() syncs the
|
|
32
|
+
// children on construction so exactly the indexed one is focused.
|
|
33
|
+
const key = require('../key')
|
|
34
|
+
|
|
35
|
+
const defaultKeys = {
|
|
36
|
+
next: key.binding({ keys: ['tab'], help: { key: 'tab', desc: 'next field' } }),
|
|
37
|
+
prev: key.binding({ keys: ['shift+tab'], help: { key: 'shift+tab', desc: 'prev field' } })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class Focus {
|
|
41
|
+
constructor(opts = {}) {
|
|
42
|
+
this.items = opts.items ? opts.items.slice() : []
|
|
43
|
+
this.index = opts.index || 0
|
|
44
|
+
this.keys = opts.keys || defaultKeys
|
|
45
|
+
this._clamp()
|
|
46
|
+
this._sync()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The currently focused child, or null when there are none.
|
|
50
|
+
focused() {
|
|
51
|
+
return this.items[this.index] || null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setItems(items) {
|
|
55
|
+
this.items = items ? items.slice() : []
|
|
56
|
+
this._clamp()
|
|
57
|
+
this._sync()
|
|
58
|
+
return this
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Focus a specific index (clamped); blurs the rest.
|
|
62
|
+
focus(i) {
|
|
63
|
+
this.index = i
|
|
64
|
+
this._clamp()
|
|
65
|
+
this._sync()
|
|
66
|
+
return this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
next() {
|
|
70
|
+
this._move(1)
|
|
71
|
+
return this
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
prev() {
|
|
75
|
+
this._move(-1)
|
|
76
|
+
return this
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
update(msg) {
|
|
80
|
+
// Ring navigation is handled here, before the child sees the key. Check the
|
|
81
|
+
// more-specific 'prev' chord (shift+tab) first: key matching also accepts a
|
|
82
|
+
// bare name, so a 'tab' binding would otherwise swallow 'shift+tab' too.
|
|
83
|
+
if (key.matches(msg, this.keys.prev)) {
|
|
84
|
+
this._move(-1)
|
|
85
|
+
return [this, null]
|
|
86
|
+
}
|
|
87
|
+
if (key.matches(msg, this.keys.next)) {
|
|
88
|
+
this._move(1)
|
|
89
|
+
return [this, null]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Everything else goes to the focused child; thread its Cmd up.
|
|
93
|
+
const cur = this.items[this.index]
|
|
94
|
+
if (cur && typeof cur.update === 'function') {
|
|
95
|
+
const [m, cmd] = cur.update(msg)
|
|
96
|
+
this.items[this.index] = m
|
|
97
|
+
return [this, cmd]
|
|
98
|
+
}
|
|
99
|
+
return [this, null]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_move(dir) {
|
|
103
|
+
if (this.items.length < 2) return
|
|
104
|
+
this.index = (this.index + dir + this.items.length) % this.items.length
|
|
105
|
+
this._sync()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_clamp() {
|
|
109
|
+
if (!this.items.length) this.index = 0
|
|
110
|
+
else this.index = Math.max(0, Math.min(this.index, this.items.length - 1))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Make exactly items[index] focused; blur the rest. Guards children that
|
|
114
|
+
// don't implement the focus contract.
|
|
115
|
+
_sync() {
|
|
116
|
+
this.items.forEach((it, i) => {
|
|
117
|
+
if (!it) return
|
|
118
|
+
if (i === this.index) {
|
|
119
|
+
if (typeof it.focus === 'function') it.focus()
|
|
120
|
+
} else if (typeof it.blur === 'function') it.blur()
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function create(opts) {
|
|
126
|
+
return new Focus(opts)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { create, Focus, defaultKeys }
|