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 +20 -2
- package/commands.js +12 -2
- package/components/chars.js +38 -0
- package/components/textarea.js +10 -15
- package/components/textinput.js +19 -25
- package/index.js +1 -1
- package/messages.js +25 -7
- package/package.json +1 -1
- package/program.js +43 -5
- package/renderer.js +47 -6
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 }
|
package/components/textarea.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/components/textinput.js
CHANGED
|
@@ -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 =
|
|
68
|
+
this.cursor = chars.before(this.value, this.cursor)
|
|
68
69
|
} else if (msg.is('right', 'ctrl+f')) {
|
|
69
|
-
this.cursor =
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
91
|
-
//
|
|
92
|
-
//
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
127
|
-
|
|
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
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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 (
|
|
38
|
-
if (chord ===
|
|
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
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
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
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.
|