bare-tui 0.2.0 → 0.3.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 CHANGED
@@ -107,9 +107,10 @@ Run it with `bare counter.js`. The `Program` puts the terminal into raw mode, en
107
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.
108
108
 
109
109
  ```js
110
- const { quit, batch, sequence, tick, every, suspend } = require('bare-tui')
110
+ const { quit, repaint, batch, sequence, tick, every, suspend } = require('bare-tui')
111
111
 
112
112
  quit // a Cmd that quits the program
113
+ repaint // a Cmd that forces a full repaint of the screen
113
114
  tick(1000, () => ({ type: 'tick' })) // fire a Msg after 1s
114
115
  every(1000, () => ({ type: 'tick' })) // fire on the wall-clock second
115
116
  batch(cmdA, cmdB) // run several Cmds concurrently
@@ -140,9 +141,24 @@ const load = () =>
140
141
 
141
142
  Return commands from `init` or `update`; the result comes back as a message.
142
143
 
144
+ ## Repainting
145
+
146
+ The renderer rewrites only the rows whose text actually changed, which is what makes a redraw-per-keystroke loop feel instant. The flip side is that it has no way to know when _something else_ has drawn over the screen — a native library logging to the same fd, a multiplexer redrawing a pane, a terminal that dropped the alt-screen. Those rows are never repainted on their own: a header with a clock heals itself on the next frame, while a static body stays broken.
147
+
148
+ When you know the screen may have been disturbed, ask for a full repaint:
149
+
150
+ ```js
151
+ return [model, repaint] // from update()
152
+ program.repaint() // from outside the loop
153
+ ```
154
+
155
+ The runtime already repaints on its own whenever the window is resized, after a `suspend`, and — if you enabled `focus: true` — whenever the window regains focus.
156
+
157
+ You should not need it for your own output: the renderer knows the screen size and fits every frame to it, dropping rows past the last one and trimming lines to the visible width (measured in cells, so ANSI escapes and wide glyphs are counted correctly). That matters because a frame one row too tall scrolls the terminal, and every absolute cursor move after that addresses the wrong row — permanently. If content is disappearing off the bottom or right, your layout is bigger than the terminal; measure it with `style.height` / `style.width` rather than counting lines by hand.
158
+
143
159
  ## Key, mouse & focus input
144
160
 
145
- Keys arrive as `{ type: 'key' }` messages (a `KeyMsg`). Match them with `key.matches`, which is null- and type-safe:
161
+ Keys arrive as `{ type: 'key' }` messages (a `KeyMsg`). Match them with `key.matches`, which is null- and type-safe. A chord is compared whole — `'up'` does not match `ctrl+up` — and `esc`/`escape`, `enter`/`return` are aliases:
146
162
 
147
163
  ```js
148
164
  if (key.matches(msg, 'enter')) ...
@@ -172,6 +188,8 @@ new Program(model, { focus: true })
172
188
 
173
189
  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
190
 
191
+ With `focus: true` the runtime also repaints the whole screen on every focus-in, on the theory that anything could have happened to the window while you were away.
192
+
175
193
  ## Components
176
194
 
177
195
  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/commands.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // update() returns `[model, cmd]`. `cmd` may be a single Cmd, an array of Cmds
8
8
  // (run concurrently — see batch), a sequence marker (run in order — see
9
9
  // sequence), or null for "do nothing".
10
- const { quitMsg } = require('./messages')
10
+ const { quitMsg, repaintMsg } = require('./messages')
11
11
 
12
12
  // `quit` is itself a Cmd: return it from update() to tear down and exit, e.g.
13
13
  // return [model, quit]
@@ -15,6 +15,16 @@ function quit() {
15
15
  return quitMsg()
16
16
  }
17
17
 
18
+ // Force a full repaint of the screen on the next frame:
19
+ // return [model, repaint]
20
+ // The renderer normally rewrites only the rows whose text changed, which is
21
+ // what makes it fast — but it also means it cannot know when something else has
22
+ // drawn over the screen. Reach for this after spawning something that writes to
23
+ // the same terminal, or whenever the display might have been disturbed.
24
+ function repaint() {
25
+ return repaintMsg()
26
+ }
27
+
18
28
  // Run several Cmds concurrently. The runtime expands arrays, so batch is just a
19
29
  // null-filtering spread — but naming it documents intent at the call site.
20
30
  function batch(...cmds) {
@@ -64,4 +74,4 @@ function every(ms, fn) {
64
74
  })
65
75
  }
66
76
 
67
- module.exports = { quit, batch, sequence, tick, every, suspend }
77
+ module.exports = { quit, repaint, batch, sequence, tick, every, suspend }
@@ -0,0 +1,38 @@
1
+ // Characters, not code units. A JS string index can land inside a surrogate
2
+ // pair — every emoji is one — so the text fields step their cursor with these
3
+ // and insert whatever printable string the decoder handed them.
4
+
5
+ // A key that should be inserted as text: no modifier, and a sequence with no
6
+ // control bytes in it. Named keys always arrive as escape sequences, so they
7
+ // fail the second test; a single grapheme such as '❤️' passes whole.
8
+ function printable(msg) {
9
+ const s = msg.sequence
10
+ // eslint-disable-next-line no-control-regex
11
+ return (
12
+ !msg.ctrl && !msg.meta && typeof s === 'string' && s.length > 0 && !/[\x00-\x1f\x7f]/.test(s)
13
+ )
14
+ }
15
+
16
+ // Index of the start of the character before `i`.
17
+ function before(s, i) {
18
+ if (i <= 0) return 0
19
+ const j = i - 1
20
+ return j > 0 && isLow(s.charCodeAt(j)) && isHigh(s.charCodeAt(j - 1)) ? j - 1 : j
21
+ }
22
+
23
+ // Index just past the character at `i`.
24
+ function after(s, i) {
25
+ if (i >= s.length) return s.length
26
+ return i + (isHigh(s.charCodeAt(i)) && isLow(s.charCodeAt(i + 1)) ? 2 : 1)
27
+ }
28
+
29
+ function count(s) {
30
+ let n = 0
31
+ for (let i = 0; i < s.length; i = after(s, i)) n++
32
+ return n
33
+ }
34
+
35
+ const isHigh = (c) => c >= 0xd800 && c <= 0xdbff
36
+ const isLow = (c) => c >= 0xdc00 && c <= 0xdfff
37
+
38
+ module.exports = { printable, before, after, count }
@@ -9,6 +9,7 @@
9
9
  //
10
10
  // const ta = textarea.create({ width: 60, height: 10, placeholder: '…' }).focus()
11
11
  const ansi = require('../ansi')
12
+ const chars = require('./chars')
12
13
 
13
14
  const dim = (s) => ansi.modifierDim + s + ansi.modifierReset
14
15
  const reverse = (s) => ansi.modifierReverse + s + ansi.modifierNotReverse
@@ -95,7 +96,7 @@ class TextArea {
95
96
  }
96
97
 
97
98
  _left() {
98
- if (this.col > 0) this.col--
99
+ if (this.col > 0) this.col = chars.before(this._line(), this.col)
99
100
  else if (this.row > 0) {
100
101
  this.row--
101
102
  this.col = this._line().length
@@ -103,7 +104,7 @@ class TextArea {
103
104
  }
104
105
 
105
106
  _right() {
106
- if (this.col < this._line().length) this.col++
107
+ if (this.col < this._line().length) this.col = chars.after(this._line(), this.col)
107
108
  else if (this.row < this.lines.length - 1) {
108
109
  this.row++
109
110
  this.col = 0
@@ -131,8 +132,9 @@ class TextArea {
131
132
  _backspace() {
132
133
  const line = this._line()
133
134
  if (this.col > 0) {
134
- this.lines[this.row] = line.slice(0, this.col - 1) + line.slice(this.col)
135
- this.col--
135
+ const from = chars.before(line, this.col)
136
+ this.lines[this.row] = line.slice(0, from) + line.slice(this.col)
137
+ this.col = from
136
138
  } else if (this.row > 0) {
137
139
  const prev = this.lines[this.row - 1]
138
140
  this.col = prev.length
@@ -145,7 +147,7 @@ class TextArea {
145
147
  _delete() {
146
148
  const line = this._line()
147
149
  if (this.col < line.length) {
148
- this.lines[this.row] = line.slice(0, this.col) + line.slice(this.col + 1)
150
+ this.lines[this.row] = line.slice(0, this.col) + line.slice(chars.after(line, this.col))
149
151
  } else if (this.row < this.lines.length - 1) {
150
152
  this.lines[this.row] = line + this.lines[this.row + 1]
151
153
  this.lines.splice(this.row + 1, 1)
@@ -153,20 +155,13 @@ class TextArea {
153
155
  }
154
156
 
155
157
  _insert(msg) {
156
- const ch = msg.sequence
157
- const printable =
158
- !msg.ctrl &&
159
- !msg.meta &&
160
- typeof ch === 'string' &&
161
- ch.length === 1 &&
162
- ch >= ' ' &&
163
- ch !== '\x7f'
164
- if (!printable) return
158
+ if (!chars.printable(msg)) return
165
159
  if (this.charLimit && this.length >= this.charLimit) return
166
160
 
161
+ const ch = msg.sequence
167
162
  const line = this._line()
168
163
  this.lines[this.row] = line.slice(0, this.col) + ch + line.slice(this.col)
169
- this.col++
164
+ this.col += ch.length
170
165
  }
171
166
 
172
167
  _vertical(delta) {
@@ -19,6 +19,7 @@
19
19
  // Because the Program hides the real terminal cursor, the field draws its own
20
20
  // as a reverse-video cell.
21
21
  const ansi = require('../ansi')
22
+ const chars = require('./chars')
22
23
 
23
24
  const dim = (s) => ansi.modifierDim + s + ansi.modifierReset
24
25
  const reverse = (s) => ansi.modifierReverse + s + ansi.modifierNotReverse
@@ -64,22 +65,20 @@ class TextInput {
64
65
  if (!this.focused || !msg || msg.type !== 'key') return [this, null]
65
66
 
66
67
  if (msg.is('left', 'ctrl+b')) {
67
- this.cursor = Math.max(0, this.cursor - 1)
68
+ this.cursor = chars.before(this.value, this.cursor)
68
69
  } else if (msg.is('right', 'ctrl+f')) {
69
- this.cursor = Math.min(this.value.length, this.cursor + 1)
70
+ this.cursor = chars.after(this.value, this.cursor)
70
71
  } else if (msg.is('home', 'ctrl+a')) {
71
72
  this.cursor = 0
72
73
  } else if (msg.is('end', 'ctrl+e')) {
73
74
  this.cursor = this.value.length
74
75
  } else if (msg.is('backspace')) {
75
- if (this.cursor > 0) {
76
- this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor)
77
- this.cursor--
78
- }
76
+ const from = chars.before(this.value, this.cursor)
77
+ this.value = this.value.slice(0, from) + this.value.slice(this.cursor)
78
+ this.cursor = from
79
79
  } else if (msg.is('delete')) {
80
- if (this.cursor < this.value.length) {
81
- this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1)
82
- }
80
+ const to = chars.after(this.value, this.cursor)
81
+ this.value = this.value.slice(0, this.cursor) + this.value.slice(to)
83
82
  } else {
84
83
  this._insert(msg)
85
84
  }
@@ -87,23 +86,17 @@ class TextInput {
87
86
  return [this, null]
88
87
  }
89
88
 
90
- // Insert a single printable character at the cursor. We key off the decoded
91
- // sequence (not name) so case and punctuation come through verbatim, and skip
92
- // control bytes, chorded keys, and DEL.
89
+ // Insert a printable key at the cursor. We key off the decoded sequence (not
90
+ // name) so case and punctuation come through verbatim, and skip control
91
+ // bytes, chorded keys, and DEL. The sequence may be more than one code unit:
92
+ // an emoji is a surrogate pair, and some come with a variation selector.
93
93
  _insert(msg) {
94
- const ch = msg.sequence
95
- const printable =
96
- !msg.ctrl &&
97
- !msg.meta &&
98
- typeof ch === 'string' &&
99
- ch.length === 1 &&
100
- ch >= ' ' &&
101
- ch !== '\x7f'
102
- if (!printable) return
103
- if (this.charLimit && this.value.length >= this.charLimit) return
94
+ if (!chars.printable(msg)) return
95
+ if (this.charLimit && chars.count(this.value) >= this.charLimit) return
104
96
 
97
+ const ch = msg.sequence
105
98
  this.value = this.value.slice(0, this.cursor) + ch + this.value.slice(this.cursor)
106
- this.cursor++
99
+ this.cursor += ch.length
107
100
  }
108
101
 
109
102
  _display() {
@@ -123,8 +116,9 @@ class TextInput {
123
116
  if (!this.focused) return this.prompt + text
124
117
 
125
118
  // Draw the cursor as a reverse cell; a trailing space when at end-of-line.
126
- const at = text.slice(this.cursor, this.cursor + 1) || ' '
127
- return this.prompt + text.slice(0, this.cursor) + reverse(at) + text.slice(this.cursor + 1)
119
+ const end = chars.after(text, this.cursor)
120
+ const at = text.slice(this.cursor, end) || ' '
121
+ return this.prompt + text.slice(0, this.cursor) + reverse(at) + text.slice(end)
128
122
  }
129
123
  }
130
124
 
package/index.js CHANGED
@@ -44,7 +44,7 @@ const focus = require('./components/focus')
44
44
 
45
45
  module.exports = {
46
46
  Program,
47
- ...commands, // quit, batch, sequence, tick, every, suspend
47
+ ...commands, // quit, repaint, batch, sequence, tick, every, suspend
48
48
  KeyMsg: messages.KeyMsg,
49
49
  key, // key.matches(msg, ...chords | bindings), key.binding({ keys, help })
50
50
  ansi,
package/messages.js CHANGED
@@ -28,20 +28,28 @@ class KeyMsg {
28
28
  return parts.join('+')
29
29
  }
30
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.
31
+ // True if this key matches any of the given chords. A chord is compared
32
+ // whole, so 'up' means up and not ctrl+up or shift+up; 'esc'/'escape' and
33
+ // 'enter'/'return' are aliases.
34
34
  // if (msg.is('q', 'ctrl+c')) ...
35
35
  is(...chords) {
36
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
37
+ for (const chord of chords) {
38
+ if (alias(chord) === str) return true
40
39
  }
41
40
  return false
42
41
  }
43
42
  }
44
43
 
44
+ // The names a key goes by: the decoder says 'escape' and 'return', chords
45
+ // usually say 'esc' and 'enter'. Only the final segment is a key name.
46
+ function alias(chord) {
47
+ return String(chord).replace(
48
+ /(^|\+)(esc|return)$/,
49
+ (m, sep, name) => sep + (name === 'esc' ? 'escape' : 'enter')
50
+ )
51
+ }
52
+
45
53
  // Emitted on startup and whenever the terminal is resized.
46
54
  function windowSize(width, height) {
47
55
  return { type: 'resize', width, height }
@@ -56,6 +64,16 @@ function focusMsg(focused) {
56
64
  return { type: 'focus', focused }
57
65
  }
58
66
 
67
+ // Asks the runtime to repaint the whole screen on the next frame. The renderer
68
+ // only rewrites rows whose text changed, so anything that draws to the terminal
69
+ // behind its back — a native library logging to the same fd, a multiplexer
70
+ // redrawing a pane — leaves stale rows that never heal on their own. `repaint`
71
+ // (see commands.js) is the Cmd that produces it, and program.repaint() sends it
72
+ // from outside the loop.
73
+ function repaintMsg() {
74
+ return { type: 'repaint' }
75
+ }
76
+
59
77
  // The runtime tears down and exits when it sees this. `quit` (see commands.js)
60
78
  // is the Cmd that produces it.
61
79
  function quitMsg() {
@@ -68,4 +86,4 @@ function errorMsg(error) {
68
86
  return { type: 'error', error }
69
87
  }
70
88
 
71
- module.exports = { KeyMsg, windowSize, focusMsg, quitMsg, errorMsg }
89
+ module.exports = { KeyMsg, windowSize, focusMsg, repaintMsg, quitMsg, errorMsg }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-tui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A little TUI framework for Bare, based on bubbletea",
5
5
  "main": "index.js",
6
6
  "exports": {
package/program.js CHANGED
@@ -20,7 +20,13 @@ const Renderer = require('./renderer')
20
20
  const ansi = require('./ansi')
21
21
  const mouse = require('./mouse')
22
22
  const { InputParser } = require('./input')
23
- const { KeyMsg, windowSize } = require('./messages')
23
+ const { KeyMsg, windowSize, repaintMsg } = require('./messages')
24
+
25
+ // A terminal that is minimised, occluded, or backed by a detached pty reports
26
+ // a size of 0 — that means "unknown", not "zero rows". Forwarding it collapses
27
+ // any `height - chrome` layout to nothing, and the app has no way back until the
28
+ // next real resize, so we treat it as no report at all.
29
+ const known = (n) => (typeof n === 'number' && n > 0 ? n : null)
24
30
 
25
31
  module.exports = class Program {
26
32
  constructor(model, opts = {}) {
@@ -99,6 +105,15 @@ module.exports = class Program {
99
105
  this.send({ type: 'quit' })
100
106
  }
101
107
 
108
+ // Force a full repaint on the next frame. The renderer only rewrites rows
109
+ // whose text changed, so it cannot know when something else has drawn over
110
+ // the screen — a native library logging to the same fd, say. Call this from
111
+ // outside the loop after such a write; from inside update(), return the
112
+ // `repaint` Cmd instead.
113
+ repaint() {
114
+ this.send(repaintMsg())
115
+ }
116
+
102
117
  async run() {
103
118
  this._running = true
104
119
  // try/finally guarantees the terminal is restored even if init/update/view
@@ -114,7 +129,12 @@ module.exports = class Program {
114
129
  const msg = await this._next()
115
130
  if (!msg) continue
116
131
  if (msg.type === 'quit') break
117
- if (msg.type === 'resize') this.renderer.clear() // geometry changed: repaint
132
+ // Three ways the screen stops matching what the renderer believes:
133
+ // the geometry moved, the app told us it was disturbed, or the window
134
+ // came back after something else may have drawn over it.
135
+ if (msg.type === 'resize') this.renderer.resize(msg.width, msg.height)
136
+ else if (msg.type === 'repaint') this.renderer.clear()
137
+ else if (msg.type === 'focus' && msg.focused) this.renderer.clear()
118
138
 
119
139
  const [model, cmd] = this._update(msg)
120
140
  this.model = model
@@ -210,7 +230,12 @@ module.exports = class Program {
210
230
  }
211
231
 
212
232
  if (this.outputIsTTY && typeof this.output.on === 'function') {
213
- this._onResize = () => this.send(windowSize(this.output.columns, this.output.rows))
233
+ this._onResize = () => {
234
+ const width = known(this.output.columns)
235
+ const height = known(this.output.rows)
236
+ if (width === null || height === null) return // not a real geometry
237
+ this.send(windowSize(width, height))
238
+ }
214
239
  this.output.on('resize', this._onResize)
215
240
  }
216
241
 
@@ -219,8 +244,9 @@ module.exports = class Program {
219
244
 
220
245
  // Seed the model with the initial geometry. Real TTYs report columns/rows;
221
246
  // injected streams won't, so fall back to opts then a sane default.
222
- const width = this.output.columns ?? this.opts.width ?? 80
223
- const height = this.output.rows ?? this.opts.height ?? 24
247
+ const width = known(this.output.columns) ?? known(this.opts.width) ?? 80
248
+ const height = known(this.output.rows) ?? known(this.opts.height) ?? 24
249
+ this.renderer.resize(width, height) // fit frames to the screen from frame one
224
250
  this.send(windowSize(width, height))
225
251
 
226
252
  // In raw mode the kernel won't deliver Ctrl+C as SIGINT (the app sees it as
@@ -340,6 +366,18 @@ module.exports = class Program {
340
366
  this._enableModes()
341
367
  this.renderer.clear() // next render repaints everything
342
368
  this._suspended = false
369
+
370
+ // The window may well have been resized while the child owned the terminal,
371
+ // and no SIGWINCH is coming to tell us about it — we were the ones not
372
+ // looking. Re-read the geometry so the repaint below is drawn to the right
373
+ // shape, and let the model re-lay-out if it actually moved.
374
+ const width = known(this.output.columns)
375
+ const height = known(this.output.rows)
376
+ if (width !== null && height !== null) {
377
+ const moved = width !== this.renderer.width || height !== this.renderer.height
378
+ this.renderer.resize(width, height)
379
+ if (moved) this.send(windowSize(width, height))
380
+ }
343
381
  }
344
382
 
345
383
  // Normalise update()'s return into a [model, cmd] pair. Accepts a bare model
package/renderer.js CHANGED
@@ -6,12 +6,28 @@
6
6
  // render, only rewrite the lines that actually differ — addressing them with
7
7
  // absolute cursor moves. This is the same strategy as Bubble Tea's standard
8
8
  // renderer, and it's what makes a redraw-on-every-keystroke loop feel instant.
9
+ //
10
+ // Absolute addressing is only correct while the screen hasn't moved under us,
11
+ // so the renderer also owns two safeguards:
12
+ //
13
+ // - it knows the screen geometry and fits every frame to it, because a frame
14
+ // one row too tall (or one cell too wide, which wraps) scrolls the terminal
15
+ // and puts every subsequent row address permanently out by one;
16
+ // - clear() re-syncs from scratch, for the damage it can't prevent — another
17
+ // process writing to the same fd, a terminal that dropped the alt-screen.
18
+ //
19
+ // Without those, a stale row is never repainted again: only rows whose *text*
20
+ // changed are rewritten, so a header with a clock heals itself every frame
21
+ // while a static body stays broken for the life of the process.
9
22
  const ansi = require('./ansi')
23
+ const { style } = require('./style')
10
24
 
11
25
  module.exports = class Renderer {
12
- constructor(output, { altScreen = true } = {}) {
26
+ constructor(output, { altScreen = true, width = 0, height = 0 } = {}) {
13
27
  this.out = output
14
28
  this.altScreen = altScreen
29
+ this.width = width > 0 ? width : 0 // 0 => unknown, don't fit
30
+ this.height = height > 0 ? height : 0
15
31
  this.lastLines = null // null => next render is a full repaint
16
32
  }
17
33
 
@@ -23,13 +39,36 @@ module.exports = class Renderer {
23
39
  this.out.write(s)
24
40
  }
25
41
 
26
- // Force the next render() to repaint everything (used on resize).
42
+ // Force the next render() to repaint everything (used on resize, on a
43
+ // repaint Msg, and when the terminal window regains focus).
27
44
  clear() {
28
45
  this.lastLines = null
29
46
  }
30
47
 
48
+ // Tell the renderer how big the screen is. Frames are fitted to it from here
49
+ // on, and the next render repaints in full since the geometry moved.
50
+ resize(width, height) {
51
+ this.width = width > 0 ? width : 0
52
+ this.height = height > 0 ? height : 0
53
+ this.clear()
54
+ }
55
+
56
+ // Trim one line to the screen width. Measured in visible cells — a styled
57
+ // line's .length counts escape bytes the terminal never draws, and a wide
58
+ // glyph occupies two columns.
59
+ _fit(line) {
60
+ if (this.width <= 0 || style.width(line) <= this.width) return line
61
+ return style.truncate(line, this.width)
62
+ }
63
+
31
64
  render(view) {
32
- const lines = String(view).split('\n')
65
+ let lines = String(view).split('\n')
66
+
67
+ // Surplus rows would scroll the screen, and a scrolled screen breaks every
68
+ // absolute cursor move from then on. Losing the overflow is recoverable;
69
+ // losing the row addressing is not.
70
+ if (this.height > 0 && lines.length > this.height) lines = lines.slice(0, this.height)
71
+
33
72
  let s = ''
34
73
 
35
74
  if (this.lastLines === null) {
@@ -37,15 +76,17 @@ module.exports = class Renderer {
37
76
  // a carriage return, so we'd otherwise stair-step down the screen.
38
77
  s += ansi.home
39
78
  for (let i = 0; i < lines.length; i++) {
40
- s += ansi.eraseLineEnd + lines[i]
79
+ s += ansi.eraseLineEnd + this._fit(lines[i])
41
80
  if (i < lines.length - 1) s += '\r\n'
42
81
  }
43
82
  s += ansi.eraseDisplayEnd
44
83
  } else {
45
- // Diff: touch only changed rows.
84
+ // Diff: touch only changed rows. Compare the untrimmed text — _fit is a
85
+ // pure function of (line, width) and a width change clears lastLines, so
86
+ // equal input always means equal output.
46
87
  for (let i = 0; i < lines.length; i++) {
47
88
  if (lines[i] !== this.lastLines[i]) {
48
- s += ansi.cursorTo(i, 0) + ansi.eraseLineEnd + lines[i]
89
+ s += ansi.cursorTo(i, 0) + ansi.eraseLineEnd + this._fit(lines[i])
49
90
  }
50
91
  }
51
92
  // The frame got shorter — wipe the now-orphaned rows below it.