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
package/program.js ADDED
@@ -0,0 +1,387 @@
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
+ this._suspended = false // true while the terminal is handed to a child process
66
+
67
+ this._decoder = null
68
+ this._onInput = null
69
+ this._onKey = null
70
+ this._onResize = null
71
+ this._signals = []
72
+ }
73
+
74
+ // Enqueue a Msg from anywhere — key decoder, resize handler, Cmd result, or
75
+ // external code (e.g. a worker IPC bridge calling program.send(...)).
76
+ send(msg) {
77
+ if (!msg) return
78
+ this._queue.push(msg)
79
+ if (this._wake) {
80
+ const wake = this._wake
81
+ this._wake = null
82
+ wake()
83
+ }
84
+ }
85
+
86
+ quit() {
87
+ this.send({ type: 'quit' })
88
+ }
89
+
90
+ async run() {
91
+ this._running = true
92
+ // try/finally guarantees the terminal is restored even if init/update/view
93
+ // throws — otherwise a single bad model would leave the user in raw mode and
94
+ // the alt-screen. The error still propagates after cleanup.
95
+ try {
96
+ this._setup()
97
+
98
+ if (typeof this.model.init === 'function') this._exec(this.model.init())
99
+ this.renderer.render(this._view()) // first frame before any input
100
+
101
+ while (this._running) {
102
+ const msg = await this._next()
103
+ if (!msg) continue
104
+ if (msg.type === 'quit') break
105
+ if (msg.type === 'resize') this.renderer.clear() // geometry changed: repaint
106
+
107
+ const [model, cmd] = this._update(msg)
108
+ this.model = model
109
+ this._invalidate() // coalesced render
110
+ this._exec(cmd)
111
+ }
112
+ } finally {
113
+ this._running = false
114
+ this._cancelFrame()
115
+ // Flush any pending coalesced frame so the final state is the last thing
116
+ // drawn (matters for inline mode; harmless under the alt-screen).
117
+ if (this._needsRender) {
118
+ this._needsRender = false
119
+ this.renderer.render(this._view())
120
+ }
121
+ this._teardown()
122
+ }
123
+ return this.model
124
+ }
125
+
126
+ // Mark the view dirty and schedule a render at most once per frame. Updates
127
+ // that land in the same frame collapse into one write.
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
132
+ if (this._frameMs === 0) {
133
+ this.renderer.render(this._view())
134
+ return
135
+ }
136
+ this._needsRender = true
137
+ if (this._frameTimer) return
138
+ this._frameTimer = setTimeout(() => {
139
+ this._frameTimer = null
140
+ if (this._needsRender) {
141
+ this._needsRender = false
142
+ this.renderer.render(this._view())
143
+ }
144
+ }, this._frameMs)
145
+ }
146
+
147
+ _cancelFrame() {
148
+ if (this._frameTimer) {
149
+ clearTimeout(this._frameTimer)
150
+ this._frameTimer = null
151
+ }
152
+ }
153
+
154
+ _setup() {
155
+ if (this.input) {
156
+ if (this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(true)
157
+ this._decoder = new KeyDecoder()
158
+ // Forward bytes manually instead of input.pipe(decoder): streamx has no
159
+ // unpipe, and a piped source destroyed mid-stream (which is exactly what
160
+ // teardown does) destroys the destination with a synthetic "closed before
161
+ // ending" error. Manual forwarding has no Pipeline, so teardown is clean.
162
+ this._mouseParser = this._mouseMode ? new mouse.MouseParser() : null
163
+ this._onKey = (key) => this.send(new KeyMsg(key))
164
+ this._onInput = (data) => {
165
+ if (this._mouseParser) {
166
+ // Peel mouse reports off the stream; the rest is keys.
167
+ const { keys, events } = this._mouseParser.feed(data)
168
+ for (const event of events) this.send(event)
169
+ if (keys.length) this._decoder.write(keys)
170
+ } else {
171
+ this._decoder.write(data)
172
+ }
173
+ }
174
+ this._decoder.on('data', this._onKey)
175
+ this.input.on('data', this._onInput)
176
+ }
177
+
178
+ if (this.outputIsTTY && typeof this.output.on === 'function') {
179
+ this._onResize = () => this.send(windowSize(this.output.columns, this.output.rows))
180
+ this.output.on('resize', this._onResize)
181
+ }
182
+
183
+ this.renderer.start()
184
+ if (this._mouseMode) this.output.write(mouse.enable(this._mouseMode))
185
+
186
+ // Seed the model with the initial geometry. Real TTYs report columns/rows;
187
+ // injected streams won't, so fall back to opts then a sane default.
188
+ const width = this.output.columns ?? this.opts.width ?? 80
189
+ const height = this.output.rows ?? this.opts.height ?? 24
190
+ this.send(windowSize(width, height))
191
+
192
+ // In raw mode the kernel won't deliver Ctrl+C as SIGINT (the app sees it as
193
+ // a key), but a kill/hangup from outside still must restore the terminal.
194
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
195
+ const handler = () => this.send({ type: 'quit' })
196
+ try {
197
+ global.Bare.on(sig, handler)
198
+ this._signals.push([sig, handler])
199
+ } catch {}
200
+ }
201
+ }
202
+
203
+ _teardown() {
204
+ if (this._tornDown) return
205
+ this._tornDown = true
206
+
207
+ // No frame may fire after the screen is restored, or it writes onto the
208
+ // user's normal buffer.
209
+ this._cancelFrame()
210
+
211
+ for (const [sig, handler] of this._signals) {
212
+ try {
213
+ global.Bare.removeListener(sig, handler)
214
+ } catch {}
215
+ }
216
+ try {
217
+ if (this._onResize) this.output.removeListener('resize', this._onResize)
218
+ } catch {}
219
+ // Detach the manual forwarders before tearing anything down so neither
220
+ // stream sees data after it's gone.
221
+ try {
222
+ if (this.input && this._onInput) {
223
+ this.input.removeListener('data', this._onInput)
224
+ }
225
+ } catch {}
226
+ try {
227
+ if (this._decoder && this._onKey) {
228
+ this._decoder.removeListener('data', this._onKey)
229
+ }
230
+ } catch {}
231
+ try {
232
+ this._decoder?.destroy()
233
+ } catch {}
234
+ try {
235
+ if (this.input && this.inputIsTTY && this.input.setRawMode) {
236
+ this.input.setRawMode(false)
237
+ }
238
+ } catch {}
239
+ try {
240
+ if (this._mouseMode) this.output.write(mouse.disable(this._mouseMode))
241
+ } catch {}
242
+
243
+ this.renderer.stop() // show cursor, leave alt screen
244
+
245
+ // We own the input fd, so close it; leave output open in case the host CLI
246
+ // keeps writing after the TUI exits.
247
+ if (this._ownsInput && this.input) {
248
+ try {
249
+ this.input.destroy()
250
+ } catch {}
251
+ }
252
+ }
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
+
307
+ // Normalise update()'s return into a [model, cmd] pair. Accepts a bare model
308
+ // (no cmd) or null (no change), so update() can be terse.
309
+ _update(msg) {
310
+ const ret = this.model.update(msg)
311
+ if (ret === undefined || ret === null) return [this.model, null]
312
+ if (Array.isArray(ret)) return [ret[0] ?? this.model, ret[1] ?? null]
313
+ return [ret, null]
314
+ }
315
+
316
+ _view() {
317
+ try {
318
+ return String(this.model.view())
319
+ } catch (err) {
320
+ return 'view error: ' + (err && err.message)
321
+ }
322
+ }
323
+
324
+ // Kick off a Cmd off the update path. Fire-and-forget at the top level —
325
+ // _runCmd dispatches each resulting Msg as it resolves.
326
+ _exec(cmd) {
327
+ this._runCmd(cmd)
328
+ }
329
+
330
+ // Recursively run a Cmd to completion. One function handles every shape so
331
+ // they nest correctly:
332
+ // null/undefined -> nothing
333
+ // array (batch) -> run all concurrently, resolve when the last finishes
334
+ // { __seq } (seq) -> run in order, awaiting each (and its nested cmds)
335
+ // function (Cmd) -> call it, send the Msg it returns
336
+ // Bails if the program is quitting so a sequence can't outlive teardown.
337
+ async _runCmd(cmd) {
338
+ if (!cmd || !this._running) return
339
+
340
+ if (Array.isArray(cmd)) {
341
+ await Promise.all(cmd.map((c) => this._runCmd(c)))
342
+ return
343
+ }
344
+
345
+ if (cmd.__seq) {
346
+ for (const c of cmd.__seq) {
347
+ if (!this._running) return
348
+ await this._runCmd(c)
349
+ }
350
+ return
351
+ }
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
+
370
+ try {
371
+ this.send(await cmd())
372
+ } catch (error) {
373
+ this.send({ type: 'error', error })
374
+ }
375
+ }
376
+
377
+ // Await the next Msg. The executor body runs synchronously, so _wake is set
378
+ // before we suspend — no lost-wakeup race with send().
379
+ async _next() {
380
+ if (this._queue.length === 0) {
381
+ await new Promise((resolve) => {
382
+ this._wake = resolve
383
+ })
384
+ }
385
+ return this._queue.shift()
386
+ }
387
+ }
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
+ }