bare-tui 0.0.0 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +13 -0
- package/README.md +244 -0
- package/ansi.js +34 -0
- package/commands.js +51 -0
- package/components/autocomplete.js +208 -0
- package/components/filepicker.js +232 -0
- package/components/help.js +117 -0
- package/components/list.js +172 -0
- package/components/paginator.js +111 -0
- package/components/progress.js +78 -0
- package/components/spinner.js +70 -0
- package/components/stopwatch.js +88 -0
- package/components/table.js +130 -0
- package/components/textarea.js +279 -0
- package/components/textinput.js +135 -0
- package/components/timer.js +91 -0
- package/components/viewport.js +104 -0
- package/docs/autocomplete.md +80 -0
- package/docs/filepicker.md +80 -0
- package/docs/help.md +45 -0
- package/docs/list.md +47 -0
- package/docs/paginator.md +43 -0
- package/docs/progress.md +36 -0
- package/docs/spinner.md +54 -0
- package/docs/stopwatch.md +52 -0
- package/docs/table.md +52 -0
- package/docs/textarea.md +43 -0
- package/docs/textinput.md +51 -0
- package/docs/timer.md +57 -0
- package/docs/viewport.md +39 -0
- package/index.d.ts +1 -0
- package/index.js +63 -0
- package/key.js +29 -0
- package/messages.js +62 -0
- package/mouse.js +102 -0
- package/package.json +59 -1
- package/program.js +313 -0
- package/renderer.js +67 -0
- package/style.js +469 -0
package/key.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Key matching helpers — the ergonomic front door to KeyMsg in update().
|
|
2
|
+
//
|
|
3
|
+
// const { key } = require('./lib/tea')
|
|
4
|
+
// if (key.matches(msg, 'q', 'ctrl+c')) return [model, quit]
|
|
5
|
+
//
|
|
6
|
+
// matches() is null/type-safe: it returns false for any non-key Msg, so you
|
|
7
|
+
// don't have to guard `msg.type === 'key'` first.
|
|
8
|
+
|
|
9
|
+
// A reusable named binding: the chords that trigger an action plus optional
|
|
10
|
+
// help text. Shaped for a future help component, but useful now as a single
|
|
11
|
+
// place to define an action's keys.
|
|
12
|
+
// const up = key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } })
|
|
13
|
+
// if (key.matches(msg, up)) ...
|
|
14
|
+
function binding({ keys = [], help = null } = {}) {
|
|
15
|
+
return { keys: [].concat(keys), help }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// True if `msg` is a key matching any of the given chords or bindings. Bindings
|
|
19
|
+
// are expanded to their `keys`, so you can mix:
|
|
20
|
+
// key.matches(msg, 'enter', someBinding, 'ctrl+c')
|
|
21
|
+
function matches(msg, ...items) {
|
|
22
|
+
if (!msg || msg.type !== 'key' || typeof msg.is !== 'function') return false
|
|
23
|
+
const chords = items.flatMap((item) =>
|
|
24
|
+
item && typeof item === 'object' && Array.isArray(item.keys) ? item.keys : [item]
|
|
25
|
+
)
|
|
26
|
+
return msg.is(...chords)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { matches, binding }
|
package/messages.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Messages (Msg) are the only thing that flows into a model's update().
|
|
2
|
+
//
|
|
3
|
+
// A Msg is just a tagged plain object — authors can define their own. These are
|
|
4
|
+
// the ones the runtime itself produces. We keep the shapes small and stable so
|
|
5
|
+
// they read the same as Bubble Tea's KeyMsg / WindowSizeMsg / QuitMsg.
|
|
6
|
+
|
|
7
|
+
// KeyMsg wraps a decoded key from bare-ansi-escapes' KeyDecoder. The raw fields
|
|
8
|
+
// (name, ctrl, meta, shift, sequence) are preserved; toString() renders the
|
|
9
|
+
// Bubble Tea-style chord ("ctrl+c", "up", "enter") so update() can match on a
|
|
10
|
+
// single string instead of juggling booleans.
|
|
11
|
+
class KeyMsg {
|
|
12
|
+
constructor(key) {
|
|
13
|
+
this.type = 'key'
|
|
14
|
+
this.name = key.name
|
|
15
|
+
this.sequence = key.sequence
|
|
16
|
+
this.ctrl = key.ctrl
|
|
17
|
+
this.meta = key.meta
|
|
18
|
+
this.shift = key.shift
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
toString() {
|
|
22
|
+
const parts = []
|
|
23
|
+
if (this.ctrl) parts.push('ctrl')
|
|
24
|
+
if (this.meta) parts.push('alt')
|
|
25
|
+
// Only surface shift for named keys; letters already arrive upper/lower.
|
|
26
|
+
if (this.shift && this.name && this.name.length > 1) parts.push('shift')
|
|
27
|
+
parts.push(this.name === 'return' ? 'enter' : this.name)
|
|
28
|
+
return parts.join('+')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// True if this key matches any of the given chords. A chord is matched
|
|
32
|
+
// against both the full string form ("ctrl+c", "enter") and the bare name
|
|
33
|
+
// ("c", "return"), so 'enter'/'return' and 'esc'/'escape' both work.
|
|
34
|
+
// if (msg.is('q', 'ctrl+c')) ...
|
|
35
|
+
is(...chords) {
|
|
36
|
+
const str = this.toString()
|
|
37
|
+
for (let chord of chords) {
|
|
38
|
+
if (chord === 'esc') chord = 'escape'
|
|
39
|
+
if (chord === str || chord === this.name) return true
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Emitted on startup and whenever the terminal is resized.
|
|
46
|
+
function windowSize(width, height) {
|
|
47
|
+
return { type: 'resize', width, height }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The runtime tears down and exits when it sees this. `quit` (see commands.js)
|
|
51
|
+
// is the Cmd that produces it.
|
|
52
|
+
function quitMsg() {
|
|
53
|
+
return { type: 'quit' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Wraps an error thrown by a Cmd so it can be handled in update() rather than
|
|
57
|
+
// crashing the loop.
|
|
58
|
+
function errorMsg(error) {
|
|
59
|
+
return { type: 'error', error }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { KeyMsg, windowSize, quitMsg, errorMsg }
|
package/mouse.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Mouse support — SGR (1006) tracking and decoding.
|
|
2
|
+
//
|
|
3
|
+
// bare-ansi-escapes' KeyDecoder doesn't understand mouse reports, so the
|
|
4
|
+
// Program runs raw input through MouseParser first: it pulls SGR sequences
|
|
5
|
+
// (\x1b[<b;x;yM for press / motion, \x1b[<b;x;ym for release) out of the byte
|
|
6
|
+
// stream as MouseMsgs and forwards everything else to the key decoder.
|
|
7
|
+
//
|
|
8
|
+
// A MouseMsg looks like:
|
|
9
|
+
// { type: 'mouse', action, button, x, y, ctrl, alt, shift }
|
|
10
|
+
// action: 'press' | 'release' | 'motion' | 'wheel'
|
|
11
|
+
// button: 'left' | 'middle' | 'right' | 'none' | 'wheelup' | 'wheeldown'
|
|
12
|
+
// x, y: zero-indexed cell coordinates
|
|
13
|
+
const { constants } = require('bare-ansi-escapes')
|
|
14
|
+
const CSI = constants.CSI
|
|
15
|
+
|
|
16
|
+
const SGR = '?1006' // SGR extended coordinates (no 223-column cap, clean parse)
|
|
17
|
+
const MODES = {
|
|
18
|
+
basic: '?1000', // press / release
|
|
19
|
+
drag: '?1002', // + motion while a button is held
|
|
20
|
+
all: '?1003' // + motion with no button (hover)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function enable(mode = 'basic') {
|
|
24
|
+
const m = MODES[mode] || MODES.basic
|
|
25
|
+
return CSI + m + 'h' + CSI + SGR + 'h'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function disable(mode = 'basic') {
|
|
29
|
+
const m = MODES[mode] || MODES.basic
|
|
30
|
+
return CSI + SGR + 'l' + CSI + m + 'l'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BUTTONS = ['left', 'middle', 'right', 'none']
|
|
34
|
+
|
|
35
|
+
// Decode the "b;x;y" body of an SGR mouse report plus its final char.
|
|
36
|
+
function decode(body, final) {
|
|
37
|
+
const parts = body.split(';')
|
|
38
|
+
if (parts.length !== 3) return null
|
|
39
|
+
const b = Number(parts[0])
|
|
40
|
+
const col = Number(parts[1])
|
|
41
|
+
const row = Number(parts[2])
|
|
42
|
+
if (!Number.isInteger(b) || !Number.isInteger(col) || !Number.isInteger(row)) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const mods = { ctrl: !!(b & 16), alt: !!(b & 8), shift: !!(b & 4) }
|
|
47
|
+
|
|
48
|
+
let action
|
|
49
|
+
let button
|
|
50
|
+
if (b & 64) {
|
|
51
|
+
action = 'wheel'
|
|
52
|
+
button = b & 1 ? 'wheeldown' : 'wheelup'
|
|
53
|
+
} else {
|
|
54
|
+
button = BUTTONS[b & 3]
|
|
55
|
+
action = b & 32 ? 'motion' : final === 'M' ? 'press' : 'release'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { type: 'mouse', action, button, x: col - 1, y: row - 1, ...mods }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Splits a byte stream into MouseMsgs and the remaining key bytes. Holds an
|
|
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 }
|
package/package.json
CHANGED
|
@@ -1 +1,59 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "bare-tui",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A little TUI framework for Bare, based on bubbletea",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./package": "./package.json",
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./index.d.ts",
|
|
10
|
+
"default": "./index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"package.json",
|
|
15
|
+
"index.js",
|
|
16
|
+
"index.d.ts",
|
|
17
|
+
"ansi.js",
|
|
18
|
+
"commands.js",
|
|
19
|
+
"key.js",
|
|
20
|
+
"messages.js",
|
|
21
|
+
"mouse.js",
|
|
22
|
+
"program.js",
|
|
23
|
+
"renderer.js",
|
|
24
|
+
"style.js",
|
|
25
|
+
"components",
|
|
26
|
+
"docs",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"NOTICE"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"bare-ansi-escapes": "^2.2.3",
|
|
33
|
+
"bare-tty": "^5.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"bare-fs": "^4.7.1",
|
|
37
|
+
"bare-path": "^3.0.0",
|
|
38
|
+
"bare-stream": "^2.13.1",
|
|
39
|
+
"brittle": "^3.19.0",
|
|
40
|
+
"lunte": "^1.2.0",
|
|
41
|
+
"prettier": "^3.6.2",
|
|
42
|
+
"prettier-config-holepunch": "^2.0.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"format": "prettier --write . && lunte --fix",
|
|
46
|
+
"lint": "prettier --check . && lunte",
|
|
47
|
+
"test": "brittle-bare --coverage test/index.js"
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "https://github.com/holepunchto/bare-tui.git"
|
|
52
|
+
},
|
|
53
|
+
"author": "Holepunch",
|
|
54
|
+
"license": "Apache-2.0",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/holepunchto/bare-tui/issues"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/holepunchto/bare-tui"
|
|
59
|
+
}
|
package/program.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Program is the runtime — the event loop that drives one model.
|
|
2
|
+
//
|
|
3
|
+
// The Elm Architecture in three methods on a model:
|
|
4
|
+
// init() -> Cmd | null run once at startup
|
|
5
|
+
// update(msg) -> [model, Cmd] | model fold a Msg into new state
|
|
6
|
+
// view() -> string render current state to text
|
|
7
|
+
//
|
|
8
|
+
// Program wires those to the terminal: it puts the input into raw mode, decodes
|
|
9
|
+
// keystrokes into KeyMsgs, turns SIGWINCH into resize Msgs, runs the
|
|
10
|
+
// update/render loop, executes Cmds off the update path, and — crucially —
|
|
11
|
+
// always restores the terminal on the way out.
|
|
12
|
+
//
|
|
13
|
+
// IO is injectable. By default it grabs the real TTY (fd 0/1); tests pass their
|
|
14
|
+
// own streams plus `isTTY: true` to exercise the full escape-sequence path
|
|
15
|
+
// without a terminal.
|
|
16
|
+
const tty = require('bare-tty')
|
|
17
|
+
const KeyDecoder = require('bare-ansi-escapes/key-decoder')
|
|
18
|
+
const Renderer = require('./renderer')
|
|
19
|
+
const mouse = require('./mouse')
|
|
20
|
+
const { KeyMsg, windowSize } = require('./messages')
|
|
21
|
+
|
|
22
|
+
module.exports = class Program {
|
|
23
|
+
constructor(model, opts = {}) {
|
|
24
|
+
this.model = model
|
|
25
|
+
this.opts = opts
|
|
26
|
+
this.altScreen = opts.altScreen !== false
|
|
27
|
+
|
|
28
|
+
// Frame coalescing: many Msgs arriving within one frame produce a single
|
|
29
|
+
// render. fps <= 0 renders synchronously per update (handy in tests).
|
|
30
|
+
this.fps = opts.fps ?? 60
|
|
31
|
+
this._frameMs = this.fps > 0 ? Math.max(1, Math.round(1000 / this.fps)) : 0
|
|
32
|
+
this._frameTimer = null
|
|
33
|
+
this._needsRender = false
|
|
34
|
+
|
|
35
|
+
// Mouse tracking: true → press/release, 'drag' → + held-button motion,
|
|
36
|
+
// 'all' → + hover motion. Off by default.
|
|
37
|
+
const m = opts.mouse
|
|
38
|
+
this._mouseMode = m === true ? 'basic' : m === 'motion' ? 'drag' : m in mouse.MODES ? m : null
|
|
39
|
+
this._mouseParser = null
|
|
40
|
+
|
|
41
|
+
// Only TTY fds can be put in raw mode / sized, and constructing a
|
|
42
|
+
// tty.WriteStream on a non-TTY fd throws — so fall back to a no-op-ish
|
|
43
|
+
// stream when there's no real terminal and nothing was injected.
|
|
44
|
+
this._ownsInput = !opts.input
|
|
45
|
+
this._ownsOutput = !opts.output
|
|
46
|
+
this.input = opts.input || (tty.isTTY(0) ? new tty.ReadStream(0) : null)
|
|
47
|
+
this.output = opts.output || (tty.isTTY(1) ? new tty.WriteStream(1) : null)
|
|
48
|
+
|
|
49
|
+
// `isTTY` override lets headless tests drive the real rendering path.
|
|
50
|
+
const detected = (s) => !!(s && s.isTTY)
|
|
51
|
+
this.inputIsTTY = opts.isTTY ?? detected(this.input)
|
|
52
|
+
this.outputIsTTY = opts.isTTY ?? detected(this.output)
|
|
53
|
+
|
|
54
|
+
if (!this.output) {
|
|
55
|
+
throw new Error('tea: no output stream (not a TTY); pass opts.output')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.renderer = new Renderer(this.output, { altScreen: this.altScreen })
|
|
59
|
+
|
|
60
|
+
// Single-consumer async message queue. send() wakes the loop.
|
|
61
|
+
this._queue = []
|
|
62
|
+
this._wake = null
|
|
63
|
+
this._running = false
|
|
64
|
+
this._tornDown = false
|
|
65
|
+
|
|
66
|
+
this._decoder = null
|
|
67
|
+
this._onInput = null
|
|
68
|
+
this._onKey = null
|
|
69
|
+
this._onResize = null
|
|
70
|
+
this._signals = []
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Enqueue a Msg from anywhere — key decoder, resize handler, Cmd result, or
|
|
74
|
+
// external code (e.g. a worker IPC bridge calling program.send(...)).
|
|
75
|
+
send(msg) {
|
|
76
|
+
if (!msg) return
|
|
77
|
+
this._queue.push(msg)
|
|
78
|
+
if (this._wake) {
|
|
79
|
+
const wake = this._wake
|
|
80
|
+
this._wake = null
|
|
81
|
+
wake()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
quit() {
|
|
86
|
+
this.send({ type: 'quit' })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async run() {
|
|
90
|
+
this._running = true
|
|
91
|
+
// try/finally guarantees the terminal is restored even if init/update/view
|
|
92
|
+
// throws — otherwise a single bad model would leave the user in raw mode and
|
|
93
|
+
// the alt-screen. The error still propagates after cleanup.
|
|
94
|
+
try {
|
|
95
|
+
this._setup()
|
|
96
|
+
|
|
97
|
+
if (typeof this.model.init === 'function') this._exec(this.model.init())
|
|
98
|
+
this.renderer.render(this._view()) // first frame before any input
|
|
99
|
+
|
|
100
|
+
while (this._running) {
|
|
101
|
+
const msg = await this._next()
|
|
102
|
+
if (!msg) continue
|
|
103
|
+
if (msg.type === 'quit') break
|
|
104
|
+
if (msg.type === 'resize') this.renderer.clear() // geometry changed: repaint
|
|
105
|
+
|
|
106
|
+
const [model, cmd] = this._update(msg)
|
|
107
|
+
this.model = model
|
|
108
|
+
this._invalidate() // coalesced render
|
|
109
|
+
this._exec(cmd)
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
this._running = false
|
|
113
|
+
this._cancelFrame()
|
|
114
|
+
// Flush any pending coalesced frame so the final state is the last thing
|
|
115
|
+
// drawn (matters for inline mode; harmless under the alt-screen).
|
|
116
|
+
if (this._needsRender) {
|
|
117
|
+
this._needsRender = false
|
|
118
|
+
this.renderer.render(this._view())
|
|
119
|
+
}
|
|
120
|
+
this._teardown()
|
|
121
|
+
}
|
|
122
|
+
return this.model
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Mark the view dirty and schedule a render at most once per frame. Updates
|
|
126
|
+
// that land in the same frame collapse into one write.
|
|
127
|
+
_invalidate() {
|
|
128
|
+
if (this._frameMs === 0) {
|
|
129
|
+
this.renderer.render(this._view())
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
this._needsRender = true
|
|
133
|
+
if (this._frameTimer) return
|
|
134
|
+
this._frameTimer = setTimeout(() => {
|
|
135
|
+
this._frameTimer = null
|
|
136
|
+
if (this._needsRender) {
|
|
137
|
+
this._needsRender = false
|
|
138
|
+
this.renderer.render(this._view())
|
|
139
|
+
}
|
|
140
|
+
}, this._frameMs)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_cancelFrame() {
|
|
144
|
+
if (this._frameTimer) {
|
|
145
|
+
clearTimeout(this._frameTimer)
|
|
146
|
+
this._frameTimer = null
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_setup() {
|
|
151
|
+
if (this.input) {
|
|
152
|
+
if (this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(true)
|
|
153
|
+
this._decoder = new KeyDecoder()
|
|
154
|
+
// Forward bytes manually instead of input.pipe(decoder): streamx has no
|
|
155
|
+
// unpipe, and a piped source destroyed mid-stream (which is exactly what
|
|
156
|
+
// teardown does) destroys the destination with a synthetic "closed before
|
|
157
|
+
// ending" error. Manual forwarding has no Pipeline, so teardown is clean.
|
|
158
|
+
this._mouseParser = this._mouseMode ? new mouse.MouseParser() : null
|
|
159
|
+
this._onKey = (key) => this.send(new KeyMsg(key))
|
|
160
|
+
this._onInput = (data) => {
|
|
161
|
+
if (this._mouseParser) {
|
|
162
|
+
// Peel mouse reports off the stream; the rest is keys.
|
|
163
|
+
const { keys, events } = this._mouseParser.feed(data)
|
|
164
|
+
for (const event of events) this.send(event)
|
|
165
|
+
if (keys.length) this._decoder.write(keys)
|
|
166
|
+
} else {
|
|
167
|
+
this._decoder.write(data)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this._decoder.on('data', this._onKey)
|
|
171
|
+
this.input.on('data', this._onInput)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (this.outputIsTTY && typeof this.output.on === 'function') {
|
|
175
|
+
this._onResize = () => this.send(windowSize(this.output.columns, this.output.rows))
|
|
176
|
+
this.output.on('resize', this._onResize)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.renderer.start()
|
|
180
|
+
if (this._mouseMode) this.output.write(mouse.enable(this._mouseMode))
|
|
181
|
+
|
|
182
|
+
// Seed the model with the initial geometry. Real TTYs report columns/rows;
|
|
183
|
+
// injected streams won't, so fall back to opts then a sane default.
|
|
184
|
+
const width = this.output.columns ?? this.opts.width ?? 80
|
|
185
|
+
const height = this.output.rows ?? this.opts.height ?? 24
|
|
186
|
+
this.send(windowSize(width, height))
|
|
187
|
+
|
|
188
|
+
// In raw mode the kernel won't deliver Ctrl+C as SIGINT (the app sees it as
|
|
189
|
+
// a key), but a kill/hangup from outside still must restore the terminal.
|
|
190
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
191
|
+
const handler = () => this.send({ type: 'quit' })
|
|
192
|
+
try {
|
|
193
|
+
global.Bare.on(sig, handler)
|
|
194
|
+
this._signals.push([sig, handler])
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_teardown() {
|
|
200
|
+
if (this._tornDown) return
|
|
201
|
+
this._tornDown = true
|
|
202
|
+
|
|
203
|
+
// No frame may fire after the screen is restored, or it writes onto the
|
|
204
|
+
// user's normal buffer.
|
|
205
|
+
this._cancelFrame()
|
|
206
|
+
|
|
207
|
+
for (const [sig, handler] of this._signals) {
|
|
208
|
+
try {
|
|
209
|
+
global.Bare.removeListener(sig, handler)
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
if (this._onResize) this.output.removeListener('resize', this._onResize)
|
|
214
|
+
} catch {}
|
|
215
|
+
// Detach the manual forwarders before tearing anything down so neither
|
|
216
|
+
// stream sees data after it's gone.
|
|
217
|
+
try {
|
|
218
|
+
if (this.input && this._onInput) {
|
|
219
|
+
this.input.removeListener('data', this._onInput)
|
|
220
|
+
}
|
|
221
|
+
} catch {}
|
|
222
|
+
try {
|
|
223
|
+
if (this._decoder && this._onKey) {
|
|
224
|
+
this._decoder.removeListener('data', this._onKey)
|
|
225
|
+
}
|
|
226
|
+
} catch {}
|
|
227
|
+
try {
|
|
228
|
+
this._decoder?.destroy()
|
|
229
|
+
} catch {}
|
|
230
|
+
try {
|
|
231
|
+
if (this.input && this.inputIsTTY && this.input.setRawMode) {
|
|
232
|
+
this.input.setRawMode(false)
|
|
233
|
+
}
|
|
234
|
+
} catch {}
|
|
235
|
+
try {
|
|
236
|
+
if (this._mouseMode) this.output.write(mouse.disable(this._mouseMode))
|
|
237
|
+
} catch {}
|
|
238
|
+
|
|
239
|
+
this.renderer.stop() // show cursor, leave alt screen
|
|
240
|
+
|
|
241
|
+
// We own the input fd, so close it; leave output open in case the host CLI
|
|
242
|
+
// keeps writing after the TUI exits.
|
|
243
|
+
if (this._ownsInput && this.input) {
|
|
244
|
+
try {
|
|
245
|
+
this.input.destroy()
|
|
246
|
+
} catch {}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Normalise update()'s return into a [model, cmd] pair. Accepts a bare model
|
|
251
|
+
// (no cmd) or null (no change), so update() can be terse.
|
|
252
|
+
_update(msg) {
|
|
253
|
+
const ret = this.model.update(msg)
|
|
254
|
+
if (ret === undefined || ret === null) return [this.model, null]
|
|
255
|
+
if (Array.isArray(ret)) return [ret[0] ?? this.model, ret[1] ?? null]
|
|
256
|
+
return [ret, null]
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
_view() {
|
|
260
|
+
try {
|
|
261
|
+
return String(this.model.view())
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return 'view error: ' + (err && err.message)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Kick off a Cmd off the update path. Fire-and-forget at the top level —
|
|
268
|
+
// _runCmd dispatches each resulting Msg as it resolves.
|
|
269
|
+
_exec(cmd) {
|
|
270
|
+
this._runCmd(cmd)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Recursively run a Cmd to completion. One function handles every shape so
|
|
274
|
+
// they nest correctly:
|
|
275
|
+
// null/undefined -> nothing
|
|
276
|
+
// array (batch) -> run all concurrently, resolve when the last finishes
|
|
277
|
+
// { __seq } (seq) -> run in order, awaiting each (and its nested cmds)
|
|
278
|
+
// function (Cmd) -> call it, send the Msg it returns
|
|
279
|
+
// Bails if the program is quitting so a sequence can't outlive teardown.
|
|
280
|
+
async _runCmd(cmd) {
|
|
281
|
+
if (!cmd || !this._running) return
|
|
282
|
+
|
|
283
|
+
if (Array.isArray(cmd)) {
|
|
284
|
+
await Promise.all(cmd.map((c) => this._runCmd(c)))
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (cmd.__seq) {
|
|
289
|
+
for (const c of cmd.__seq) {
|
|
290
|
+
if (!this._running) return
|
|
291
|
+
await this._runCmd(c)
|
|
292
|
+
}
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
this.send(await cmd())
|
|
298
|
+
} catch (error) {
|
|
299
|
+
this.send({ type: 'error', error })
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Await the next Msg. The executor body runs synchronously, so _wake is set
|
|
304
|
+
// before we suspend — no lost-wakeup race with send().
|
|
305
|
+
async _next() {
|
|
306
|
+
if (this._queue.length === 0) {
|
|
307
|
+
await new Promise((resolve) => {
|
|
308
|
+
this._wake = resolve
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
return this._queue.shift()
|
|
312
|
+
}
|
|
313
|
+
}
|
package/renderer.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The renderer turns a model's View() string into terminal output, repainting
|
|
2
|
+
// only what changed.
|
|
3
|
+
//
|
|
4
|
+
// View() returns the whole frame as text. Naively rewriting it every tick
|
|
5
|
+
// flickers and wastes bandwidth, so we keep the previous frame and, on each
|
|
6
|
+
// render, only rewrite the lines that actually differ — addressing them with
|
|
7
|
+
// absolute cursor moves. This is the same strategy as Bubble Tea's standard
|
|
8
|
+
// renderer, and it's what makes a redraw-on-every-keystroke loop feel instant.
|
|
9
|
+
const ansi = require('./ansi')
|
|
10
|
+
|
|
11
|
+
module.exports = class Renderer {
|
|
12
|
+
constructor(output, { altScreen = true } = {}) {
|
|
13
|
+
this.out = output
|
|
14
|
+
this.altScreen = altScreen
|
|
15
|
+
this.lastLines = null // null => next render is a full repaint
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Enter the screen: optional alt buffer, hide the cursor, clear.
|
|
19
|
+
start() {
|
|
20
|
+
let s = ''
|
|
21
|
+
if (this.altScreen) s += ansi.enterAltScreen
|
|
22
|
+
s += ansi.cursorHide + ansi.home + ansi.eraseDisplay
|
|
23
|
+
this.out.write(s)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Force the next render() to repaint everything (used on resize).
|
|
27
|
+
clear() {
|
|
28
|
+
this.lastLines = null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
render(view) {
|
|
32
|
+
const lines = String(view).split('\n')
|
|
33
|
+
let s = ''
|
|
34
|
+
|
|
35
|
+
if (this.lastLines === null) {
|
|
36
|
+
// Full repaint. \r\n (not \n) because raw mode doesn't translate \n into
|
|
37
|
+
// a carriage return, so we'd otherwise stair-step down the screen.
|
|
38
|
+
s += ansi.home
|
|
39
|
+
for (let i = 0; i < lines.length; i++) {
|
|
40
|
+
s += ansi.eraseLineEnd + lines[i]
|
|
41
|
+
if (i < lines.length - 1) s += '\r\n'
|
|
42
|
+
}
|
|
43
|
+
s += ansi.eraseDisplayEnd
|
|
44
|
+
} else {
|
|
45
|
+
// Diff: touch only changed rows.
|
|
46
|
+
for (let i = 0; i < lines.length; i++) {
|
|
47
|
+
if (lines[i] !== this.lastLines[i]) {
|
|
48
|
+
s += ansi.cursorTo(i, 0) + ansi.eraseLineEnd + lines[i]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// The frame got shorter — wipe the now-orphaned rows below it.
|
|
52
|
+
if (this.lastLines.length > lines.length) {
|
|
53
|
+
s += ansi.cursorTo(lines.length, 0) + ansi.eraseDisplayEnd
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.lastLines = lines
|
|
58
|
+
if (s) this.out.write(s)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Restore the terminal: show the cursor, leave the alt buffer.
|
|
62
|
+
stop() {
|
|
63
|
+
let s = ansi.cursorShow
|
|
64
|
+
if (this.altScreen) s += ansi.leaveAltScreen
|
|
65
|
+
this.out.write(s)
|
|
66
|
+
}
|
|
67
|
+
}
|