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.
- package/LICENSE +201 -0
- package/NOTICE +13 -0
- package/README.md +261 -0
- package/ansi.js +34 -0
- package/commands.js +67 -0
- package/components/autocomplete.js +208 -0
- package/components/checkbox.js +68 -0
- package/components/filepicker.js +232 -0
- package/components/focus.js +129 -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/radio.js +113 -0
- package/components/select.js +165 -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/checkbox.md +43 -0
- package/docs/filepicker.md +80 -0
- package/docs/focus.md +68 -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/radio.md +49 -0
- package/docs/select.md +54 -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 +71 -0
- package/key.js +29 -0
- package/messages.js +62 -0
- package/mouse.js +102 -0
- package/package.json +59 -1
- package/program.js +387 -0
- package/renderer.js +67 -0
- 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
|
+
}
|