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,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 }
@@ -0,0 +1,172 @@
1
+ // list — a selectable, filterable list.
2
+ //
3
+ // Selection + filtering live here; scrolling mirrors the viewport model — a
4
+ // window [offset, offset+height) over the (possibly filtered) items, nudged to
5
+ // keep the selection visible. Filtering is delegated to an embedded textinput,
6
+ // so the list is itself a composition of components.
7
+ //
8
+ // Items may be strings or objects: `title` is shown, `filterValue` (falling
9
+ // back to title) is matched. selectedItem() returns the underlying item.
10
+ const key = require('../key')
11
+ const ansi = require('../ansi')
12
+ const textinput = require('./textinput')
13
+
14
+ const reverse = (s) => ansi.modifierReverse + s + ansi.modifierNotReverse
15
+ const dim = (s) => ansi.modifierDim + s + ansi.modifierReset
16
+
17
+ const keys = {
18
+ up: key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } }),
19
+ down: key.binding({ keys: ['down', 'j'], help: { key: '↓/j', desc: 'down' } }),
20
+ pageUp: key.binding({ keys: ['pageup'] }),
21
+ pageDown: key.binding({ keys: ['pagedown'] }),
22
+ filter: key.binding({ keys: ['/'], help: { key: '/', desc: 'filter' } }),
23
+ accept: key.binding({ keys: ['enter'] }),
24
+ cancel: key.binding({ keys: ['esc'], help: { key: 'esc', desc: 'clear filter' } })
25
+ }
26
+
27
+ function filterValue(item) {
28
+ if (item === undefined || item === null) return ''
29
+ if (typeof item === 'string') return item
30
+ return item.filterValue || item.title || String(item)
31
+ }
32
+
33
+ function titleOf(item) {
34
+ if (item === undefined || item === null) return ''
35
+ if (typeof item === 'string') return item
36
+ return item.title ?? String(item)
37
+ }
38
+
39
+ class List {
40
+ constructor(opts = {}) {
41
+ this.items = opts.items ? opts.items.slice() : []
42
+ this.height = opts.height || 10 // visible rows
43
+ this.width = opts.width || 0
44
+ this.title = opts.title || ''
45
+ this.filterable = opts.filterable !== false
46
+
47
+ this.input = textinput.create({ prompt: '' }) // filter query editor
48
+ this.filter = ''
49
+ this.filtering = false
50
+
51
+ this.selected = 0 // position within `filtered`
52
+ this.offset = 0 // top of the visible window
53
+ this.filtered = this.items.map((_, i) => i) // original indices that match
54
+ }
55
+
56
+ get visibleCount() {
57
+ return this.filtered.length
58
+ }
59
+
60
+ selectedItem() {
61
+ if (!this.filtered.length) return null
62
+ return this.items[this.filtered[this.selected]]
63
+ }
64
+
65
+ setItems(items) {
66
+ this.items = items.slice()
67
+ this._applyFilter()
68
+ return this
69
+ }
70
+
71
+ update(msg) {
72
+ if (!msg || msg.type !== 'key') return [this, null]
73
+
74
+ // While filtering, keys edit the query; esc cancels, enter accepts.
75
+ if (this.filtering) {
76
+ if (key.matches(msg, keys.cancel)) {
77
+ this.filtering = false
78
+ this.filter = ''
79
+ this.input.reset().blur()
80
+ this._applyFilter()
81
+ return [this, null]
82
+ }
83
+ if (key.matches(msg, keys.accept)) {
84
+ this.filtering = false
85
+ this.input.blur()
86
+ return [this, null]
87
+ }
88
+ const [input, cmd] = this.input.update(msg)
89
+ this.input = input
90
+ this.filter = this.input.value
91
+ this._applyFilter()
92
+ return [this, cmd]
93
+ }
94
+
95
+ if (this.filterable && key.matches(msg, keys.filter)) {
96
+ this.filtering = true
97
+ this.input.focus()
98
+ return [this, null]
99
+ }
100
+ if (key.matches(msg, keys.cancel) && this.filter) {
101
+ this.filter = ''
102
+ this.input.reset()
103
+ this._applyFilter()
104
+ return [this, null]
105
+ }
106
+
107
+ if (key.matches(msg, keys.up)) this._move(-1)
108
+ else if (key.matches(msg, keys.down)) this._move(1)
109
+ else if (key.matches(msg, keys.pageUp)) this._move(-this.height)
110
+ else if (key.matches(msg, keys.pageDown)) this._move(this.height)
111
+
112
+ return [this, null]
113
+ }
114
+
115
+ _applyFilter() {
116
+ const q = this.filter.trim().toLowerCase()
117
+ const all = this.items.map((_, i) => i)
118
+ this.filtered = q
119
+ ? all.filter((i) => filterValue(this.items[i]).toLowerCase().includes(q))
120
+ : all
121
+ // Filtering changes what's under the cursor, so reset to the first match.
122
+ this.selected = 0
123
+ this.offset = 0
124
+ }
125
+
126
+ _move(delta) {
127
+ if (!this.filtered.length) return
128
+ const last = this.filtered.length - 1
129
+ this.selected = Math.max(0, Math.min(this.selected + delta, last))
130
+ if (this.selected < this.offset) this.offset = this.selected
131
+ else if (this.selected >= this.offset + this.height) {
132
+ this.offset = this.selected - this.height + 1
133
+ }
134
+ }
135
+
136
+ view() {
137
+ const lines = []
138
+ if (this.title) lines.push(this.title)
139
+ if (this.filtering || this.filter) {
140
+ lines.push('/' + (this.filtering ? this.input.view() : this.filter))
141
+ }
142
+
143
+ const rows = []
144
+ if (!this.filtered.length) {
145
+ rows.push(dim(' no matches'))
146
+ } else {
147
+ const end = Math.min(this.offset + this.height, this.filtered.length)
148
+ for (let p = this.offset; p < end; p++) {
149
+ const item = this.items[this.filtered[p]]
150
+ rows.push(this._renderRow(titleOf(item), p === this.selected))
151
+ }
152
+ }
153
+ while (rows.length < this.height) rows.push('') // stable height
154
+ lines.push(...rows)
155
+
156
+ const pos = this.filtered.length ? this.selected + 1 : 0
157
+ lines.push(dim(` ${pos}/${this.filtered.length}`))
158
+ return lines.join('\n')
159
+ }
160
+
161
+ _renderRow(label, selected) {
162
+ let line = (selected ? '› ' : ' ') + label
163
+ if (this.width > 0) line = line.slice(0, this.width).padEnd(this.width)
164
+ return selected ? reverse(line) : line
165
+ }
166
+ }
167
+
168
+ function create(opts) {
169
+ return new List(opts)
170
+ }
171
+
172
+ module.exports = { create, List, keys }
@@ -0,0 +1,111 @@
1
+ // paginator — page state plus an indicator, for paging through a long list.
2
+ //
3
+ // Holds the current page and page size, handles the paging keys, and offers
4
+ // sliceBounds() so a parent can carve the visible page out of its items:
5
+ //
6
+ // const p = paginator.create({ perPage: 10, total: items.length, type: 'dots' })
7
+ // const [start, end] = p.sliceBounds()
8
+ // render(items.slice(start, end))
9
+ // p.view() // "●○○○○" (dots) or "1/5" (arabic)
10
+ const key = require('../key')
11
+ const { style } = require('../style')
12
+
13
+ const dim = (s) => style().faint(true).render(s)
14
+
15
+ const keys = {
16
+ prev: key.binding({
17
+ keys: ['left', 'h', 'pageup'],
18
+ help: { key: '←/h', desc: 'prev page' }
19
+ }),
20
+ next: key.binding({
21
+ keys: ['right', 'l', 'pagedown'],
22
+ help: { key: '→/l', desc: 'next page' }
23
+ })
24
+ }
25
+
26
+ class Paginator {
27
+ constructor(opts = {}) {
28
+ this.perPage = opts.perPage || 10
29
+ this.total = opts.total || 0 // number of items
30
+ this.page = opts.page || 0 // zero-indexed
31
+ this.type = opts.type || 'arabic' // 'arabic' | 'dots'
32
+ this.activeDot = opts.activeDot || '●'
33
+ this.inactiveDot = opts.inactiveDot || '○'
34
+ this._clamp()
35
+ }
36
+
37
+ get totalPages() {
38
+ return Math.max(1, Math.ceil(this.total / this.perPage))
39
+ }
40
+
41
+ onFirstPage() {
42
+ return this.page <= 0
43
+ }
44
+
45
+ onLastPage() {
46
+ return this.page >= this.totalPages - 1
47
+ }
48
+
49
+ setTotal(n) {
50
+ this.total = n
51
+ this._clamp()
52
+ return this
53
+ }
54
+
55
+ setPage(n) {
56
+ this.page = n
57
+ this._clamp()
58
+ return this
59
+ }
60
+
61
+ nextPage() {
62
+ if (!this.onLastPage()) this.page++
63
+ return this
64
+ }
65
+
66
+ prevPage() {
67
+ if (!this.onFirstPage()) this.page--
68
+ return this
69
+ }
70
+
71
+ _clamp() {
72
+ this.page = Math.max(0, Math.min(this.page, this.totalPages - 1))
73
+ }
74
+
75
+ // Items on the current page (last page may be short).
76
+ itemsOnPage(length = this.total) {
77
+ const [start, end] = this.sliceBounds(length)
78
+ return Math.max(0, end - start)
79
+ }
80
+
81
+ // [start, end) into a collection of `length` items for the current page.
82
+ sliceBounds(length = this.total) {
83
+ const start = Math.min(this.page * this.perPage, length)
84
+ const end = Math.min(start + this.perPage, length)
85
+ return [start, end]
86
+ }
87
+
88
+ update(msg) {
89
+ if (!msg || msg.type !== 'key') return [this, null]
90
+ if (key.matches(msg, keys.next)) this.nextPage()
91
+ else if (key.matches(msg, keys.prev)) this.prevPage()
92
+ return [this, null]
93
+ }
94
+
95
+ view() {
96
+ if (this.type === 'dots') {
97
+ let out = ''
98
+ for (let i = 0; i < this.totalPages; i++) {
99
+ out += i === this.page ? this.activeDot : dim(this.inactiveDot)
100
+ }
101
+ return out
102
+ }
103
+ return `${this.page + 1}/${this.totalPages}`
104
+ }
105
+ }
106
+
107
+ function create(opts) {
108
+ return new Paginator(opts)
109
+ }
110
+
111
+ module.exports = { create, Paginator, keys }
@@ -0,0 +1,78 @@
1
+ // progress — a percentage bar.
2
+ //
3
+ // Static like the help component: it holds the look (width, fill chars, color)
4
+ // and view(percent) renders a bar at the given fraction (0..1). Drive the
5
+ // percent from your model (a tick Cmd, a download callback, the OTA updater).
6
+ //
7
+ // const bar = progress.create({ width: 40, gradient: ['#5A56E0', '#EE6FF8'] })
8
+ // bar.view(0.42) // -> "████████░░░░… 42%"
9
+ const { style } = require('../style')
10
+
11
+ const dim = (s) => style().faint(true).render(s)
12
+
13
+ class Progress {
14
+ constructor(opts = {}) {
15
+ this.width = opts.width || 40
16
+ this.full = opts.full || '█'
17
+ this.empty = opts.empty || '░'
18
+ this.showPercentage = opts.showPercentage !== false
19
+ this.color = opts.color || null // solid fill color (any style color spec)
20
+ this.gradient = opts.gradient || null // [fromHex, toHex] across the fill
21
+ }
22
+
23
+ setWidth(n) {
24
+ this.width = n
25
+ return this
26
+ }
27
+
28
+ view(percent) {
29
+ percent = Math.max(0, Math.min(1, percent || 0))
30
+
31
+ // A fixed 5 cells (" 100%") are reserved for the label so the bar width is
32
+ // stable as the number changes.
33
+ const reserve = this.showPercentage ? 5 : 0
34
+ const w = Math.max(1, this.width - reserve)
35
+ const filled = Math.round(w * percent)
36
+ const gap = w - filled
37
+
38
+ let bar
39
+ if (this.gradient) {
40
+ let head = ''
41
+ for (let i = 0; i < filled; i++) {
42
+ const t = filled <= 1 ? 0 : i / (filled - 1)
43
+ const color = lerpHex(this.gradient[0], this.gradient[1], t)
44
+ head += style().foreground(color).render(this.full)
45
+ }
46
+ bar = head + dim(this.empty.repeat(gap))
47
+ } else {
48
+ const head = this.color
49
+ ? style().foreground(this.color).render(this.full.repeat(filled))
50
+ : this.full.repeat(filled)
51
+ bar = head + dim(this.empty.repeat(gap))
52
+ }
53
+
54
+ if (!this.showPercentage) return bar
55
+ const pct = Math.round(percent * 100)
56
+ return bar + ' ' + (pct + '%').padStart(4)
57
+ }
58
+ }
59
+
60
+ function hexToRgb(hex) {
61
+ let h = hex.replace('#', '')
62
+ if (h.length === 3) h = h.replace(/./g, (c) => c + c)
63
+ const n = parseInt(h, 16)
64
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
65
+ }
66
+
67
+ function lerpHex(a, b, t) {
68
+ const A = hexToRgb(a)
69
+ const B = hexToRgb(b)
70
+ const mix = A.map((v, i) => Math.round(v + (B[i] - v) * t))
71
+ return '#' + mix.map((v) => v.toString(16).padStart(2, '0')).join('')
72
+ }
73
+
74
+ function create(opts) {
75
+ return new Progress(opts)
76
+ }
77
+
78
+ module.exports = { create, Progress }
@@ -0,0 +1,113 @@
1
+ // radio — single choice from a fixed set of options.
2
+ //
3
+ // A tiny vertical list where the cursor *is* the value: up/down (or k/j) move
4
+ // the selection directly, so there is no separate "highlight vs commit" step.
5
+ // Like the other field controls it only reacts when focused and never consumes
6
+ // enter, leaving enter free for a parent's submit.
7
+ //
8
+ // const size = radio.create({
9
+ // options: ['small', 'medium', 'large'],
10
+ // selected: 1
11
+ // }).focus()
12
+ // // in update: const [r] = size.update(msg); this.size = r
13
+ // // in view: size.view()
14
+ // size.value() // 'medium'
15
+ //
16
+ // Options may be strings or { label, value } objects; value() returns the
17
+ // underlying value (the label when none is given). The chosen option always
18
+ // shows a filled bullet so the value is visible even when blurred; a leading
19
+ // '›' marks the focused row.
20
+ const key = require('../key')
21
+
22
+ const keys = {
23
+ up: key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } }),
24
+ down: key.binding({ keys: ['down', 'j'], help: { key: '↓/j', desc: 'down' } })
25
+ }
26
+
27
+ // Normalise an option to { label, value }. Bare values become their own label.
28
+ function normalize(opt) {
29
+ if (opt !== null && typeof opt === 'object') {
30
+ const value = 'value' in opt ? opt.value : opt.label
31
+ return { label: String(opt.label ?? opt.value ?? ''), value }
32
+ }
33
+ return { label: String(opt), value: opt }
34
+ }
35
+
36
+ class Radio {
37
+ constructor(opts = {}) {
38
+ this.options = (opts.options || []).map(normalize)
39
+ this.selected = opts.selected || 0 // index into options
40
+ this.focused = !!opts.focused
41
+ this.onGlyph = opts.onGlyph || '(•)'
42
+ this.offGlyph = opts.offGlyph || '( )'
43
+ this._clamp()
44
+ }
45
+
46
+ focus() {
47
+ this.focused = true
48
+ return this
49
+ }
50
+
51
+ blur() {
52
+ this.focused = false
53
+ return this
54
+ }
55
+
56
+ // The chosen option's value, or null when there are no options.
57
+ value() {
58
+ const o = this.options[this.selected]
59
+ return o ? o.value : null
60
+ }
61
+
62
+ // The chosen { label, value }, or null.
63
+ selectedOption() {
64
+ return this.options[this.selected] || null
65
+ }
66
+
67
+ setOptions(options) {
68
+ this.options = (options || []).map(normalize)
69
+ this._clamp()
70
+ return this
71
+ }
72
+
73
+ // Select by value; no-op if the value isn't present.
74
+ setValue(v) {
75
+ const i = this.options.findIndex((o) => o.value === v)
76
+ if (i >= 0) this.selected = i
77
+ return this
78
+ }
79
+
80
+ update(msg) {
81
+ if (!this.focused || !msg || msg.type !== 'key') return [this, null]
82
+ if (key.matches(msg, keys.up)) this._move(-1)
83
+ else if (key.matches(msg, keys.down)) this._move(1)
84
+ return [this, null]
85
+ }
86
+
87
+ _move(delta) {
88
+ if (!this.options.length) return
89
+ this.selected = Math.max(0, Math.min(this.selected + delta, this.options.length - 1))
90
+ }
91
+
92
+ _clamp() {
93
+ if (!this.options.length) this.selected = 0
94
+ else this.selected = Math.max(0, Math.min(this.selected, this.options.length - 1))
95
+ }
96
+
97
+ view() {
98
+ return this.options
99
+ .map((o, i) => {
100
+ const chosen = i === this.selected
101
+ const pointer = this.focused && chosen ? '› ' : ' '
102
+ const bullet = chosen ? this.onGlyph : this.offGlyph
103
+ return pointer + bullet + ' ' + o.label
104
+ })
105
+ .join('\n')
106
+ }
107
+ }
108
+
109
+ function create(opts) {
110
+ return new Radio(opts)
111
+ }
112
+
113
+ module.exports = { create, Radio, keys }