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