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.
- package/LICENSE +201 -0
- package/NOTICE +13 -0
- package/README.md +244 -0
- package/ansi.js +34 -0
- package/commands.js +51 -0
- package/components/autocomplete.js +208 -0
- package/components/filepicker.js +232 -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/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/filepicker.md +80 -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/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 +63 -0
- package/key.js +29 -0
- package/messages.js +62 -0
- package/mouse.js +102 -0
- package/package.json +59 -1
- package/program.js +313 -0
- package/renderer.js +67 -0
- package/style.js +469 -0
|
@@ -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,70 @@
|
|
|
1
|
+
// spinner — a Cmd-driven animated spinner.
|
|
2
|
+
//
|
|
3
|
+
// This is the reference for a *command-driven* component. It animates by
|
|
4
|
+
// re-issuing a tick Cmd each frame; the parent model just routes 'spinner.tick'
|
|
5
|
+
// Msgs into update() and threads the returned Cmd up to the Program:
|
|
6
|
+
//
|
|
7
|
+
// class App {
|
|
8
|
+
// constructor () { this.spinner = spinner.create() }
|
|
9
|
+
// init () { return this.spinner.init() }
|
|
10
|
+
// update (msg) {
|
|
11
|
+
// const [s, cmd] = this.spinner.update(msg)
|
|
12
|
+
// this.spinner = s
|
|
13
|
+
// return [this, cmd]
|
|
14
|
+
// }
|
|
15
|
+
// view () { return this.spinner.view() + ' loading…' }
|
|
16
|
+
// }
|
|
17
|
+
//
|
|
18
|
+
// Each spinner has a unique id and a monotonic tag so that strays — a second
|
|
19
|
+
// spinner's ticks, or a duplicated/late tick — can't double-drive the loop.
|
|
20
|
+
const { tick } = require('../commands')
|
|
21
|
+
|
|
22
|
+
// A few frame sets. `dots` is the default.
|
|
23
|
+
const dots = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
24
|
+
const line = ['|', '/', '-', '\\']
|
|
25
|
+
const points = ['∙∙∙', '●∙∙', '∙●∙', '∙∙●']
|
|
26
|
+
|
|
27
|
+
let nextId = 1
|
|
28
|
+
|
|
29
|
+
class Spinner {
|
|
30
|
+
constructor(opts = {}) {
|
|
31
|
+
this.frames = opts.frames || dots
|
|
32
|
+
this.fps = opts.fps || 10
|
|
33
|
+
this.frame = 0
|
|
34
|
+
this.id = nextId++ // distinguishes this spinner's ticks from any other's
|
|
35
|
+
this.tag = 0 // bumped each accepted tick; stale ticks are ignored
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Returns the Cmd that starts the animation. Call from the parent's init().
|
|
39
|
+
init() {
|
|
40
|
+
return this._tick()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_tick() {
|
|
44
|
+
const id = this.id
|
|
45
|
+
const tag = this.tag
|
|
46
|
+
const ms = Math.max(1, Math.round(1000 / this.fps))
|
|
47
|
+
return tick(ms, () => ({ type: 'spinner.tick', id, tag }))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
update(msg) {
|
|
51
|
+
if (!msg || msg.type !== 'spinner.tick') return [this, null]
|
|
52
|
+
// Ignore another spinner's ticks and any tick that isn't the one we're
|
|
53
|
+
// currently waiting on (duplicates, late fires).
|
|
54
|
+
if (msg.id !== this.id || msg.tag !== this.tag) return [this, null]
|
|
55
|
+
|
|
56
|
+
this.frame = (this.frame + 1) % this.frames.length
|
|
57
|
+
this.tag++
|
|
58
|
+
return [this, this._tick()]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
view() {
|
|
62
|
+
return this.frames[this.frame]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function create(opts) {
|
|
67
|
+
return new Spinner(opts)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { create, Spinner, dots, line, points }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// stopwatch — counts elapsed time upward.
|
|
2
|
+
//
|
|
3
|
+
// Cmd-driven like the spinner: start() returns a tick Cmd, and each accepted
|
|
4
|
+
// 'stopwatch.tick' advances `elapsed` and re-issues the next tick. id + tag
|
|
5
|
+
// guard against strays so pausing/resuming can't double-drive it.
|
|
6
|
+
//
|
|
7
|
+
// this.sw = stopwatch.create()
|
|
8
|
+
// init() { return this.sw.start() }
|
|
9
|
+
// update(msg) { const [sw, cmd] = this.sw.update(msg); this.sw = sw; return [this, cmd] }
|
|
10
|
+
// view() { return this.sw.view() } // "01:23"
|
|
11
|
+
const { tick } = require('../commands')
|
|
12
|
+
|
|
13
|
+
let nextId = 1
|
|
14
|
+
|
|
15
|
+
function pad(n) {
|
|
16
|
+
return String(n).padStart(2, '0')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ms → "M:SS" / "MM:SS" / "H:MM:SS"
|
|
20
|
+
function format(ms) {
|
|
21
|
+
const total = Math.floor(ms / 1000)
|
|
22
|
+
const h = Math.floor(total / 3600)
|
|
23
|
+
const m = Math.floor((total % 3600) / 60)
|
|
24
|
+
const s = total % 60
|
|
25
|
+
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class Stopwatch {
|
|
29
|
+
constructor(opts = {}) {
|
|
30
|
+
this.interval = opts.interval || 1000
|
|
31
|
+
this.elapsed = opts.elapsed || 0
|
|
32
|
+
this.running = false
|
|
33
|
+
this.id = nextId++
|
|
34
|
+
this.tag = 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_tick() {
|
|
38
|
+
const id = this.id
|
|
39
|
+
const tag = this.tag
|
|
40
|
+
return tick(this.interval, () => ({ type: 'stopwatch.tick', id, tag }))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
start() {
|
|
44
|
+
if (this.running) return null
|
|
45
|
+
this.running = true
|
|
46
|
+
return this._tick()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stop() {
|
|
50
|
+
this.running = false
|
|
51
|
+
this.tag++ // invalidate any in-flight tick
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
toggle() {
|
|
56
|
+
return this.running ? this.stop() : this.start()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
reset() {
|
|
60
|
+
this.elapsed = 0
|
|
61
|
+
return null // a running stopwatch keeps ticking
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
update(msg) {
|
|
65
|
+
if (
|
|
66
|
+
!msg ||
|
|
67
|
+
msg.type !== 'stopwatch.tick' ||
|
|
68
|
+
msg.id !== this.id ||
|
|
69
|
+
msg.tag !== this.tag ||
|
|
70
|
+
!this.running
|
|
71
|
+
) {
|
|
72
|
+
return [this, null]
|
|
73
|
+
}
|
|
74
|
+
this.elapsed += this.interval
|
|
75
|
+
this.tag++
|
|
76
|
+
return [this, this._tick()]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
view() {
|
|
80
|
+
return format(this.elapsed)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function create(opts) {
|
|
85
|
+
return new Stopwatch(opts)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { create, Stopwatch, format }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// table — fixed-width columns with selectable, scrolling rows.
|
|
2
|
+
//
|
|
3
|
+
// const t = table.create({
|
|
4
|
+
// columns: [{ title: 'Name', width: 12 }, { title: 'Lang', width: 8 }],
|
|
5
|
+
// rows: [['corestore', 'js'], ['hypercore', 'js']],
|
|
6
|
+
// height: 8
|
|
7
|
+
// })
|
|
8
|
+
//
|
|
9
|
+
// Cells are truncated/padded to their column width (ANSI-aware), the selection
|
|
10
|
+
// is a reverse-video bar, and the body scrolls in a `height`-row window. Like
|
|
11
|
+
// list/viewport it always responds to its keys — the parent decides routing.
|
|
12
|
+
const key = require('../key')
|
|
13
|
+
const { style, width, truncate } = require('../style')
|
|
14
|
+
|
|
15
|
+
const header = (s) => style().bold(true).render(s)
|
|
16
|
+
const selected = (s) => style().reverse(true).render(s)
|
|
17
|
+
const dim = (s) => style().faint(true).render(s)
|
|
18
|
+
|
|
19
|
+
const keys = {
|
|
20
|
+
up: key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } }),
|
|
21
|
+
down: key.binding({ keys: ['down', 'j'], help: { key: '↓/j', desc: 'down' } }),
|
|
22
|
+
pageUp: key.binding({ keys: ['pageup'] }),
|
|
23
|
+
pageDown: key.binding({ keys: ['pagedown'] }),
|
|
24
|
+
top: key.binding({ keys: ['home'], help: { key: 'home', desc: 'top' } }),
|
|
25
|
+
bottom: key.binding({ keys: ['end'], help: { key: 'end', desc: 'bottom' } })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Truncate/pad a value to exactly `w` visible cells.
|
|
29
|
+
function cell(value, w) {
|
|
30
|
+
const text = String(value ?? '')
|
|
31
|
+
if (width(text) > w) return truncate(text, w)
|
|
32
|
+
return text + ' '.repeat(w - width(text))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class Table {
|
|
36
|
+
constructor(opts = {}) {
|
|
37
|
+
this.columns = opts.columns || []
|
|
38
|
+
this.rows = opts.rows || []
|
|
39
|
+
this.height = opts.height || 10 // visible body rows
|
|
40
|
+
this.rule = opts.rule || '─'
|
|
41
|
+
this.cursor = 0
|
|
42
|
+
this.offset = 0
|
|
43
|
+
this._clamp()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get totalWidth() {
|
|
47
|
+
if (!this.columns.length) return 0
|
|
48
|
+
const cols = this.columns.reduce((sum, c) => sum + c.width, 0)
|
|
49
|
+
return cols + (this.columns.length - 1) // single-space gutters
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
selectedRow() {
|
|
53
|
+
return this.rows[this.cursor] || null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setRows(rows) {
|
|
57
|
+
this.rows = rows
|
|
58
|
+
this._clamp()
|
|
59
|
+
return this
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setColumns(columns) {
|
|
63
|
+
this.columns = columns
|
|
64
|
+
return this
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
gotoTop() {
|
|
68
|
+
this.cursor = 0
|
|
69
|
+
this.offset = 0
|
|
70
|
+
return this
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
gotoBottom() {
|
|
74
|
+
this.cursor = Math.max(0, this.rows.length - 1)
|
|
75
|
+
this.offset = Math.max(0, this.rows.length - this.height)
|
|
76
|
+
return this
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_move(delta) {
|
|
80
|
+
if (!this.rows.length) return
|
|
81
|
+
this.cursor = Math.max(0, Math.min(this.cursor + delta, this.rows.length - 1))
|
|
82
|
+
if (this.cursor < this.offset) this.offset = this.cursor
|
|
83
|
+
else if (this.cursor >= this.offset + this.height) {
|
|
84
|
+
this.offset = this.cursor - this.height + 1
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_clamp() {
|
|
89
|
+
this.cursor = Math.max(0, Math.min(this.cursor, Math.max(0, this.rows.length - 1)))
|
|
90
|
+
const maxOffset = Math.max(0, this.rows.length - this.height)
|
|
91
|
+
this.offset = Math.max(0, Math.min(this.offset, maxOffset))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
update(msg) {
|
|
95
|
+
if (!msg || msg.type !== 'key') return [this, null]
|
|
96
|
+
if (key.matches(msg, keys.up)) this._move(-1)
|
|
97
|
+
else if (key.matches(msg, keys.down)) this._move(1)
|
|
98
|
+
else if (key.matches(msg, keys.pageUp)) this._move(-this.height)
|
|
99
|
+
else if (key.matches(msg, keys.pageDown)) this._move(this.height)
|
|
100
|
+
else if (key.matches(msg, keys.top)) this.gotoTop()
|
|
101
|
+
else if (key.matches(msg, keys.bottom)) this.gotoBottom()
|
|
102
|
+
return [this, null]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_row(cells) {
|
|
106
|
+
return this.columns.map((c, i) => cell(cells[i], c.width)).join(' ')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
view() {
|
|
110
|
+
const lines = []
|
|
111
|
+
lines.push(header(this._row(this.columns.map((c) => c.title))))
|
|
112
|
+
lines.push(dim(this.rule.repeat(this.totalWidth)))
|
|
113
|
+
|
|
114
|
+
const end = Math.min(this.offset + this.height, this.rows.length)
|
|
115
|
+
const body = []
|
|
116
|
+
for (let i = this.offset; i < end; i++) {
|
|
117
|
+
const line = this._row(this.rows[i])
|
|
118
|
+
body.push(i === this.cursor ? selected(line) : line)
|
|
119
|
+
}
|
|
120
|
+
while (body.length < this.height) body.push(' '.repeat(this.totalWidth))
|
|
121
|
+
|
|
122
|
+
return lines.concat(body).join('\n')
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function create(opts) {
|
|
127
|
+
return new Table(opts)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { create, Table, keys }
|