bare-tui 0.0.1 → 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/README.md +22 -5
- package/commands.js +17 -1
- package/components/checkbox.js +68 -0
- package/components/focus.js +129 -0
- package/components/radio.js +113 -0
- package/components/select.js +165 -0
- package/docs/checkbox.md +43 -0
- package/docs/focus.md +68 -0
- package/docs/radio.md +49 -0
- package/docs/select.md +54 -0
- package/index.js +10 -2
- package/package.json +1 -1
- package/program.js +74 -0
- package/style.js +6 -2
package/README.md
CHANGED
|
@@ -20,8 +20,7 @@ This tutorial assumes you have [Bare](https://bare.pears.com) installed. We'll b
|
|
|
20
20
|
bare-tui programs are made of a **model** describing the application state, and three methods on that model:
|
|
21
21
|
|
|
22
22
|
- **`init`** — a function that returns an initial _command_ (or `null`).
|
|
23
|
-
- **`update`** — a function that handles incoming _messages_ and updates the
|
|
24
|
-
model.
|
|
23
|
+
- **`update`** — a function that handles incoming _messages_ and updates the model.
|
|
25
24
|
- **`view`** — a function that renders the model to a string.
|
|
26
25
|
|
|
27
26
|
### The Model
|
|
@@ -108,13 +107,26 @@ Run it with `bare counter.js`. The `Program` puts the terminal into raw mode, en
|
|
|
108
107
|
A **command** (`Cmd`) is a function `() => Msg | Promise<Msg> | null`. The runtime runs it _off_ the update path and feeds whatever message it returns back into `update`. This is how you do anything asynchronous — timers, file or network I/O, talking to a worker — without blocking the UI.
|
|
109
108
|
|
|
110
109
|
```js
|
|
111
|
-
const { quit, batch, sequence, tick, every } = require('bare-tui')
|
|
110
|
+
const { quit, batch, sequence, tick, every, suspend } = require('bare-tui')
|
|
112
111
|
|
|
113
112
|
quit // a Cmd that quits the program
|
|
114
113
|
tick(1000, () => ({ type: 'tick' })) // fire a Msg after 1s
|
|
115
114
|
every(1000, () => ({ type: 'tick' })) // fire on the wall-clock second
|
|
116
115
|
batch(cmdA, cmdB) // run several Cmds concurrently
|
|
117
116
|
sequence(cmdA, cmdB) // run several Cmds in order
|
|
117
|
+
suspend(fn) // drop the TUI, run fn() with the real terminal, then resume
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Use `suspend` to hand the terminal to an external program that needs it — an editor (`$EDITOR`), a pager, a sub-shell. The runtime drops raw mode, leaves the alt-screen and releases stdin while `fn()` runs, then re-attaches and repaints; the message `fn` resolves to is delivered once the TUI is back:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
return [
|
|
124
|
+
model,
|
|
125
|
+
suspend(async () => {
|
|
126
|
+
await spawnEditor(file) // owns the real terminal while it runs
|
|
127
|
+
return { type: 'edited', file }
|
|
128
|
+
})
|
|
129
|
+
]
|
|
118
130
|
```
|
|
119
131
|
|
|
120
132
|
An async command just returns a promise:
|
|
@@ -162,6 +174,10 @@ Ready-made, composable pieces — each is a model (`update`/`view`) you embed in
|
|
|
162
174
|
| [autocomplete](docs/autocomplete.md) | Text field with a suggestion menu |
|
|
163
175
|
| [textarea](docs/textarea.md) | Multi-line text editor |
|
|
164
176
|
| [list](docs/list.md) | Selectable, filterable list |
|
|
177
|
+
| [select](docs/select.md) | Compact dropdown over a fixed list |
|
|
178
|
+
| [radio](docs/radio.md) | Single choice from a fixed set |
|
|
179
|
+
| [checkbox](docs/checkbox.md) | Boolean toggle |
|
|
180
|
+
| [focus](docs/focus.md) | Focus ring across child components |
|
|
165
181
|
| [table](docs/table.md) | Columns with selectable, scrolling rows |
|
|
166
182
|
| [viewport](docs/viewport.md) | Scrollable window over long content |
|
|
167
183
|
| [paginator](docs/paginator.md) | Page state + indicator |
|
|
@@ -218,8 +234,9 @@ bare-tui is built to be tested headlessly — no real terminal, no real I/O.
|
|
|
218
234
|
|
|
219
235
|
Runnable, one per concept, in [`examples/`](examples):
|
|
220
236
|
|
|
221
|
-
`counter` · `form` · `
|
|
222
|
-
`paginator` · `mouse` · `textarea` · `timer` · `filepicker` ·
|
|
237
|
+
`counter` · `form` · `controls` · `list` · `table` · `dashboard` · `pager` ·
|
|
238
|
+
`progress` · `paginator` · `mouse` · `textarea` · `timer` · `filepicker` ·
|
|
239
|
+
`claude-code`
|
|
223
240
|
|
|
224
241
|
```sh
|
|
225
242
|
bare examples/dashboard.js
|
package/commands.js
CHANGED
|
@@ -37,6 +37,22 @@ function tick(ms, fn) {
|
|
|
37
37
|
})
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Suspend the TUI, run an async function with the terminal handed back to the
|
|
41
|
+
// shell, then resume and repaint. Use it to drop into an external program that
|
|
42
|
+
// needs the real terminal — an editor ($EDITOR), a pager, a sub-shell:
|
|
43
|
+
//
|
|
44
|
+
// return [model, suspend(async () => {
|
|
45
|
+
// await runEditor(file) // owns the TTY while it runs
|
|
46
|
+
// return { type: 'edited', file } // delivered to update() after resume
|
|
47
|
+
// })]
|
|
48
|
+
//
|
|
49
|
+
// The runtime detaches the terminal (drops raw mode, leaves the alt-screen,
|
|
50
|
+
// releases stdin) before calling fn and re-attaches + repaints after it
|
|
51
|
+
// settles. Whatever Msg fn resolves to is dispatched once the TUI is back.
|
|
52
|
+
function suspend(fn) {
|
|
53
|
+
return { __suspend: fn }
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
// Like tick, but aligned to wall-clock boundaries: every(1000, ...) fires on
|
|
41
57
|
// each whole second rather than one second after it happened to start. Keeps
|
|
42
58
|
// repeated timers from drifting. Re-issue from update() to keep it going.
|
|
@@ -48,4 +64,4 @@ function every(ms, fn) {
|
|
|
48
64
|
})
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
module.exports = { quit, batch, sequence, tick, every }
|
|
67
|
+
module.exports = { quit, batch, sequence, tick, every, suspend }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// checkbox — a single boolean toggle.
|
|
2
|
+
//
|
|
3
|
+
// A leaf input in the textinput mould: it only reacts when focused, so a parent
|
|
4
|
+
// can broadcast keys to several controls and only the focused one moves. Space
|
|
5
|
+
// toggles; it deliberately never consumes enter, so a parent form can keep enter
|
|
6
|
+
// for "submit" while a checkbox has focus.
|
|
7
|
+
//
|
|
8
|
+
// const agree = checkbox.create({ label: 'I agree', checked: false }).focus()
|
|
9
|
+
// // in update: const [c] = agree.update(msg); this.agree = c
|
|
10
|
+
// // in view: agree.view() // "› [x] I agree"
|
|
11
|
+
//
|
|
12
|
+
// Focus is shown with a leading '›' pointer (a blank when blurred), the same
|
|
13
|
+
// idiom radio and select use, so a stack of controls reads consistently and the
|
|
14
|
+
// pointer never shifts the line width.
|
|
15
|
+
const key = require('../key')
|
|
16
|
+
|
|
17
|
+
const keys = {
|
|
18
|
+
// Space only — enter is left for the parent (e.g. submit).
|
|
19
|
+
toggle: key.binding({ keys: ['space'], help: { key: 'space', desc: 'toggle' } })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class Checkbox {
|
|
23
|
+
constructor(opts = {}) {
|
|
24
|
+
this.label = opts.label || ''
|
|
25
|
+
this.checked = !!opts.checked
|
|
26
|
+
this.focused = !!opts.focused
|
|
27
|
+
this.checkedGlyph = opts.checkedGlyph || '[x]'
|
|
28
|
+
this.uncheckedGlyph = opts.uncheckedGlyph || '[ ]'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
focus() {
|
|
32
|
+
this.focused = true
|
|
33
|
+
return this
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
blur() {
|
|
37
|
+
this.focused = false
|
|
38
|
+
return this
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setChecked(v) {
|
|
42
|
+
this.checked = !!v
|
|
43
|
+
return this
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
toggle() {
|
|
47
|
+
this.checked = !this.checked
|
|
48
|
+
return this
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
update(msg) {
|
|
52
|
+
if (!this.focused || !msg || msg.type !== 'key') return [this, null]
|
|
53
|
+
if (key.matches(msg, keys.toggle)) this.toggle()
|
|
54
|
+
return [this, null]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
view() {
|
|
58
|
+
const pointer = this.focused ? '› ' : ' '
|
|
59
|
+
const box = this.checked ? this.checkedGlyph : this.uncheckedGlyph
|
|
60
|
+
return pointer + (this.label ? box + ' ' + this.label : box)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function create(opts) {
|
|
65
|
+
return new Checkbox(opts)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { create, Checkbox, keys }
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// focus — an ordered focus ring across a set of child components.
|
|
2
|
+
//
|
|
3
|
+
// This is connective tissue, not a widget: it has no view(). It holds an
|
|
4
|
+
// ordered list of focusable children and owns the one job every multi-field
|
|
5
|
+
// screen reimplements — moving focus between them with tab/shift+tab, blurring
|
|
6
|
+
// the old and focusing the new. It relies only on the component contract the
|
|
7
|
+
// built-ins already follow (`focus()` / `blur()` and `update(msg) → [m, cmd]`).
|
|
8
|
+
//
|
|
9
|
+
// this.ring = focus.create({
|
|
10
|
+
// items: [
|
|
11
|
+
// textinput.create({ prompt: '> ' }),
|
|
12
|
+
// radio.create({ options: ['a', 'b'] }),
|
|
13
|
+
// checkbox.create({ label: 'ok' })
|
|
14
|
+
// ]
|
|
15
|
+
// })
|
|
16
|
+
//
|
|
17
|
+
// update(msg) {
|
|
18
|
+
// // Handle global / submit keys FIRST so a focused child can't swallow them.
|
|
19
|
+
// if (key.matches(msg, 'ctrl+c')) return [this, quit]
|
|
20
|
+
// if (key.matches(msg, 'enter')) return [this, this._submit()]
|
|
21
|
+
// // Then let the ring move focus and route the rest to the focused child,
|
|
22
|
+
// // threading its Cmd back up.
|
|
23
|
+
// const [ring, cmd] = this.ring.update(msg)
|
|
24
|
+
// this.ring = ring
|
|
25
|
+
// return [this, cmd]
|
|
26
|
+
// }
|
|
27
|
+
// view() { return this.ring.items.map((it) => it.view()).join('\n') }
|
|
28
|
+
//
|
|
29
|
+
// Navigation defaults to tab/shift+tab ONLY — deliberately not up/down, because
|
|
30
|
+
// the focusable children (radio, select, list, textarea) use the arrows
|
|
31
|
+
// internally. Pass `keys` to override if your children don't. focus() syncs the
|
|
32
|
+
// children on construction so exactly the indexed one is focused.
|
|
33
|
+
const key = require('../key')
|
|
34
|
+
|
|
35
|
+
const defaultKeys = {
|
|
36
|
+
next: key.binding({ keys: ['tab'], help: { key: 'tab', desc: 'next field' } }),
|
|
37
|
+
prev: key.binding({ keys: ['shift+tab'], help: { key: 'shift+tab', desc: 'prev field' } })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class Focus {
|
|
41
|
+
constructor(opts = {}) {
|
|
42
|
+
this.items = opts.items ? opts.items.slice() : []
|
|
43
|
+
this.index = opts.index || 0
|
|
44
|
+
this.keys = opts.keys || defaultKeys
|
|
45
|
+
this._clamp()
|
|
46
|
+
this._sync()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The currently focused child, or null when there are none.
|
|
50
|
+
focused() {
|
|
51
|
+
return this.items[this.index] || null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setItems(items) {
|
|
55
|
+
this.items = items ? items.slice() : []
|
|
56
|
+
this._clamp()
|
|
57
|
+
this._sync()
|
|
58
|
+
return this
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Focus a specific index (clamped); blurs the rest.
|
|
62
|
+
focus(i) {
|
|
63
|
+
this.index = i
|
|
64
|
+
this._clamp()
|
|
65
|
+
this._sync()
|
|
66
|
+
return this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
next() {
|
|
70
|
+
this._move(1)
|
|
71
|
+
return this
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
prev() {
|
|
75
|
+
this._move(-1)
|
|
76
|
+
return this
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
update(msg) {
|
|
80
|
+
// Ring navigation is handled here, before the child sees the key. Check the
|
|
81
|
+
// more-specific 'prev' chord (shift+tab) first: key matching also accepts a
|
|
82
|
+
// bare name, so a 'tab' binding would otherwise swallow 'shift+tab' too.
|
|
83
|
+
if (key.matches(msg, this.keys.prev)) {
|
|
84
|
+
this._move(-1)
|
|
85
|
+
return [this, null]
|
|
86
|
+
}
|
|
87
|
+
if (key.matches(msg, this.keys.next)) {
|
|
88
|
+
this._move(1)
|
|
89
|
+
return [this, null]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Everything else goes to the focused child; thread its Cmd up.
|
|
93
|
+
const cur = this.items[this.index]
|
|
94
|
+
if (cur && typeof cur.update === 'function') {
|
|
95
|
+
const [m, cmd] = cur.update(msg)
|
|
96
|
+
this.items[this.index] = m
|
|
97
|
+
return [this, cmd]
|
|
98
|
+
}
|
|
99
|
+
return [this, null]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_move(dir) {
|
|
103
|
+
if (this.items.length < 2) return
|
|
104
|
+
this.index = (this.index + dir + this.items.length) % this.items.length
|
|
105
|
+
this._sync()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_clamp() {
|
|
109
|
+
if (!this.items.length) this.index = 0
|
|
110
|
+
else this.index = Math.max(0, Math.min(this.index, this.items.length - 1))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Make exactly items[index] focused; blur the rest. Guards children that
|
|
114
|
+
// don't implement the focus contract.
|
|
115
|
+
_sync() {
|
|
116
|
+
this.items.forEach((it, i) => {
|
|
117
|
+
if (!it) return
|
|
118
|
+
if (i === this.index) {
|
|
119
|
+
if (typeof it.focus === 'function') it.focus()
|
|
120
|
+
} else if (typeof it.blur === 'function') it.blur()
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function create(opts) {
|
|
126
|
+
return new Focus(opts)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { create, Focus, defaultKeys }
|
|
@@ -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 }
|
|
@@ -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 }
|
package/docs/checkbox.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# checkbox
|
|
2
|
+
|
|
3
|
+
A single boolean toggle. A focus-gated leaf input: it only reacts when focused,
|
|
4
|
+
so a parent can broadcast keys to several controls and only the focused one
|
|
5
|
+
moves.
|
|
6
|
+
|
|
7
|
+
[← all components](../README.md#components)
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
const { checkbox } = require('bare-tui')
|
|
13
|
+
|
|
14
|
+
const agree = checkbox.create({ label: 'I agree', checked: false }).focus()
|
|
15
|
+
|
|
16
|
+
// in update: const [c] = agree.update(msg); this.agree = c
|
|
17
|
+
// in view: agree.view() // "› [x] I agree"
|
|
18
|
+
agree.checked // boolean
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Options
|
|
22
|
+
|
|
23
|
+
| Option | Default | Description |
|
|
24
|
+
| --------------------------------- | ------------- | ------------------------ |
|
|
25
|
+
| `label` | `''` | Text shown after the box |
|
|
26
|
+
| `checked` | `false` | Initial state |
|
|
27
|
+
| `focused` | `false` | Start focused |
|
|
28
|
+
| `checkedGlyph` / `uncheckedGlyph` | `[x]` / `[ ]` | Box characters |
|
|
29
|
+
|
|
30
|
+
## API
|
|
31
|
+
|
|
32
|
+
- `toggle()` / `setChecked(v)` — change state imperatively.
|
|
33
|
+
- `focus()` / `blur()` — gate input. `.checked` / `.focused` are readable.
|
|
34
|
+
|
|
35
|
+
## Keys
|
|
36
|
+
|
|
37
|
+
`space` toggles. **`enter` is deliberately not consumed**, so a parent form can
|
|
38
|
+
keep `enter` for "submit" while a checkbox has focus. Bindings are exported as
|
|
39
|
+
`checkbox.keys` for the [help](help.md) component.
|
|
40
|
+
|
|
41
|
+
A leading `›` marks focus (a blank when blurred), the same idiom
|
|
42
|
+
[radio](radio.md) and [select](select.md) use, so the pointer never shifts the
|
|
43
|
+
line width.
|
package/docs/focus.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# focus
|
|
2
|
+
|
|
3
|
+
An ordered focus ring across a set of child components. This is connective
|
|
4
|
+
tissue, not a widget — it has **no `view()`**. It owns the one job every
|
|
5
|
+
multi-field screen reimplements: moving focus between children with
|
|
6
|
+
`tab`/`shift+tab`, blurring the old and focusing the new.
|
|
7
|
+
|
|
8
|
+
It relies only on the component contract the built-ins already follow
|
|
9
|
+
(`focus()` / `blur()` and `update(msg) → [model, cmd]`).
|
|
10
|
+
|
|
11
|
+
[← all components](../README.md#components)
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const { focus, textinput, radio, checkbox } = require('bare-tui')
|
|
17
|
+
|
|
18
|
+
this.ring = focus.create({
|
|
19
|
+
items: [
|
|
20
|
+
textinput.create({ prompt: '> ' }),
|
|
21
|
+
radio.create({ options: ['a', 'b'] }),
|
|
22
|
+
checkbox.create({ label: 'ok' })
|
|
23
|
+
]
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
update(msg) {
|
|
27
|
+
// Handle global / submit keys FIRST so a focused child can't swallow them.
|
|
28
|
+
if (key.matches(msg, 'ctrl+c')) return [this, quit]
|
|
29
|
+
if (key.matches(msg, 'enter')) return [this, this._submit()]
|
|
30
|
+
// Then let the ring move focus and route the rest to the focused child,
|
|
31
|
+
// threading its Cmd back up.
|
|
32
|
+
const [ring, cmd] = this.ring.update(msg)
|
|
33
|
+
this.ring = ring
|
|
34
|
+
return [this, cmd]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
view() {
|
|
38
|
+
return this.ring.items.map((it) => it.view()).join('\n')
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Options
|
|
43
|
+
|
|
44
|
+
| Option | Default | Description |
|
|
45
|
+
| ------- | ------------------- | -------------------------------------- |
|
|
46
|
+
| `items` | `[]` | Ordered focusable children |
|
|
47
|
+
| `index` | `0` | Which child starts focused |
|
|
48
|
+
| `keys` | `tab` / `shift+tab` | Navigation bindings (`{ next, prev }`) |
|
|
49
|
+
|
|
50
|
+
On construction the ring syncs the children so exactly the indexed one is
|
|
51
|
+
focused.
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
- `focused()` — the active child, or `null`. `.index` / `.items` are readable.
|
|
56
|
+
- `next()` / `prev()` / `focus(i)` — move focus imperatively (wraps).
|
|
57
|
+
- `setItems(items)` — replace the children and re-sync focus.
|
|
58
|
+
- `update(msg)` — handles navigation, then delegates everything else to the
|
|
59
|
+
focused child and threads its Cmd up.
|
|
60
|
+
|
|
61
|
+
## Keys
|
|
62
|
+
|
|
63
|
+
Navigation defaults to `tab`/`shift+tab` **only** — deliberately not the arrows,
|
|
64
|
+
because the focusable children ([radio](radio.md), [select](select.md),
|
|
65
|
+
[list](list.md), [textarea](textarea.md)) use the arrows internally. Pass `keys`
|
|
66
|
+
to override if your children don't. Always handle global and submit keys in the
|
|
67
|
+
parent _before_ calling `ring.update`, so a focused child can't swallow the
|
|
68
|
+
escape hatch.
|
package/docs/radio.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# radio
|
|
2
|
+
|
|
3
|
+
Single choice from a fixed set of options. A tiny vertical list where the cursor
|
|
4
|
+
_is_ the value — the arrows move the selection directly, so there is no separate
|
|
5
|
+
highlight-then-commit step.
|
|
6
|
+
|
|
7
|
+
[← all components](../README.md#components)
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
const { radio } = require('bare-tui')
|
|
13
|
+
|
|
14
|
+
const size = radio
|
|
15
|
+
.create({
|
|
16
|
+
options: ['small', 'medium', 'large'],
|
|
17
|
+
selected: 1
|
|
18
|
+
})
|
|
19
|
+
.focus()
|
|
20
|
+
|
|
21
|
+
// in update: const [r] = size.update(msg); this.size = r
|
|
22
|
+
// in view: size.view()
|
|
23
|
+
size.value() // 'medium'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Options may be strings or `{ label, value }` objects; `value()` returns the
|
|
27
|
+
underlying value (the label when none is given).
|
|
28
|
+
|
|
29
|
+
## Options
|
|
30
|
+
|
|
31
|
+
| Option | Default | Description |
|
|
32
|
+
| -------------------- | ------------- | ---------------------------- |
|
|
33
|
+
| `options` | `[]` | Strings or `{ label, value}` |
|
|
34
|
+
| `selected` | `0` | Initial index |
|
|
35
|
+
| `focused` | `false` | Start focused |
|
|
36
|
+
| `onGlyph`/`offGlyph` | `(•)` / `( )` | Bullet characters |
|
|
37
|
+
|
|
38
|
+
## API
|
|
39
|
+
|
|
40
|
+
- `value()` — the chosen value, or `null` when there are no options.
|
|
41
|
+
- `selectedOption()` — the chosen `{ label, value }`.
|
|
42
|
+
- `setValue(v)` — select by value (no-op if absent). `setOptions(opts)`.
|
|
43
|
+
|
|
44
|
+
## Keys
|
|
45
|
+
|
|
46
|
+
`↑`/`↓` (`k`/`j`) move the selection. **`enter` is not consumed**, so a parent
|
|
47
|
+
keeps it for "submit". The chosen option always shows a filled bullet (even when
|
|
48
|
+
blurred); a leading `›` marks the focused row. Bindings are exported as
|
|
49
|
+
`radio.keys` for the [help](help.md) component.
|
package/docs/select.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# select
|
|
2
|
+
|
|
3
|
+
A compact dropdown over a fixed list of options. Where
|
|
4
|
+
[autocomplete](autocomplete.md) is for _open_ typing with a filtered menu,
|
|
5
|
+
select is for a _closed_ set you pick from: it shows one line (the current
|
|
6
|
+
choice) until you open it, then a menu to choose from.
|
|
7
|
+
|
|
8
|
+
[← all components](../README.md#components)
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
Like autocomplete, rendering is split in two so the dropdown never reflows the
|
|
13
|
+
layout — `view()` is the one-line control, `menuView()` is the overlay:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const { select } = require('bare-tui')
|
|
17
|
+
|
|
18
|
+
const fruit = select
|
|
19
|
+
.create({
|
|
20
|
+
options: ['apple', 'banana', 'cherry'],
|
|
21
|
+
placeholder: 'pick one'
|
|
22
|
+
})
|
|
23
|
+
.focus()
|
|
24
|
+
|
|
25
|
+
// in view: draw the control, then overlay the menu where it fits
|
|
26
|
+
const line = fruit.view() // always one line, stable width
|
|
27
|
+
const menu = fruit.menuView() // '' when closed; rows when open
|
|
28
|
+
fruit.value() // 'apple', or null while nothing is chosen
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Options
|
|
32
|
+
|
|
33
|
+
| Option | Default | Description |
|
|
34
|
+
| ------------- | ----------- | --------------------------------- |
|
|
35
|
+
| `options` | `[]` | Strings or `{ label, value }` |
|
|
36
|
+
| `selected` | `-1` | Initial index (`-1` = none) |
|
|
37
|
+
| `placeholder` | `'select…'` | Shown when nothing is chosen |
|
|
38
|
+
| `maxVisible` | `6` | Rows before the menu scrolls |
|
|
39
|
+
| `openGlyph` | `'▾'` | Trailing indicator on the control |
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
- `value()` — the committed value, or `null`. `selectedOption()` → `{label,value}`.
|
|
44
|
+
- `setValue(v)` / `setOptions(opts)`.
|
|
45
|
+
- `view()` — the closed control line. `menuView()` — the dropdown, or `''`.
|
|
46
|
+
- `.open` — whether the menu is showing. `blur()` also closes it.
|
|
47
|
+
|
|
48
|
+
## Keys
|
|
49
|
+
|
|
50
|
+
The contract mirrors the other field controls. While **closed** it consumes only
|
|
51
|
+
`space` (to open) and never `enter`, so a parent form keeps `enter` for "submit".
|
|
52
|
+
While **open** it owns the menu — `↑`/`↓` move, `enter`/`space` commit, `esc`
|
|
53
|
+
cancels — which is fine because a form won't submit with a menu open. Bindings
|
|
54
|
+
are exported as `select.keys` for the [help](help.md) component.
|
package/index.js
CHANGED
|
@@ -37,10 +37,14 @@ const paginator = require('./components/paginator')
|
|
|
37
37
|
const stopwatch = require('./components/stopwatch')
|
|
38
38
|
const timer = require('./components/timer')
|
|
39
39
|
const filepicker = require('./components/filepicker')
|
|
40
|
+
const checkbox = require('./components/checkbox')
|
|
41
|
+
const radio = require('./components/radio')
|
|
42
|
+
const select = require('./components/select')
|
|
43
|
+
const focus = require('./components/focus')
|
|
40
44
|
|
|
41
45
|
module.exports = {
|
|
42
46
|
Program,
|
|
43
|
-
...commands, // quit, batch, sequence, tick, every
|
|
47
|
+
...commands, // quit, batch, sequence, tick, every, suspend
|
|
44
48
|
KeyMsg: messages.KeyMsg,
|
|
45
49
|
key, // key.matches(msg, ...chords | bindings), key.binding({ keys, help })
|
|
46
50
|
ansi,
|
|
@@ -59,5 +63,9 @@ module.exports = {
|
|
|
59
63
|
paginator, // paginator.create({ perPage, total, type }) — page state + indicator
|
|
60
64
|
stopwatch, // stopwatch.create({ interval }) — counts up; start/stop/toggle
|
|
61
65
|
timer, // timer.create({ timeout, interval }) — counts down; emits timer.timeout
|
|
62
|
-
filepicker // filepicker.create({ fs, path, cwd }) — browse + pick; filepicker.mock(tree)
|
|
66
|
+
filepicker, // filepicker.create({ fs, path, cwd }) — browse + pick; filepicker.mock(tree)
|
|
67
|
+
checkbox, // checkbox.create({ label, checked }) — boolean toggle (space)
|
|
68
|
+
radio, // radio.create({ options, selected }) — single choice; value()
|
|
69
|
+
select, // select.create({ options, placeholder }) — dropdown; view() + menuView()
|
|
70
|
+
focus // focus.create({ items }) — ordered focus ring across child components
|
|
63
71
|
}
|
package/package.json
CHANGED
package/program.js
CHANGED
|
@@ -62,6 +62,7 @@ module.exports = class Program {
|
|
|
62
62
|
this._wake = null
|
|
63
63
|
this._running = false
|
|
64
64
|
this._tornDown = false
|
|
65
|
+
this._suspended = false // true while the terminal is handed to a child process
|
|
65
66
|
|
|
66
67
|
this._decoder = null
|
|
67
68
|
this._onInput = null
|
|
@@ -125,6 +126,9 @@ module.exports = class Program {
|
|
|
125
126
|
// Mark the view dirty and schedule a render at most once per frame. Updates
|
|
126
127
|
// that land in the same frame collapse into one write.
|
|
127
128
|
_invalidate() {
|
|
129
|
+
// While suspended the terminal belongs to a child process; a render here
|
|
130
|
+
// would paint over it. We repaint in full on resume instead.
|
|
131
|
+
if (this._suspended) return
|
|
128
132
|
if (this._frameMs === 0) {
|
|
129
133
|
this.renderer.render(this._view())
|
|
130
134
|
return
|
|
@@ -247,6 +251,59 @@ module.exports = class Program {
|
|
|
247
251
|
}
|
|
248
252
|
}
|
|
249
253
|
|
|
254
|
+
// Hand the terminal back to the shell: stop decoding input, drop raw mode,
|
|
255
|
+
// stop reading stdin, and leave the alt-screen. Mirrors the terminal parts of
|
|
256
|
+
// _teardown, but keeps the model and loop alive.
|
|
257
|
+
//
|
|
258
|
+
// Crucially we must NOT close stdin's fd here: a child spawned with
|
|
259
|
+
// `stdio: 'inherit'` inherits fd 0 directly, and a closed fd would hand it a
|
|
260
|
+
// dead stdin (the editor exits instantly). So we detach + pause and leave the
|
|
261
|
+
// fd open for the child.
|
|
262
|
+
_suspendTerminal() {
|
|
263
|
+
this._suspended = true
|
|
264
|
+
this._cancelFrame()
|
|
265
|
+
try {
|
|
266
|
+
if (this.input && this._onInput) this.input.removeListener('data', this._onInput)
|
|
267
|
+
} catch {}
|
|
268
|
+
try {
|
|
269
|
+
if (this._decoder && this._onKey) this._decoder.removeListener('data', this._onKey)
|
|
270
|
+
} catch {}
|
|
271
|
+
try {
|
|
272
|
+
this._decoder?.destroy()
|
|
273
|
+
} catch {}
|
|
274
|
+
this._decoder = null
|
|
275
|
+
try {
|
|
276
|
+
if (this.input && this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(false)
|
|
277
|
+
} catch {}
|
|
278
|
+
try {
|
|
279
|
+
this.input?.pause?.()
|
|
280
|
+
} catch {}
|
|
281
|
+
try {
|
|
282
|
+
if (this._mouseMode) this.output.write(mouse.disable(this._mouseMode))
|
|
283
|
+
} catch {}
|
|
284
|
+
this.renderer.stop()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Reclaim the terminal after a suspend: re-enter the screen, restore raw mode,
|
|
288
|
+
// re-attach the decoder, resume reading, and force a full repaint.
|
|
289
|
+
_resumeTerminal() {
|
|
290
|
+
this.renderer.start()
|
|
291
|
+
if (this.input) {
|
|
292
|
+
try {
|
|
293
|
+
if (this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(true)
|
|
294
|
+
} catch {}
|
|
295
|
+
this._decoder = new KeyDecoder()
|
|
296
|
+
this._decoder.on('data', this._onKey)
|
|
297
|
+
this.input.on('data', this._onInput)
|
|
298
|
+
try {
|
|
299
|
+
this.input.resume?.()
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
if (this._mouseMode) this.output.write(mouse.enable(this._mouseMode))
|
|
303
|
+
this.renderer.clear() // next render repaints everything
|
|
304
|
+
this._suspended = false
|
|
305
|
+
}
|
|
306
|
+
|
|
250
307
|
// Normalise update()'s return into a [model, cmd] pair. Accepts a bare model
|
|
251
308
|
// (no cmd) or null (no change), so update() can be terse.
|
|
252
309
|
_update(msg) {
|
|
@@ -293,6 +350,23 @@ module.exports = class Program {
|
|
|
293
350
|
return
|
|
294
351
|
}
|
|
295
352
|
|
|
353
|
+
// suspend: hand the terminal to fn() (a child process), then resume.
|
|
354
|
+
if (cmd.__suspend) {
|
|
355
|
+
this._suspendTerminal()
|
|
356
|
+
let msg = null
|
|
357
|
+
try {
|
|
358
|
+
msg = await cmd.__suspend()
|
|
359
|
+
} catch (error) {
|
|
360
|
+
msg = { type: 'error', error }
|
|
361
|
+
}
|
|
362
|
+
if (this._running) {
|
|
363
|
+
this._resumeTerminal()
|
|
364
|
+
this._invalidate() // repaint the restored screen
|
|
365
|
+
}
|
|
366
|
+
this.send(msg)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
296
370
|
try {
|
|
297
371
|
this.send(await cmd())
|
|
298
372
|
} catch (error) {
|
package/style.js
CHANGED
|
@@ -354,9 +354,12 @@ class Style {
|
|
|
354
354
|
for (let i = 0; i < pad[0]; i++) lines.unshift(blank)
|
|
355
355
|
for (let i = 0; i < pad[2]; i++) lines.push(blank)
|
|
356
356
|
|
|
357
|
-
// 4. text styling — wraps padding too so a background fills the box
|
|
357
|
+
// 4. text styling — wraps padding too so a background fills the box.
|
|
358
|
+
// Re-apply the block's SGR after every reset *inside* the content, so a
|
|
359
|
+
// background (or any attribute) covers the whole line instead of dying at
|
|
360
|
+
// the first nested span's reset and leaving the remainder unstyled.
|
|
358
361
|
const open = this._open()
|
|
359
|
-
if (open) lines = lines.map((line) => open + line + RESET)
|
|
362
|
+
if (open) lines = lines.map((line) => open + line.split(RESET).join(RESET + open) + RESET)
|
|
360
363
|
|
|
361
364
|
// 5. border
|
|
362
365
|
if (p.border) lines = applyBorder(lines, innerW, p.border, p.borderSides, p.borderFg)
|
|
@@ -454,6 +457,7 @@ style.joinVertical = joinVertical
|
|
|
454
457
|
style.width = width
|
|
455
458
|
style.height = height
|
|
456
459
|
style.truncate = truncate
|
|
460
|
+
style.stripAnsi = stripAnsi
|
|
457
461
|
|
|
458
462
|
module.exports = {
|
|
459
463
|
style,
|