bare-tui 0.0.3 → 0.2.0
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 +10 -1
- package/ansi.js +8 -1
- package/input.js +110 -0
- package/messages.js +10 -1
- package/mouse.js +4 -46
- package/package.json +2 -1
- package/program.js +52 -14
package/README.md
CHANGED
|
@@ -140,7 +140,7 @@ const load = () =>
|
|
|
140
140
|
|
|
141
141
|
Return commands from `init` or `update`; the result comes back as a message.
|
|
142
142
|
|
|
143
|
-
## Key &
|
|
143
|
+
## Key, mouse & focus input
|
|
144
144
|
|
|
145
145
|
Keys arrive as `{ type: 'key' }` messages (a `KeyMsg`). Match them with `key.matches`, which is null- and type-safe:
|
|
146
146
|
|
|
@@ -163,6 +163,15 @@ Enable the mouse with a Program option; clicks/scroll/drag arrive as `{ type: 'm
|
|
|
163
163
|
new Program(model, { mouse: true }) // true | 'drag' | 'all'
|
|
164
164
|
```
|
|
165
165
|
|
|
166
|
+
Ask for focus reporting the same way, and the terminal tells you when its window gains or loses focus — handy for pausing an animation or muting a bell while the user is elsewhere:
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
new Program(model, { focus: true })
|
|
170
|
+
// update(msg): msg.type === 'focus' -> msg.focused is true on focus in, false on focus out
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Focus messages are **transitions**, not state: nothing is sent until the focus actually changes, so assume you start focused. They may also never arrive at all — Terminal.app, `screen` and the Linux console don't implement the mode, and under tmux the pane needs `set -g focus-events on`. Don't gate anything your app needs on receiving one.
|
|
174
|
+
|
|
166
175
|
## Components
|
|
167
176
|
|
|
168
177
|
Ready-made, composable pieces — each is a model (`update`/`view`) you embed in your own. See each doc for options, methods, messages, and keybindings.
|
package/ansi.js
CHANGED
|
@@ -30,5 +30,12 @@ module.exports = {
|
|
|
30
30
|
|
|
31
31
|
// SGR mouse tracking (button events + SGR extended coordinates).
|
|
32
32
|
enableMouse: CSI + '?1000h' + CSI + '?1006h',
|
|
33
|
-
disableMouse: CSI + '?1006l' + CSI + '?1000l'
|
|
33
|
+
disableMouse: CSI + '?1006l' + CSI + '?1000l',
|
|
34
|
+
|
|
35
|
+
// Focus reporting (DEC private mode 1004) — the terminal sends ESC [ I when
|
|
36
|
+
// the window gains focus and ESC [ O when it loses it. Plain constants rather
|
|
37
|
+
// than the enable(mode)/disable(mode) functions mouse.js exposes, because
|
|
38
|
+
// there is only one mode to set; see input.js for the reports themselves.
|
|
39
|
+
enableFocus: CSI + '?1004h',
|
|
40
|
+
disableFocus: CSI + '?1004l'
|
|
34
41
|
}
|
package/input.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Input pre-parser — peels the sequences that aren't keystrokes off the byte
|
|
2
|
+
// stream before the key decoder sees them.
|
|
3
|
+
//
|
|
4
|
+
// bare-ansi-escapes' KeyDecoder only understands keys, so anything else the
|
|
5
|
+
// terminal reports has to be claimed first: SGR mouse reports
|
|
6
|
+
// (\x1b[<b;x;yM / m) and focus reports (\x1b[I on focus in, \x1b[O on focus
|
|
7
|
+
// out). InputParser does both in a single pass with a single partial buffer and
|
|
8
|
+
// hands the remaining bytes on untouched:
|
|
9
|
+
//
|
|
10
|
+
// const parser = new InputParser({ mouse: 'basic', focus: true })
|
|
11
|
+
// const { keys, events } = parser.feed(chunk)
|
|
12
|
+
//
|
|
13
|
+
// Each stream is opt-in: with `focus` off a focus report is left in `keys` and
|
|
14
|
+
// reaches the decoder exactly as it does today (as a key named 'undefined'), so
|
|
15
|
+
// enabling one mode never changes the other's behaviour. latin1 throughout so
|
|
16
|
+
// non-claimed bytes — including 8-bit meta keys — round-trip intact.
|
|
17
|
+
const { decode } = require('./mouse')
|
|
18
|
+
const { focusMsg } = require('./messages')
|
|
19
|
+
|
|
20
|
+
const ESC = '\x1b'
|
|
21
|
+
|
|
22
|
+
// An SGR mouse body is "b;x;y" — at most a handful of digits. Bounding the scan
|
|
23
|
+
// keeps a stray \x1b[< (which a user can type) from swallowing the rest of the
|
|
24
|
+
// session while it hunts for a terminator that never comes.
|
|
25
|
+
const MAX_MOUSE_BODY = 24
|
|
26
|
+
|
|
27
|
+
class InputParser {
|
|
28
|
+
constructor({ mouse = null, focus = false } = {}) {
|
|
29
|
+
this.mouse = !!mouse
|
|
30
|
+
this.focus = !!focus
|
|
31
|
+
this._partial = ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Drop any half-claimed sequence. Called when the terminal is handed to a
|
|
35
|
+
// child process and reclaimed, so a prefix from before the suspend can't
|
|
36
|
+
// corrupt the first bytes read after it.
|
|
37
|
+
reset() {
|
|
38
|
+
this._partial = ''
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
feed(buf) {
|
|
42
|
+
const s = this._partial + buf.toString('latin1')
|
|
43
|
+
this._partial = ''
|
|
44
|
+
|
|
45
|
+
let keys = ''
|
|
46
|
+
const events = []
|
|
47
|
+
let i = 0
|
|
48
|
+
|
|
49
|
+
while (i < s.length) {
|
|
50
|
+
// Only CSI (ESC [) introduces something we might claim. This is also what
|
|
51
|
+
// keeps SS3 (ESC O, the prefix for F1-F4 and application-mode arrows)
|
|
52
|
+
// safe: it has no '[', so it never looks like a focus report.
|
|
53
|
+
if (s[i] !== ESC || s[i + 1] !== '[') {
|
|
54
|
+
keys += s[i++]
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The read ended on a bare "ESC [". Hold it: the decoder would park on
|
|
59
|
+
// those same two bytes and emit nothing either way (its 500ms escape
|
|
60
|
+
// timer only arms when ESC is the *last* byte written), so holding costs
|
|
61
|
+
// no latency and lets a report split across reads still be recognised. A
|
|
62
|
+
// lone trailing ESC is never held — that one does time out into an
|
|
63
|
+
// escape key, and swallowing it would break the Escape key.
|
|
64
|
+
if (i + 2 >= s.length) {
|
|
65
|
+
this._partial = s.slice(i)
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const c = s[i + 2]
|
|
70
|
+
|
|
71
|
+
if (this.focus && (c === 'I' || c === 'O')) {
|
|
72
|
+
events.push(focusMsg(c === 'I'))
|
|
73
|
+
i += 3
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (this.mouse && c === '<') {
|
|
78
|
+
const cap = i + 3 + MAX_MOUSE_BODY
|
|
79
|
+
let j = i + 3
|
|
80
|
+
while (j < s.length && j < cap && ((s[j] >= '0' && s[j] <= '9') || s[j] === ';')) j++
|
|
81
|
+
|
|
82
|
+
// Stopped because the input ran out (rather than because the body is
|
|
83
|
+
// overlong or ended in something that isn't a terminator): wait for the
|
|
84
|
+
// rest of the report.
|
|
85
|
+
if (j === s.length && j < cap) {
|
|
86
|
+
this._partial = s.slice(i)
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (s[j] === 'M' || s[j] === 'm') {
|
|
91
|
+
const event = decode(s.slice(i + 3, j), s[j])
|
|
92
|
+
if (event) {
|
|
93
|
+
events.push(event)
|
|
94
|
+
i = j + 1
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Malformed or overlong — not a report after all; fall through and let
|
|
99
|
+
// the bytes go to the decoder as keys.
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Not ours: emit the ESC and re-examine the rest as ordinary key bytes.
|
|
103
|
+
keys += s[i++]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { keys: Buffer.from(keys, 'latin1'), events }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { InputParser }
|
package/messages.js
CHANGED
|
@@ -47,6 +47,15 @@ function windowSize(width, height) {
|
|
|
47
47
|
return { type: 'resize', width, height }
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Emitted when the terminal window gains or loses focus. Only ever produced
|
|
51
|
+
// when the Program was created with `focus: true`, which puts the terminal into
|
|
52
|
+
// focus reporting (DEC private mode 1004). Reports are *transitions*: a terminal
|
|
53
|
+
// that supports the mode says nothing until the focus actually changes, so a
|
|
54
|
+
// model should assume it starts focused rather than wait to be told.
|
|
55
|
+
function focusMsg(focused) {
|
|
56
|
+
return { type: 'focus', focused }
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
// The runtime tears down and exits when it sees this. `quit` (see commands.js)
|
|
51
60
|
// is the Cmd that produces it.
|
|
52
61
|
function quitMsg() {
|
|
@@ -59,4 +68,4 @@ function errorMsg(error) {
|
|
|
59
68
|
return { type: 'error', error }
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
module.exports = { KeyMsg, windowSize, quitMsg, errorMsg }
|
|
71
|
+
module.exports = { KeyMsg, windowSize, focusMsg, quitMsg, errorMsg }
|
package/mouse.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Mouse support — SGR (1006) tracking and decoding.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// stream as MouseMsgs and forwards everything else to the key decoder.
|
|
3
|
+
// This module owns the mode strings and the report decoder; pulling the reports
|
|
4
|
+
// out of the raw byte stream is input.js's job (it claims \x1b[<b;x;yM for
|
|
5
|
+
// press / motion and \x1b[<b;x;ym for release, and calls decode() on the body).
|
|
7
6
|
//
|
|
8
7
|
// A MouseMsg looks like:
|
|
9
8
|
// { type: 'mouse', action, button, x, y, ctrl, alt, shift }
|
|
@@ -58,45 +57,4 @@ function decode(body, final) {
|
|
|
58
57
|
return { type: 'mouse', action, button, x: col - 1, y: row - 1, ...mods }
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
// incomplete trailing mouse sequence between feeds; uses latin1 throughout so
|
|
63
|
-
// non-mouse bytes (including 8-bit meta keys) round-trip to the decoder intact.
|
|
64
|
-
//
|
|
65
|
-
// Note: a mouse report split across two reads *before* its `<` arrives can't be
|
|
66
|
-
// distinguished from a key escape, so we only buffer once `\x1b[<` is seen.
|
|
67
|
-
// Terminals emit each report in a single write, so this is not a problem in
|
|
68
|
-
// practice.
|
|
69
|
-
class MouseParser {
|
|
70
|
-
constructor() {
|
|
71
|
-
this._partial = ''
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
feed(buf) {
|
|
75
|
-
const s = this._partial + buf.toString('latin1')
|
|
76
|
-
this._partial = ''
|
|
77
|
-
|
|
78
|
-
let keys = ''
|
|
79
|
-
const events = []
|
|
80
|
-
let i = 0
|
|
81
|
-
while (i < s.length) {
|
|
82
|
-
if (s[i] === '\x1b' && s[i + 1] === '[' && s[i + 2] === '<') {
|
|
83
|
-
let j = i + 3
|
|
84
|
-
while (j < s.length && s[j] !== 'M' && s[j] !== 'm') j++
|
|
85
|
-
if (j >= s.length) {
|
|
86
|
-
this._partial = s.slice(i) // incomplete report; wait for more
|
|
87
|
-
break
|
|
88
|
-
}
|
|
89
|
-
const ev = decode(s.slice(i + 3, j), s[j])
|
|
90
|
-
if (ev) events.push(ev)
|
|
91
|
-
i = j + 1
|
|
92
|
-
} else {
|
|
93
|
-
keys += s[i]
|
|
94
|
-
i++
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return { keys: Buffer.from(keys, 'latin1'), events }
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
module.exports = { enable, disable, decode, MouseParser, MODES }
|
|
60
|
+
module.exports = { enable, disable, decode, MODES }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-tui",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A little TUI framework for Bare, based on bubbletea",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"index.js",
|
|
16
16
|
"index.d.ts",
|
|
17
17
|
"ansi.js",
|
|
18
|
+
"input.js",
|
|
18
19
|
"commands.js",
|
|
19
20
|
"key.js",
|
|
20
21
|
"messages.js",
|
package/program.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// view() -> string render current state to text
|
|
7
7
|
//
|
|
8
8
|
// Program wires those to the terminal: it puts the input into raw mode, decodes
|
|
9
|
-
// keystrokes into KeyMsgs,
|
|
9
|
+
// keystrokes into KeyMsgs, claims the terminal's non-key reports (mouse, and
|
|
10
|
+
// focus when `focus: true`), turns SIGWINCH into resize Msgs, runs the
|
|
10
11
|
// update/render loop, executes Cmds off the update path, and — crucially —
|
|
11
12
|
// always restores the terminal on the way out.
|
|
12
13
|
//
|
|
@@ -16,7 +17,9 @@
|
|
|
16
17
|
const tty = require('bare-tty')
|
|
17
18
|
const KeyDecoder = require('bare-ansi-escapes/key-decoder')
|
|
18
19
|
const Renderer = require('./renderer')
|
|
20
|
+
const ansi = require('./ansi')
|
|
19
21
|
const mouse = require('./mouse')
|
|
22
|
+
const { InputParser } = require('./input')
|
|
20
23
|
const { KeyMsg, windowSize } = require('./messages')
|
|
21
24
|
|
|
22
25
|
module.exports = class Program {
|
|
@@ -36,7 +39,16 @@ module.exports = class Program {
|
|
|
36
39
|
// 'all' → + hover motion. Off by default.
|
|
37
40
|
const m = opts.mouse
|
|
38
41
|
this._mouseMode = m === true ? 'basic' : m === 'motion' ? 'drag' : m in mouse.MODES ? m : null
|
|
39
|
-
|
|
42
|
+
|
|
43
|
+
// Focus reporting (DEC mode 1004): the terminal reports when its window
|
|
44
|
+
// gains or loses focus as { type: 'focus', focused }. Off by default — not
|
|
45
|
+
// every terminal implements it (Terminal.app and screen don't; tmux needs
|
|
46
|
+
// `focus-events on`), and an app that doesn't care shouldn't pay for it.
|
|
47
|
+
this._focus = opts.focus === true
|
|
48
|
+
|
|
49
|
+
// Claims mouse / focus reports before the key decoder. Null when neither is
|
|
50
|
+
// enabled, so the common case writes bytes straight through.
|
|
51
|
+
this._parser = null
|
|
40
52
|
|
|
41
53
|
// Only TTY fds can be put in raw mode / sized, and constructing a
|
|
42
54
|
// tty.WriteStream on a non-TTY fd throws — so fall back to a no-op-ish
|
|
@@ -151,6 +163,28 @@ module.exports = class Program {
|
|
|
151
163
|
}
|
|
152
164
|
}
|
|
153
165
|
|
|
166
|
+
// The pre-parser only exists when there's something to claim; otherwise input
|
|
167
|
+
// bytes go straight to the key decoder.
|
|
168
|
+
_newParser() {
|
|
169
|
+
if (!this._mouseMode && !this._focus) return null
|
|
170
|
+
return new InputParser({ mouse: this._mouseMode, focus: this._focus })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The input reporting modes go on together after the screen is entered and
|
|
174
|
+
// come off together before it's restored, so they never outlive the program
|
|
175
|
+
// (_teardown) or leak into a child process (_suspendTerminal).
|
|
176
|
+
_enableModes() {
|
|
177
|
+
if (this._mouseMode) this.output.write(mouse.enable(this._mouseMode))
|
|
178
|
+
if (this._focus) this.output.write(ansi.enableFocus)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_disableModes() {
|
|
182
|
+
try {
|
|
183
|
+
if (this._mouseMode) this.output.write(mouse.disable(this._mouseMode))
|
|
184
|
+
if (this._focus) this.output.write(ansi.disableFocus)
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
|
|
154
188
|
_setup() {
|
|
155
189
|
if (this.input) {
|
|
156
190
|
if (this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(true)
|
|
@@ -159,12 +193,12 @@ module.exports = class Program {
|
|
|
159
193
|
// unpipe, and a piped source destroyed mid-stream (which is exactly what
|
|
160
194
|
// teardown does) destroys the destination with a synthetic "closed before
|
|
161
195
|
// ending" error. Manual forwarding has no Pipeline, so teardown is clean.
|
|
162
|
-
this.
|
|
196
|
+
this._parser = this._newParser()
|
|
163
197
|
this._onKey = (key) => this.send(new KeyMsg(key))
|
|
164
198
|
this._onInput = (data) => {
|
|
165
|
-
if (this.
|
|
166
|
-
// Peel mouse reports off the stream; the rest is keys.
|
|
167
|
-
const { keys, events } = this.
|
|
199
|
+
if (this._parser) {
|
|
200
|
+
// Peel mouse / focus reports off the stream; the rest is keys.
|
|
201
|
+
const { keys, events } = this._parser.feed(data)
|
|
168
202
|
for (const event of events) this.send(event)
|
|
169
203
|
if (keys.length) this._decoder.write(keys)
|
|
170
204
|
} else {
|
|
@@ -181,7 +215,7 @@ module.exports = class Program {
|
|
|
181
215
|
}
|
|
182
216
|
|
|
183
217
|
this.renderer.start()
|
|
184
|
-
|
|
218
|
+
this._enableModes()
|
|
185
219
|
|
|
186
220
|
// Seed the model with the initial geometry. Real TTYs report columns/rows;
|
|
187
221
|
// injected streams won't, so fall back to opts then a sane default.
|
|
@@ -231,14 +265,15 @@ module.exports = class Program {
|
|
|
231
265
|
try {
|
|
232
266
|
this._decoder?.destroy()
|
|
233
267
|
} catch {}
|
|
268
|
+
// Stop the reporting modes before leaving raw mode: in between the terminal
|
|
269
|
+
// is line-buffered and echoing, so a report landing in that window would be
|
|
270
|
+
// painted onto the screen we're about to hand back.
|
|
271
|
+
this._disableModes()
|
|
234
272
|
try {
|
|
235
273
|
if (this.input && this.inputIsTTY && this.input.setRawMode) {
|
|
236
274
|
this.input.setRawMode(false)
|
|
237
275
|
}
|
|
238
276
|
} catch {}
|
|
239
|
-
try {
|
|
240
|
-
if (this._mouseMode) this.output.write(mouse.disable(this._mouseMode))
|
|
241
|
-
} catch {}
|
|
242
277
|
|
|
243
278
|
this.renderer.stop() // show cursor, leave alt screen
|
|
244
279
|
|
|
@@ -272,15 +307,17 @@ module.exports = class Program {
|
|
|
272
307
|
this._decoder?.destroy()
|
|
273
308
|
} catch {}
|
|
274
309
|
this._decoder = null
|
|
310
|
+
// A half-claimed sequence must not survive into the resumed session.
|
|
311
|
+
this._parser?.reset()
|
|
312
|
+
// Off before the child runs: a program that doesn't understand these reports
|
|
313
|
+
// would read them as garbage input.
|
|
314
|
+
this._disableModes()
|
|
275
315
|
try {
|
|
276
316
|
if (this.input && this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(false)
|
|
277
317
|
} catch {}
|
|
278
318
|
try {
|
|
279
319
|
this.input?.pause?.()
|
|
280
320
|
} catch {}
|
|
281
|
-
try {
|
|
282
|
-
if (this._mouseMode) this.output.write(mouse.disable(this._mouseMode))
|
|
283
|
-
} catch {}
|
|
284
321
|
this.renderer.stop()
|
|
285
322
|
}
|
|
286
323
|
|
|
@@ -293,13 +330,14 @@ module.exports = class Program {
|
|
|
293
330
|
if (this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(true)
|
|
294
331
|
} catch {}
|
|
295
332
|
this._decoder = new KeyDecoder()
|
|
333
|
+
this._parser = this._newParser()
|
|
296
334
|
this._decoder.on('data', this._onKey)
|
|
297
335
|
this.input.on('data', this._onInput)
|
|
298
336
|
try {
|
|
299
337
|
this.input.resume?.()
|
|
300
338
|
} catch {}
|
|
301
339
|
}
|
|
302
|
-
|
|
340
|
+
this._enableModes()
|
|
303
341
|
this.renderer.clear() // next render repaints everything
|
|
304
342
|
this._suspended = false
|
|
305
343
|
}
|