bare-tui 0.0.2 → 0.1.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
@@ -140,7 +140,7 @@ const load = () =>
140
140
 
141
141
  Return commands from `init` or `update`; the result comes back as a message.
142
142
 
143
- ## Key & mouse input
143
+ ## Key, mouse & focus input
144
144
 
145
145
  Keys arrive as `{ type: 'key' }` messages (a `KeyMsg`). Match them with `key.matches`, which is null- and type-safe:
146
146
 
@@ -163,6 +163,15 @@ Enable the mouse with a Program option; clicks/scroll/drag arrive as `{ type: 'm
163
163
  new Program(model, { mouse: true }) // true | 'drag' | 'all'
164
164
  ```
165
165
 
166
+ Ask for focus reporting the same way, and the terminal tells you when its window gains or loses focus — handy for pausing an animation or muting a bell while the user is elsewhere:
167
+
168
+ ```js
169
+ new Program(model, { focus: true })
170
+ // update(msg): msg.type === 'focus' -> msg.focused is true on focus in, false on focus out
171
+ ```
172
+
173
+ 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
+
166
175
  ## Components
167
176
 
168
177
  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/ansi.js CHANGED
@@ -30,5 +30,12 @@ module.exports = {
30
30
 
31
31
  // SGR mouse tracking (button events + SGR extended coordinates).
32
32
  enableMouse: CSI + '?1000h' + CSI + '?1006h',
33
- disableMouse: CSI + '?1006l' + CSI + '?1000l'
33
+ disableMouse: CSI + '?1006l' + CSI + '?1000l',
34
+
35
+ // Focus reporting (DEC private mode 1004) — the terminal sends ESC [ I when
36
+ // the window gains focus and ESC [ O when it loses it. Plain constants rather
37
+ // than the enable(mode)/disable(mode) functions mouse.js exposes, because
38
+ // there is only one mode to set; see input.js for the reports themselves.
39
+ enableFocus: CSI + '?1004h',
40
+ disableFocus: CSI + '?1004l'
34
41
  }
@@ -1,4 +1,4 @@
1
- // filepicker — browse a filesystem and pick a file.
1
+ // filepicker — browse a filesystem and pick a file (or a directory).
2
2
  //
3
3
  // Dependency-injected so the framework core stays filesystem-free: pass your
4
4
  // own { fs, path } to create(), or let it lazily require('bare-fs') /
@@ -6,12 +6,18 @@
6
6
  // filepicker, so consumers who don't use it never pull those modules in.
7
7
  //
8
8
  // const fp = filepicker.create({ height: 12 }) // real fs
9
+ // const fp = filepicker.create({ pick: 'dir' }) // choose a directory
9
10
  // const fp = filepicker.create({ ...filepicker.mock(tree), cwd: '/' }) // tests
10
11
  //
12
+ // `pick` ('file' default, or 'dir') decides what enter selects. In 'file' mode
13
+ // enter/→/l opens a directory or selects a file. In 'dir' mode →/l descends into
14
+ // the highlighted directory while enter SELECTS it; files are shown dimmed and
15
+ // can't be chosen.
16
+ //
11
17
  // Directory reads happen through Cmds (async), surfacing as Msgs:
12
18
  // { type: 'filepicker.entries', dir, entries }
13
19
  // { type: 'filepicker.error', dir, error }
14
- // { type: 'filepicker.select', path } // a file was chosen
20
+ // { type: 'filepicker.select', path } // a file (or directory) was chosen
15
21
  //
16
22
  // The only fs surface used is fs.readdir(dir, { withFileTypes: true }, cb); the
17
23
  // only path surface is path.join and path.dirname. The mock implements exactly
@@ -27,6 +33,8 @@ const keys = {
27
33
  up: key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } }),
28
34
  down: key.binding({ keys: ['down', 'j'], help: { key: '↓/j', desc: 'down' } }),
29
35
  open: key.binding({ keys: ['enter', 'right', 'l'], help: { key: '↵', desc: 'open' } }),
36
+ descend: key.binding({ keys: ['right', 'l'], help: { key: '→/l', desc: 'open dir' } }),
37
+ choose: key.binding({ keys: ['enter'], help: { key: '↵', desc: 'select' } }),
30
38
  back: key.binding({ keys: ['backspace', 'left', 'h'], help: { key: '⌫', desc: 'up dir' } })
31
39
  }
32
40
 
@@ -55,6 +63,7 @@ class FilePicker {
55
63
  this.cwd = opts.cwd
56
64
  this.height = opts.height || 12
57
65
  this.showHidden = !!opts.showHidden
66
+ this.pick = opts.pick === 'dir' ? 'dir' : 'file'
58
67
 
59
68
  this.entries = []
60
69
  this.cursor = 0
@@ -113,10 +122,32 @@ class FilePicker {
113
122
  }
114
123
 
115
124
  _key(msg) {
116
- if (key.matches(msg, keys.up)) this._move(-1)
117
- else if (key.matches(msg, keys.down)) this._move(1)
118
- else if (key.matches(msg, keys.back)) return this._open(this.path.dirname(this.cwd))
119
- else if (key.matches(msg, keys.open)) {
125
+ if (key.matches(msg, keys.up)) {
126
+ this._move(-1)
127
+ return [this, null]
128
+ }
129
+ if (key.matches(msg, keys.down)) {
130
+ this._move(1)
131
+ return [this, null]
132
+ }
133
+ if (key.matches(msg, keys.back)) return this._open(this.path.dirname(this.cwd))
134
+
135
+ // dir mode: →/l descends into the highlighted directory, enter selects it;
136
+ // files aren't selectable.
137
+ if (this.pick === 'dir') {
138
+ const entry = this.entries[this.cursor]
139
+ if (!entry || !entry.directory) return [this, null]
140
+ const full = this.path.join(this.cwd, entry.name)
141
+ if (key.matches(msg, keys.descend)) return this._open(full)
142
+ if (key.matches(msg, keys.choose)) {
143
+ this.selected = full
144
+ return [this, () => ({ type: 'filepicker.select', path: full })]
145
+ }
146
+ return [this, null]
147
+ }
148
+
149
+ // file mode: enter/→/l opens a directory or selects a file.
150
+ if (key.matches(msg, keys.open)) {
120
151
  const entry = this.entries[this.cursor]
121
152
  if (!entry) return [this, null]
122
153
  const full = this.path.join(this.cwd, entry.name)
@@ -159,7 +190,11 @@ class FilePicker {
159
190
  const entry = this.entries[p]
160
191
  const label = entry.directory ? entry.name + '/' : entry.name
161
192
  const text = (p === this.cursor ? '› ' : ' ') + label
162
- rows.push(p === this.cursor ? selectedStyle(text) : entry.directory ? dirStyle(text) : text)
193
+ // In dir mode files aren't selectable, so render them dimmed.
194
+ const plain = this.pick === 'dir' && !entry.directory ? dim(text) : text
195
+ rows.push(
196
+ p === this.cursor ? selectedStyle(text) : entry.directory ? dirStyle(text) : plain
197
+ )
163
198
  }
164
199
  }
165
200
  while (rows.length < this.height) rows.push('')
package/input.js ADDED
@@ -0,0 +1,110 @@
1
+ // Input pre-parser — peels the sequences that aren't keystrokes off the byte
2
+ // stream before the key decoder sees them.
3
+ //
4
+ // bare-ansi-escapes' KeyDecoder only understands keys, so anything else the
5
+ // terminal reports has to be claimed first: SGR mouse reports
6
+ // (\x1b[<b;x;yM / m) and focus reports (\x1b[I on focus in, \x1b[O on focus
7
+ // out). InputParser does both in a single pass with a single partial buffer and
8
+ // hands the remaining bytes on untouched:
9
+ //
10
+ // const parser = new InputParser({ mouse: 'basic', focus: true })
11
+ // const { keys, events } = parser.feed(chunk)
12
+ //
13
+ // Each stream is opt-in: with `focus` off a focus report is left in `keys` and
14
+ // reaches the decoder exactly as it does today (as a key named 'undefined'), so
15
+ // enabling one mode never changes the other's behaviour. latin1 throughout so
16
+ // non-claimed bytes — including 8-bit meta keys — round-trip intact.
17
+ const { decode } = require('./mouse')
18
+ const { focusMsg } = require('./messages')
19
+
20
+ const ESC = '\x1b'
21
+
22
+ // An SGR mouse body is "b;x;y" — at most a handful of digits. Bounding the scan
23
+ // keeps a stray \x1b[< (which a user can type) from swallowing the rest of the
24
+ // session while it hunts for a terminator that never comes.
25
+ const MAX_MOUSE_BODY = 24
26
+
27
+ class InputParser {
28
+ constructor({ mouse = null, focus = false } = {}) {
29
+ this.mouse = !!mouse
30
+ this.focus = !!focus
31
+ this._partial = ''
32
+ }
33
+
34
+ // Drop any half-claimed sequence. Called when the terminal is handed to a
35
+ // child process and reclaimed, so a prefix from before the suspend can't
36
+ // corrupt the first bytes read after it.
37
+ reset() {
38
+ this._partial = ''
39
+ }
40
+
41
+ feed(buf) {
42
+ const s = this._partial + buf.toString('latin1')
43
+ this._partial = ''
44
+
45
+ let keys = ''
46
+ const events = []
47
+ let i = 0
48
+
49
+ while (i < s.length) {
50
+ // Only CSI (ESC [) introduces something we might claim. This is also what
51
+ // keeps SS3 (ESC O, the prefix for F1-F4 and application-mode arrows)
52
+ // safe: it has no '[', so it never looks like a focus report.
53
+ if (s[i] !== ESC || s[i + 1] !== '[') {
54
+ keys += s[i++]
55
+ continue
56
+ }
57
+
58
+ // The read ended on a bare "ESC [". Hold it: the decoder would park on
59
+ // those same two bytes and emit nothing either way (its 500ms escape
60
+ // timer only arms when ESC is the *last* byte written), so holding costs
61
+ // no latency and lets a report split across reads still be recognised. A
62
+ // lone trailing ESC is never held — that one does time out into an
63
+ // escape key, and swallowing it would break the Escape key.
64
+ if (i + 2 >= s.length) {
65
+ this._partial = s.slice(i)
66
+ break
67
+ }
68
+
69
+ const c = s[i + 2]
70
+
71
+ if (this.focus && (c === 'I' || c === 'O')) {
72
+ events.push(focusMsg(c === 'I'))
73
+ i += 3
74
+ continue
75
+ }
76
+
77
+ if (this.mouse && c === '<') {
78
+ const cap = i + 3 + MAX_MOUSE_BODY
79
+ let j = i + 3
80
+ while (j < s.length && j < cap && ((s[j] >= '0' && s[j] <= '9') || s[j] === ';')) j++
81
+
82
+ // Stopped because the input ran out (rather than because the body is
83
+ // overlong or ended in something that isn't a terminator): wait for the
84
+ // rest of the report.
85
+ if (j === s.length && j < cap) {
86
+ this._partial = s.slice(i)
87
+ break
88
+ }
89
+
90
+ if (s[j] === 'M' || s[j] === 'm') {
91
+ const event = decode(s.slice(i + 3, j), s[j])
92
+ if (event) {
93
+ events.push(event)
94
+ i = j + 1
95
+ continue
96
+ }
97
+ }
98
+ // Malformed or overlong — not a report after all; fall through and let
99
+ // the bytes go to the decoder as keys.
100
+ }
101
+
102
+ // Not ours: emit the ESC and re-examine the rest as ordinary key bytes.
103
+ keys += s[i++]
104
+ }
105
+
106
+ return { keys: Buffer.from(keys, 'latin1'), events }
107
+ }
108
+ }
109
+
110
+ module.exports = { InputParser }
package/messages.js CHANGED
@@ -47,6 +47,15 @@ function windowSize(width, height) {
47
47
  return { type: 'resize', width, height }
48
48
  }
49
49
 
50
+ // Emitted when the terminal window gains or loses focus. Only ever produced
51
+ // when the Program was created with `focus: true`, which puts the terminal into
52
+ // focus reporting (DEC private mode 1004). Reports are *transitions*: a terminal
53
+ // that supports the mode says nothing until the focus actually changes, so a
54
+ // model should assume it starts focused rather than wait to be told.
55
+ function focusMsg(focused) {
56
+ return { type: 'focus', focused }
57
+ }
58
+
50
59
  // The runtime tears down and exits when it sees this. `quit` (see commands.js)
51
60
  // is the Cmd that produces it.
52
61
  function quitMsg() {
@@ -59,4 +68,4 @@ function errorMsg(error) {
59
68
  return { type: 'error', error }
60
69
  }
61
70
 
62
- module.exports = { KeyMsg, windowSize, quitMsg, errorMsg }
71
+ module.exports = { KeyMsg, windowSize, focusMsg, quitMsg, errorMsg }
package/mouse.js CHANGED
@@ -1,9 +1,8 @@
1
1
  // Mouse support — SGR (1006) tracking and decoding.
2
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.
3
+ // This module owns the mode strings and the report decoder; pulling the reports
4
+ // out of the raw byte stream is input.js's job (it claims \x1b[<b;x;yM for
5
+ // press / motion and \x1b[<b;x;ym for release, and calls decode() on the body).
7
6
  //
8
7
  // A MouseMsg looks like:
9
8
  // { type: 'mouse', action, button, x, y, ctrl, alt, shift }
@@ -58,45 +57,4 @@ function decode(body, final) {
58
57
  return { type: 'mouse', action, button, x: col - 1, y: row - 1, ...mods }
59
58
  }
60
59
 
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 }
60
+ module.exports = { enable, disable, decode, MODES }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-tui",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "A little TUI framework for Bare, based on bubbletea",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -15,6 +15,7 @@
15
15
  "index.js",
16
16
  "index.d.ts",
17
17
  "ansi.js",
18
+ "input.js",
18
19
  "commands.js",
19
20
  "key.js",
20
21
  "messages.js",
package/program.js CHANGED
@@ -6,7 +6,8 @@
6
6
  // view() -> string render current state to text
7
7
  //
8
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
9
+ // keystrokes into KeyMsgs, claims the terminal's non-key reports (mouse, and
10
+ // focus when `focus: true`), turns SIGWINCH into resize Msgs, runs the
10
11
  // update/render loop, executes Cmds off the update path, and — crucially —
11
12
  // always restores the terminal on the way out.
12
13
  //
@@ -16,7 +17,9 @@
16
17
  const tty = require('bare-tty')
17
18
  const KeyDecoder = require('bare-ansi-escapes/key-decoder')
18
19
  const Renderer = require('./renderer')
20
+ const ansi = require('./ansi')
19
21
  const mouse = require('./mouse')
22
+ const { InputParser } = require('./input')
20
23
  const { KeyMsg, windowSize } = require('./messages')
21
24
 
22
25
  module.exports = class Program {
@@ -36,7 +39,16 @@ module.exports = class Program {
36
39
  // 'all' → + hover motion. Off by default.
37
40
  const m = opts.mouse
38
41
  this._mouseMode = m === true ? 'basic' : m === 'motion' ? 'drag' : m in mouse.MODES ? m : null
39
- this._mouseParser = null
42
+
43
+ // Focus reporting (DEC mode 1004): the terminal reports when its window
44
+ // gains or loses focus as { type: 'focus', focused }. Off by default — not
45
+ // every terminal implements it (Terminal.app and screen don't; tmux needs
46
+ // `focus-events on`), and an app that doesn't care shouldn't pay for it.
47
+ this._focus = opts.focus === true
48
+
49
+ // Claims mouse / focus reports before the key decoder. Null when neither is
50
+ // enabled, so the common case writes bytes straight through.
51
+ this._parser = null
40
52
 
41
53
  // Only TTY fds can be put in raw mode / sized, and constructing a
42
54
  // tty.WriteStream on a non-TTY fd throws — so fall back to a no-op-ish
@@ -151,6 +163,28 @@ module.exports = class Program {
151
163
  }
152
164
  }
153
165
 
166
+ // The pre-parser only exists when there's something to claim; otherwise input
167
+ // bytes go straight to the key decoder.
168
+ _newParser() {
169
+ if (!this._mouseMode && !this._focus) return null
170
+ return new InputParser({ mouse: this._mouseMode, focus: this._focus })
171
+ }
172
+
173
+ // The input reporting modes go on together after the screen is entered and
174
+ // come off together before it's restored, so they never outlive the program
175
+ // (_teardown) or leak into a child process (_suspendTerminal).
176
+ _enableModes() {
177
+ if (this._mouseMode) this.output.write(mouse.enable(this._mouseMode))
178
+ if (this._focus) this.output.write(ansi.enableFocus)
179
+ }
180
+
181
+ _disableModes() {
182
+ try {
183
+ if (this._mouseMode) this.output.write(mouse.disable(this._mouseMode))
184
+ if (this._focus) this.output.write(ansi.disableFocus)
185
+ } catch {}
186
+ }
187
+
154
188
  _setup() {
155
189
  if (this.input) {
156
190
  if (this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(true)
@@ -159,12 +193,12 @@ module.exports = class Program {
159
193
  // unpipe, and a piped source destroyed mid-stream (which is exactly what
160
194
  // teardown does) destroys the destination with a synthetic "closed before
161
195
  // ending" error. Manual forwarding has no Pipeline, so teardown is clean.
162
- this._mouseParser = this._mouseMode ? new mouse.MouseParser() : null
196
+ this._parser = this._newParser()
163
197
  this._onKey = (key) => this.send(new KeyMsg(key))
164
198
  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)
199
+ if (this._parser) {
200
+ // Peel mouse / focus reports off the stream; the rest is keys.
201
+ const { keys, events } = this._parser.feed(data)
168
202
  for (const event of events) this.send(event)
169
203
  if (keys.length) this._decoder.write(keys)
170
204
  } else {
@@ -181,7 +215,7 @@ module.exports = class Program {
181
215
  }
182
216
 
183
217
  this.renderer.start()
184
- if (this._mouseMode) this.output.write(mouse.enable(this._mouseMode))
218
+ this._enableModes()
185
219
 
186
220
  // Seed the model with the initial geometry. Real TTYs report columns/rows;
187
221
  // injected streams won't, so fall back to opts then a sane default.
@@ -231,14 +265,15 @@ module.exports = class Program {
231
265
  try {
232
266
  this._decoder?.destroy()
233
267
  } catch {}
268
+ // Stop the reporting modes before leaving raw mode: in between the terminal
269
+ // is line-buffered and echoing, so a report landing in that window would be
270
+ // painted onto the screen we're about to hand back.
271
+ this._disableModes()
234
272
  try {
235
273
  if (this.input && this.inputIsTTY && this.input.setRawMode) {
236
274
  this.input.setRawMode(false)
237
275
  }
238
276
  } catch {}
239
- try {
240
- if (this._mouseMode) this.output.write(mouse.disable(this._mouseMode))
241
- } catch {}
242
277
 
243
278
  this.renderer.stop() // show cursor, leave alt screen
244
279
 
@@ -272,15 +307,17 @@ module.exports = class Program {
272
307
  this._decoder?.destroy()
273
308
  } catch {}
274
309
  this._decoder = null
310
+ // A half-claimed sequence must not survive into the resumed session.
311
+ this._parser?.reset()
312
+ // Off before the child runs: a program that doesn't understand these reports
313
+ // would read them as garbage input.
314
+ this._disableModes()
275
315
  try {
276
316
  if (this.input && this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(false)
277
317
  } catch {}
278
318
  try {
279
319
  this.input?.pause?.()
280
320
  } catch {}
281
- try {
282
- if (this._mouseMode) this.output.write(mouse.disable(this._mouseMode))
283
- } catch {}
284
321
  this.renderer.stop()
285
322
  }
286
323
 
@@ -293,13 +330,14 @@ module.exports = class Program {
293
330
  if (this.inputIsTTY && this.input.setRawMode) this.input.setRawMode(true)
294
331
  } catch {}
295
332
  this._decoder = new KeyDecoder()
333
+ this._parser = this._newParser()
296
334
  this._decoder.on('data', this._onKey)
297
335
  this.input.on('data', this._onInput)
298
336
  try {
299
337
  this.input.resume?.()
300
338
  } catch {}
301
339
  }
302
- if (this._mouseMode) this.output.write(mouse.enable(this._mouseMode))
340
+ this._enableModes()
303
341
  this.renderer.clear() // next render repaints everything
304
342
  this._suspended = false
305
343
  }