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,165 @@
1
+ // select — a compact dropdown over a fixed list of options.
2
+ //
3
+ // Where autocomplete is for *open* typing with a filtered menu, select is for a
4
+ // *closed* set you pick from. It shows one line (the current choice) until you
5
+ // open it, then a menu of options to choose from. Like autocomplete it splits
6
+ // rendering in two so a dropdown never reflows the layout:
7
+ //
8
+ // view() → the one-line control (always one line, stable width)
9
+ // menuView() → the dropdown rows, or '' when closed — overlay this where it
10
+ // fits (below a top-anchored control, above a bottom one)
11
+ //
12
+ // const fruit = select.create({
13
+ // options: ['apple', 'banana', 'cherry'],
14
+ // placeholder: 'pick one'
15
+ // }).focus()
16
+ // fruit.value() // 'apple', or null while nothing is chosen
17
+ //
18
+ // Key contract (mirrors the other field controls): while *closed* it consumes
19
+ // only space (to open) and never enter, so a parent form keeps enter for
20
+ // submit. While *open* it owns the menu — up/down move, enter or space commit,
21
+ // esc cancels — which is fine because a form won't submit with a menu open.
22
+ const key = require('../key')
23
+ const ansi = require('../ansi')
24
+ const { style } = require('../style')
25
+
26
+ const dim = (s) => ansi.modifierDim + s + ansi.modifierReset
27
+
28
+ const keys = {
29
+ open: key.binding({ keys: ['space'], help: { key: 'space', desc: 'open' } }),
30
+ up: key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } }),
31
+ down: key.binding({ keys: ['down', 'j'], help: { key: '↓/j', desc: 'down' } }),
32
+ commit: key.binding({ keys: ['enter', 'space'], help: { key: 'enter', desc: 'select' } }),
33
+ cancel: key.binding({ keys: ['esc'], help: { key: 'esc', desc: 'cancel' } })
34
+ }
35
+
36
+ // Normalise an option to { label, value }. Bare values become their own label.
37
+ function normalize(opt) {
38
+ if (opt !== null && typeof opt === 'object') {
39
+ const value = 'value' in opt ? opt.value : opt.label
40
+ return { label: String(opt.label ?? opt.value ?? ''), value }
41
+ }
42
+ return { label: String(opt), value: opt }
43
+ }
44
+
45
+ class Select {
46
+ constructor(opts = {}) {
47
+ this.options = (opts.options || []).map(normalize)
48
+ this.selected = opts.selected ?? -1 // committed choice; -1 = none yet
49
+ this.placeholder = opts.placeholder || 'select…'
50
+ this.focused = !!opts.focused
51
+ this.maxVisible = opts.maxVisible || 6
52
+ this.openGlyph = opts.openGlyph || '▾'
53
+
54
+ this.open = false
55
+ this.highlight = 0 // cursor within the menu while open
56
+ this._clampSelected()
57
+ }
58
+
59
+ focus() {
60
+ this.focused = true
61
+ return this
62
+ }
63
+
64
+ blur() {
65
+ this.focused = false
66
+ this.open = false
67
+ return this
68
+ }
69
+
70
+ // The committed value, or null when nothing is chosen.
71
+ value() {
72
+ const o = this.options[this.selected]
73
+ return o ? o.value : null
74
+ }
75
+
76
+ selectedOption() {
77
+ return this.options[this.selected] || null
78
+ }
79
+
80
+ setOptions(options) {
81
+ this.options = (options || []).map(normalize)
82
+ this._clampSelected()
83
+ return this
84
+ }
85
+
86
+ // Select by value; no-op if absent.
87
+ setValue(v) {
88
+ const i = this.options.findIndex((o) => o.value === v)
89
+ if (i >= 0) this.selected = i
90
+ return this
91
+ }
92
+
93
+ update(msg) {
94
+ if (!this.focused || !msg || msg.type !== 'key') return [this, null]
95
+
96
+ if (!this.open) {
97
+ if (key.matches(msg, keys.open) && this.options.length) {
98
+ this.open = true
99
+ this.highlight = this.selected >= 0 ? this.selected : 0
100
+ }
101
+ return [this, null]
102
+ }
103
+
104
+ // Menu is open: it owns navigation, commit and cancel.
105
+ if (key.matches(msg, keys.cancel)) this.open = false
106
+ else if (key.matches(msg, keys.up)) this._move(-1)
107
+ else if (key.matches(msg, keys.down)) this._move(1)
108
+ else if (key.matches(msg, keys.commit)) {
109
+ this.selected = this.highlight
110
+ this.open = false
111
+ }
112
+ return [this, null]
113
+ }
114
+
115
+ _move(delta) {
116
+ if (!this.options.length) return
117
+ this.highlight = Math.max(0, Math.min(this.highlight + delta, this.options.length - 1))
118
+ }
119
+
120
+ _clampSelected() {
121
+ if (this.selected < -1) this.selected = -1
122
+ if (this.selected > this.options.length - 1) this.selected = this.options.length - 1
123
+ }
124
+
125
+ view() {
126
+ const pointer = this.focused ? '› ' : ' '
127
+ const chosen = this.options[this.selected]
128
+ const label = chosen ? chosen.label : dim(this.placeholder)
129
+ return pointer + label + ' ' + this.openGlyph
130
+ }
131
+
132
+ // The dropdown, or '' when closed. Highlight is drawn black-on-magenta to
133
+ // match autocomplete's menu; a footer notes options scrolled out of view.
134
+ menuView() {
135
+ if (!this.open || !this.options.length) return ''
136
+
137
+ // Keep the highlight inside a maxVisible-row window.
138
+ const n = this.options.length
139
+ const start =
140
+ n > this.maxVisible
141
+ ? Math.max(0, Math.min(this.highlight - this.maxVisible + 1, n - this.maxVisible))
142
+ : 0
143
+ const shown = this.options.slice(start, start + this.maxVisible)
144
+
145
+ const rows = shown.map((o, i) => {
146
+ const isSel = start + i === this.highlight
147
+ if (isSel) {
148
+ return style()
149
+ .foreground('black')
150
+ .background('magenta')
151
+ .render(' ' + o.label + ' ')
152
+ }
153
+ return ' ' + o.label
154
+ })
155
+
156
+ if (n > shown.length) rows.push(dim(` …${n - shown.length} more`))
157
+ return rows.join('\n')
158
+ }
159
+ }
160
+
161
+ function create(opts) {
162
+ return new Select(opts)
163
+ }
164
+
165
+ module.exports = { create, Select, keys }
@@ -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 }