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,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 }
|