bare-tui 0.0.0 → 0.0.1

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.
@@ -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,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,117 @@
1
+ // help — renders keybinding hints from key.binding({ keys, help }) objects.
2
+ //
3
+ // Unlike the other components this one is a *view helper*, not a loop model:
4
+ // its view(keymap) takes the bindings to show (mirroring bubbles/help, where
5
+ // help.View(keymap) is called from the parent's View). Bindings without a
6
+ // `help` entry are skipped, so internal keys stay hidden.
7
+ //
8
+ // const h = help.create()
9
+ // h.view([keys.up, keys.down, keys.quit]) // short: one line
10
+ // h.showAll = true
11
+ // h.view([[keys.up, keys.down], [keys.quit]]) // full: aligned columns
12
+ //
13
+ // A keymap can be an array of bindings, an object of bindings (values are
14
+ // used), or an object exposing shortHelp()/fullHelp() for full control.
15
+ const { style, width, truncate } = require('../style')
16
+
17
+ const defaultStyles = {
18
+ key: (s) => s,
19
+ desc: (s) => style().faint(true).render(s),
20
+ sep: (s) => style().faint(true).render(s)
21
+ }
22
+
23
+ function toBindings(x) {
24
+ if (!x) return []
25
+ return Array.isArray(x) ? x : Object.values(x)
26
+ }
27
+
28
+ // Resolve a keymap into the binding list (short) or list-of-columns (full).
29
+ function resolve(keymap, showAll) {
30
+ if (Array.isArray(keymap)) {
31
+ if (!showAll) return keymap
32
+ // Full mode: an array of arrays is already columns; a flat list is one.
33
+ return keymap.length && Array.isArray(keymap[0]) ? keymap : [keymap]
34
+ }
35
+
36
+ if (showAll) {
37
+ if (typeof keymap.fullHelp === 'function') return keymap.fullHelp()
38
+ if (keymap.full) return keymap.full
39
+ const short =
40
+ typeof keymap.shortHelp === 'function'
41
+ ? keymap.shortHelp()
42
+ : keymap.short || toBindings(keymap)
43
+ return [short]
44
+ }
45
+
46
+ if (typeof keymap.shortHelp === 'function') return keymap.shortHelp()
47
+ if (keymap.short) return keymap.short
48
+ return toBindings(keymap)
49
+ }
50
+
51
+ function helpful(binding) {
52
+ return binding && binding.help && binding.help.key
53
+ }
54
+
55
+ class Help {
56
+ constructor(opts = {}) {
57
+ this.width = opts.width || 0 // 0 = no truncation
58
+ this.showAll = !!opts.showAll
59
+ this.separator = opts.separator || ' • '
60
+ this.styles = { ...defaultStyles, ...(opts.styles || {}) }
61
+ }
62
+
63
+ setWidth(n) {
64
+ this.width = n
65
+ return this
66
+ }
67
+
68
+ view(keymap) {
69
+ if (this.showAll) {
70
+ const columns = resolve(keymap, true)
71
+ .map((group) => this._column(group))
72
+ .filter((c) => c.length)
73
+ if (!columns.length) return ''
74
+
75
+ const blocks = []
76
+ columns.forEach((c, i) => {
77
+ if (i) blocks.push(' ') // gutter between columns
78
+ blocks.push(c)
79
+ })
80
+ return style.joinHorizontal(style.position.top, ...blocks)
81
+ }
82
+
83
+ return this._short(resolve(keymap, false))
84
+ }
85
+
86
+ _short(bindings) {
87
+ const items = bindings.filter(helpful)
88
+ if (!items.length) return ''
89
+
90
+ const sep = this.styles.sep(this.separator)
91
+ let line = items
92
+ .map((b) => this.styles.key(b.help.key) + ' ' + this.styles.desc(b.help.desc))
93
+ .join(sep)
94
+
95
+ if (this.width > 0 && width(line) > this.width) line = truncate(line, this.width)
96
+ return line
97
+ }
98
+
99
+ _column(bindings) {
100
+ const items = bindings.filter(helpful)
101
+ if (!items.length) return ''
102
+
103
+ const keyW = Math.max(...items.map((b) => width(b.help.key)))
104
+ return items
105
+ .map((b) => {
106
+ const gap = ' '.repeat(keyW - width(b.help.key) + 2)
107
+ return this.styles.key(b.help.key) + gap + this.styles.desc(b.help.desc)
108
+ })
109
+ .join('\n')
110
+ }
111
+ }
112
+
113
+ function create(opts) {
114
+ return new Help(opts)
115
+ }
116
+
117
+ module.exports = { create, Help }