table-viewer 0.0.1 → 0.0.3-beta.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.
Files changed (2) hide show
  1. package/index.js +205 -32
  2. package/package.json +10 -2
package/index.js CHANGED
@@ -23,6 +23,13 @@ const consoleTableStartChars = {
23
23
  2: '├',
24
24
  }
25
25
 
26
+ const scrollbarEndChars = {
27
+ up: '·',
28
+ down: '·',
29
+ left: '·',
30
+ right: '·',
31
+ }
32
+
26
33
  const consoleTableOutput = captureConsoleTableOutput(tableData)
27
34
 
28
35
  const consoleTableOutputSplit = consoleTableOutput.split('\n')
@@ -31,18 +38,36 @@ let consoleWidth = process.stdout.columns
31
38
  let consoleHeight = process.stdout.rows
32
39
  let xOffset = 0
33
40
  let yOffset = 0
41
+ let mouseSequenceBuffer = ''
42
+ let didCleanupTerminal = false
34
43
 
35
44
  if (!consoleTableOutputSplit.length) process.exit(0)
36
45
 
37
46
  const consoleTableOutputWidth = consoleTableOutputSplit[0].length
38
47
  const consoleTableOutputHeight = consoleTableOutputSplit.length
39
48
 
40
- // Hide cursor
41
- process.stdout.write('\x1B[?25l')
49
+ // Enter alternate screen buffer and hide cursor
50
+ process.stdout.write('\x1B[?1049h\x1B[?25l')
51
+ enableMouseReporting()
42
52
 
43
- // Restore cursor on exit
53
+ // Restore cursor and leave alternate screen buffer on exit
44
54
  process.on('exit', () => {
45
- process.stdout.write('\x1B[?25h')
55
+ cleanupTerminal()
56
+ })
57
+
58
+ process.on('SIGINT', () => {
59
+ process.exit(0)
60
+ })
61
+
62
+ process.on('unhandledRejection', (error) => {
63
+ cleanupTerminal()
64
+ throw error
65
+ })
66
+
67
+ process.on('uncaughtException', (error) => {
68
+ cleanupTerminal()
69
+ console.error(error)
70
+ process.exit(1)
46
71
  })
47
72
 
48
73
  process.stdout.on('resize', () => {
@@ -57,31 +82,24 @@ process.stdout.on('resize', () => {
57
82
  let pendingG = false
58
83
 
59
84
  ttyIn.on('keypress', (_str, key) => {
85
+ // Ignore mouse sequences that readline may emit as unrecognized keypresses
86
+ if (key.sequence?.startsWith('\x1b[<')) return
87
+
60
88
  // Ctrl+C or q to exit
61
89
  if ((key.ctrl && key.name === 'c') || key.name === 'q') {
62
90
  process.exit(0)
63
91
  }
64
92
 
65
- const { needsScrollbarX, needsScrollbarY } = computeScrollbarVisibility()
66
-
67
- const maxXOffset = Math.max(
68
- 0,
69
- consoleTableOutputWidth - consoleWidth + (needsScrollbarY ? 1 : 0),
70
- )
71
-
72
- const maxYOffset = Math.max(
73
- 0,
74
- consoleTableOutputHeight - consoleHeight + (needsScrollbarX ? 1 : 0),
75
- )
76
-
77
- const visibleHeight = consoleHeight - (needsScrollbarX ? 1 : 0)
93
+ const prevX = xOffset
94
+ const prevY = yOffset
95
+ const { maxXOffset, maxYOffset, visibleHeight } = getViewportMetrics()
78
96
 
79
97
  // Handle gg (go to top)
80
98
  if (key.name === 'g' && !key.ctrl && !key.shift) {
81
99
  if (pendingG) {
82
100
  pendingG = false
83
101
  yOffset = 0
84
- render()
102
+ if (yOffset !== prevY) render()
85
103
  return
86
104
  }
87
105
 
@@ -94,7 +112,7 @@ ttyIn.on('keypress', (_str, key) => {
94
112
  pendingG = false
95
113
  yOffset = maxYOffset
96
114
 
97
- render()
115
+ if (yOffset !== prevY) render()
98
116
  return
99
117
  }
100
118
 
@@ -105,21 +123,21 @@ ttyIn.on('keypress', (_str, key) => {
105
123
  // -- vertical movement --
106
124
  case 'k':
107
125
  case 'up':
108
- yOffset = Math.max(0, yOffset - (key.shift ? 10 : 5))
126
+ moveUp(key.shift ? 10 : 5)
109
127
  break
110
128
  case 'j':
111
129
  case 'down':
112
- yOffset = Math.min(maxYOffset, yOffset + (key.shift ? 10 : 5))
130
+ moveDown(key.shift ? 10 : 5)
113
131
  break
114
132
 
115
133
  // -- horizontal movement --
116
134
  case 'h':
117
135
  case 'left':
118
- xOffset = Math.max(0, xOffset - (key.shift ? 10 : 5))
136
+ moveLeft(key.shift ? 10 : 5)
119
137
  break
120
138
  case 'l':
121
139
  case 'right':
122
- xOffset = Math.min(maxXOffset, xOffset + (key.shift ? 10 : 5))
140
+ moveRight(key.shift ? 10 : 5)
123
141
  break
124
142
 
125
143
  // Ctrl+d — half page down
@@ -158,8 +176,8 @@ ttyIn.on('keypress', (_str, key) => {
158
176
  xOffset = 0
159
177
  break
160
178
  // $ — scroll to rightmost
161
- case '4': // shift+4 = $
162
- if (key.shift) {
179
+ case undefined:
180
+ if (key.sequence === '$') {
163
181
  xOffset = maxXOffset
164
182
  } else {
165
183
  return
@@ -170,11 +188,165 @@ ttyIn.on('keypress', (_str, key) => {
170
188
  return
171
189
  }
172
190
 
173
- render()
191
+ if (xOffset !== prevX || yOffset !== prevY) render()
192
+ })
193
+
194
+ ttyIn.on('data', (chunk) => {
195
+ mouseSequenceBuffer += chunk.toString('utf8')
196
+ processMouseBuffer()
174
197
  })
175
198
 
176
199
  render()
177
200
 
201
+ function processMouseBuffer() {
202
+ while (mouseSequenceBuffer.length) {
203
+ const mouseStart = mouseSequenceBuffer.indexOf('\x1b[<')
204
+
205
+ if (mouseStart === -1) {
206
+ mouseSequenceBuffer = mouseSequenceBuffer.endsWith('\x1b') ? '\x1b' : ''
207
+ return
208
+ }
209
+
210
+ if (mouseStart > 0) {
211
+ mouseSequenceBuffer = mouseSequenceBuffer.slice(mouseStart)
212
+ }
213
+
214
+ const match = mouseSequenceBuffer.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/)
215
+
216
+ if (!match) {
217
+ if (/^\x1b\[<[\d;]*$/.test(mouseSequenceBuffer)) {
218
+ return
219
+ }
220
+
221
+ mouseSequenceBuffer = mouseSequenceBuffer.slice(1)
222
+ continue
223
+ }
224
+
225
+ handleMouseButton(
226
+ Number.parseInt(match[1], 10),
227
+ /** @type {'M' | 'm'} */ (match[4]),
228
+ )
229
+ mouseSequenceBuffer = mouseSequenceBuffer.slice(match[0].length)
230
+ }
231
+ }
232
+
233
+ /**
234
+ * @param {number} buttonCode
235
+ * @param {'M' | 'm'} action
236
+ */
237
+ function handleMouseButton(buttonCode, action) {
238
+ // Wheel events should be press-only. Ignore release-style reports to avoid jitter.
239
+ if (action !== 'M') return
240
+
241
+ if ((buttonCode & 64) === 0) return
242
+
243
+ const wheelButton = buttonCode & 3
244
+ const isShiftPressed = (buttonCode & 4) !== 0
245
+
246
+ let moved = false
247
+
248
+ switch (wheelButton) {
249
+ case 0: // wheel up
250
+ moved = isShiftPressed ? moveRight(5) : moveUp(5)
251
+ break
252
+ case 1: // wheel down
253
+ moved = isShiftPressed ? moveLeft(5) : moveDown(5)
254
+ break
255
+ case 2: // wheel right (native horizontal)
256
+ moved = moveRight(5)
257
+ break
258
+ case 3: // wheel left (native horizontal)
259
+ moved = moveLeft(5)
260
+ break
261
+ default:
262
+ return
263
+ }
264
+
265
+ if (moved) render()
266
+ }
267
+
268
+ function getViewportMetrics() {
269
+ const { needsScrollbarX, needsScrollbarY } = computeScrollbarVisibility()
270
+
271
+ const maxXOffset = Math.max(
272
+ 0,
273
+ consoleTableOutputWidth - consoleWidth + (needsScrollbarY ? 1 : 0),
274
+ )
275
+
276
+ const maxYOffset = Math.max(
277
+ 0,
278
+ consoleTableOutputHeight - consoleHeight + (needsScrollbarX ? 1 : 0),
279
+ )
280
+
281
+ const visibleHeight = consoleHeight - (needsScrollbarX ? 1 : 0)
282
+
283
+ return { maxXOffset, maxYOffset, visibleHeight }
284
+ }
285
+
286
+ /**
287
+ * @param {number} amount
288
+ * @returns {boolean} whether the offset changed
289
+ */
290
+ function moveUp(amount) {
291
+ const prev = yOffset
292
+ yOffset = Math.max(0, yOffset - amount)
293
+ return yOffset !== prev
294
+ }
295
+
296
+ /**
297
+ * @param {number} amount
298
+ * @returns {boolean} whether the offset changed
299
+ */
300
+ function moveDown(amount) {
301
+ const prev = yOffset
302
+ const { maxYOffset } = getViewportMetrics()
303
+ yOffset = Math.min(maxYOffset, yOffset + amount)
304
+ return yOffset !== prev
305
+ }
306
+
307
+ /**
308
+ * @param {number} amount
309
+ * @returns {boolean} whether the offset changed
310
+ */
311
+ function moveLeft(amount) {
312
+ const prev = xOffset
313
+ xOffset = Math.max(0, xOffset - amount)
314
+ return xOffset !== prev
315
+ }
316
+
317
+ /**
318
+ * @param {number} amount
319
+ * @returns {boolean} whether the offset changed
320
+ */
321
+ function moveRight(amount) {
322
+ const prev = xOffset
323
+ const { maxXOffset } = getViewportMetrics()
324
+ xOffset = Math.min(maxXOffset, xOffset + amount)
325
+ return xOffset !== prev
326
+ }
327
+
328
+ function enableMouseReporting() {
329
+ process.stdout.write('\x1B[?1000h\x1B[?1006h')
330
+ }
331
+
332
+ function disableMouseReporting() {
333
+ process.stdout.write('\x1B[?1000l\x1B[?1006l')
334
+ }
335
+
336
+ function cleanupTerminal() {
337
+ if (didCleanupTerminal) return
338
+
339
+ didCleanupTerminal = true
340
+
341
+ disableMouseReporting()
342
+
343
+ if (ttyIn.isRaw) {
344
+ ttyIn.setRawMode(false)
345
+ }
346
+
347
+ process.stdout.write('\x1B[?25h\x1B[?1049l')
348
+ }
349
+
178
350
  function render() {
179
351
  console.clear()
180
352
 
@@ -236,7 +408,7 @@ function renderScrollbarX(needsScrollbarX, needsScrollbarY) {
236
408
  if (!needsScrollbarX) return ''
237
409
 
238
410
  const visibleWidth = consoleWidth - (needsScrollbarY ? 1 : 0)
239
- const trackLength = visibleWidth - (needsScrollbarY ? 1 : 2)
411
+ const trackLength = visibleWidth - 2
240
412
 
241
413
  const thumbSize = Math.max(
242
414
  1,
@@ -251,11 +423,12 @@ function renderScrollbarX(needsScrollbarX, needsScrollbarY) {
251
423
  ) || 0
252
424
 
253
425
  return (
254
- ' ' +
426
+ scrollbarEndChars.left +
255
427
  '-'.repeat(thumbPos) +
256
428
  '═'.repeat(thumbSize) +
257
429
  '-'.repeat(trackLength - thumbPos - thumbSize) +
258
- ' '
430
+ scrollbarEndChars.right +
431
+ (needsScrollbarY ? ' ' : '')
259
432
  )
260
433
  }
261
434
 
@@ -268,7 +441,7 @@ function renderScrollbarY(needsScrollbarX, needsScrollbarY) {
268
441
  if (!needsScrollbarY) return ''
269
442
 
270
443
  const visibleHeight = consoleHeight - (needsScrollbarX ? 1 : 0)
271
- const trackLength = visibleHeight - (needsScrollbarX ? 1 : 2)
444
+ const trackLength = visibleHeight - 2
272
445
 
273
446
  const thumbSize = Math.max(
274
447
  1,
@@ -283,11 +456,11 @@ function renderScrollbarY(needsScrollbarX, needsScrollbarY) {
283
456
  ) || 0
284
457
 
285
458
  return (
286
- ' ' +
459
+ scrollbarEndChars.up +
287
460
  '╎'.repeat(thumbPos) +
288
461
  '║'.repeat(thumbSize) +
289
462
  '╎'.repeat(trackLength - thumbPos - thumbSize) +
290
- (needsScrollbarX ? '' : ' ')
463
+ scrollbarEndChars.down
291
464
  )
292
465
  }
293
466
 
package/package.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
2
  "name": "table-viewer",
3
- "version": "0.0.1",
3
+ "version": "0.0.3-beta.0",
4
4
  "description": "A keyboard-navigable terminal table viewer",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
- "bin": "./index.js"
7
+ "bin": "./index.js",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/aaronccasanova/table-viewer.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/aaronccasanova/table-viewer/issues"
14
+ },
15
+ "homepage": "https://github.com/aaronccasanova/table-viewer"
8
16
  }