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.
Files changed (48) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +13 -0
  3. package/README.md +261 -0
  4. package/ansi.js +34 -0
  5. package/commands.js +67 -0
  6. package/components/autocomplete.js +208 -0
  7. package/components/checkbox.js +68 -0
  8. package/components/filepicker.js +232 -0
  9. package/components/focus.js +129 -0
  10. package/components/help.js +117 -0
  11. package/components/list.js +172 -0
  12. package/components/paginator.js +111 -0
  13. package/components/progress.js +78 -0
  14. package/components/radio.js +113 -0
  15. package/components/select.js +165 -0
  16. package/components/spinner.js +70 -0
  17. package/components/stopwatch.js +88 -0
  18. package/components/table.js +130 -0
  19. package/components/textarea.js +279 -0
  20. package/components/textinput.js +135 -0
  21. package/components/timer.js +91 -0
  22. package/components/viewport.js +104 -0
  23. package/docs/autocomplete.md +80 -0
  24. package/docs/checkbox.md +43 -0
  25. package/docs/filepicker.md +80 -0
  26. package/docs/focus.md +68 -0
  27. package/docs/help.md +45 -0
  28. package/docs/list.md +47 -0
  29. package/docs/paginator.md +43 -0
  30. package/docs/progress.md +36 -0
  31. package/docs/radio.md +49 -0
  32. package/docs/select.md +54 -0
  33. package/docs/spinner.md +54 -0
  34. package/docs/stopwatch.md +52 -0
  35. package/docs/table.md +52 -0
  36. package/docs/textarea.md +43 -0
  37. package/docs/textinput.md +51 -0
  38. package/docs/timer.md +57 -0
  39. package/docs/viewport.md +39 -0
  40. package/index.d.ts +1 -0
  41. package/index.js +71 -0
  42. package/key.js +29 -0
  43. package/messages.js +62 -0
  44. package/mouse.js +102 -0
  45. package/package.json +59 -1
  46. package/program.js +387 -0
  47. package/renderer.js +67 -0
  48. package/style.js +473 -0
package/style.js ADDED
@@ -0,0 +1,473 @@
1
+ // style — terminal layout & styling, in the spirit of Charm's lipgloss.
2
+ //
3
+ // const { style } = require('./lib/tea')
4
+ // style().bold(true).foreground('205').padding(1, 2).border(style.borders.rounded).render('hi')
5
+ //
6
+ // A Style is immutable and chainable: every setter returns a new Style, and
7
+ // render(text) applies the whole declaration in one pass — width/align, then
8
+ // padding, then text styling (so a background fills the padding), then border,
9
+ // then margin.
10
+ //
11
+ // Everything is built on visible-width measurement (`width`) that ignores ANSI
12
+ // escapes and counts wide glyphs as two cells — without that, borders and the
13
+ // join helpers would drift the instant styled text passes through them.
14
+ const { constants } = require('bare-ansi-escapes')
15
+ const ansi = require('./ansi')
16
+
17
+ const CSI = constants.CSI
18
+ const RESET = ansi.modifierReset
19
+
20
+ // ── width measurement ──────────────────────────────────────────────────────
21
+
22
+ const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g
23
+ const ANSI_STICKY = /\x1b\[[0-9;?]*[A-Za-z]/y
24
+
25
+ function stripAnsi(str) {
26
+ return String(str).replace(ANSI_RE, '')
27
+ }
28
+
29
+ // Cells occupied by a single code point: 0 for control/combining/zero-width,
30
+ // 2 for wide (CJK, fullwidth, most emoji), 1 otherwise. An approximation of
31
+ // wcwidth covering the ranges that actually show up in TUIs.
32
+ function charWidth(cp) {
33
+ if (cp === 0) return 0
34
+ if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0 // C0/C1 control
35
+ if (isZeroWidth(cp)) return 0
36
+ if (isWide(cp)) return 2
37
+ return 1
38
+ }
39
+
40
+ function isZeroWidth(cp) {
41
+ return (
42
+ (cp >= 0x0300 && cp <= 0x036f) || // combining diacriticals
43
+ (cp >= 0x1ab0 && cp <= 0x1aff) ||
44
+ (cp >= 0x1dc0 && cp <= 0x1dff) ||
45
+ (cp >= 0x20d0 && cp <= 0x20ff) || // combining marks for symbols
46
+ (cp >= 0xfe20 && cp <= 0xfe2f) ||
47
+ cp === 0x200b || // zero-width space
48
+ (cp >= 0x200c && cp <= 0x200f) ||
49
+ cp === 0xfeff
50
+ )
51
+ }
52
+
53
+ function isWide(cp) {
54
+ return (
55
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
56
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals … punctuation
57
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana … CJK compat
58
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
59
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
60
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
61
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
62
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compat ideographs
63
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compat forms
64
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
65
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
66
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji & symbols
67
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B+
68
+ )
69
+ }
70
+
71
+ // Visible width of a single line (no newlines expected here).
72
+ function lineWidth(line) {
73
+ let w = 0
74
+ for (const ch of stripAnsi(line)) w += charWidth(ch.codePointAt(0))
75
+ return w
76
+ }
77
+
78
+ // Visible width of a block: the widest line.
79
+ function width(str) {
80
+ let w = 0
81
+ for (const line of String(str).split('\n')) w = Math.max(w, lineWidth(line))
82
+ return w
83
+ }
84
+
85
+ // Line count of a block.
86
+ function height(str) {
87
+ return String(str).split('\n').length
88
+ }
89
+
90
+ // Truncate to `w` visible cells, preserving (and closing) escape sequences.
91
+ function truncate(str, w) {
92
+ if (w <= 0) return ''
93
+ let out = ''
94
+ let used = 0
95
+ let sawAnsi = false
96
+ let i = 0
97
+ while (i < str.length) {
98
+ if (str[i] === '\x1b') {
99
+ ANSI_STICKY.lastIndex = i
100
+ const m = ANSI_STICKY.exec(str)
101
+ if (m) {
102
+ out += m[0]
103
+ sawAnsi = true
104
+ i = ANSI_STICKY.lastIndex
105
+ continue
106
+ }
107
+ }
108
+ const cp = str.codePointAt(i)
109
+ const ch = String.fromCodePoint(cp)
110
+ const cw = charWidth(cp)
111
+ if (used + cw > w) break
112
+ out += ch
113
+ used += cw
114
+ i += ch.length
115
+ }
116
+ if (sawAnsi) out += RESET
117
+ return out
118
+ }
119
+
120
+ // Pad (or truncate) a line to `w` cells, positioning content by `pos`
121
+ // (0 left, 0.5 center, 1 right).
122
+ function padLine(line, w, pos = 0) {
123
+ const lw = lineWidth(line)
124
+ if (lw > w) return truncate(line, w)
125
+ const space = w - lw
126
+ if (space === 0) return line
127
+ if (pos <= 0) return line + ' '.repeat(space)
128
+ if (pos >= 1) return ' '.repeat(space) + line
129
+ const left = Math.floor(space * pos)
130
+ return ' '.repeat(left) + line + ' '.repeat(space - left)
131
+ }
132
+
133
+ // ── colors ───────────────────────────────────────────────────────────────
134
+
135
+ const NAMED = {
136
+ black: 30,
137
+ red: 31,
138
+ green: 32,
139
+ yellow: 33,
140
+ blue: 34,
141
+ magenta: 35,
142
+ cyan: 36,
143
+ white: 37,
144
+ default: 39,
145
+ gray: 90,
146
+ grey: 90,
147
+ brightblack: 90,
148
+ brightred: 91,
149
+ brightgreen: 92,
150
+ brightyellow: 93,
151
+ brightblue: 94,
152
+ brightmagenta: 95,
153
+ brightcyan: 96,
154
+ brightwhite: 97
155
+ }
156
+
157
+ // Resolve a color spec to SGR params. Accepts a named color, a 0–255 ANSI-256
158
+ // index (number or numeric string), or a #rgb / #rrggbb truecolor hex.
159
+ function colorParams(spec, bg) {
160
+ if (spec === undefined || spec === null || spec === '') return []
161
+ const lead = bg ? 48 : 38
162
+
163
+ if (typeof spec === 'number') return [lead, 5, spec & 255]
164
+
165
+ const s = String(spec)
166
+ if (s[0] === '#') {
167
+ let hex = s.slice(1)
168
+ if (hex.length === 3) hex = hex.replace(/./g, (c) => c + c)
169
+ const n = parseInt(hex, 16)
170
+ return [lead, 2, (n >> 16) & 255, (n >> 8) & 255, n & 255]
171
+ }
172
+ const name = s.toLowerCase()
173
+ if (name in NAMED) return [bg ? NAMED[name] + 10 : NAMED[name]]
174
+ if (/^\d+$/.test(s)) return [lead, 5, parseInt(s, 10) & 255]
175
+ return []
176
+ }
177
+
178
+ function sgr(params) {
179
+ return params.length ? CSI + params.join(';') + 'm' : ''
180
+ }
181
+
182
+ // ── borders ────────────────────────────────────────────────────────────────
183
+
184
+ const borders = {
185
+ normal: {
186
+ topLeft: '┌',
187
+ top: '─',
188
+ topRight: '┐',
189
+ left: '│',
190
+ right: '│',
191
+ bottomLeft: '└',
192
+ bottom: '─',
193
+ bottomRight: '┘'
194
+ },
195
+ rounded: {
196
+ topLeft: '╭',
197
+ top: '─',
198
+ topRight: '╮',
199
+ left: '│',
200
+ right: '│',
201
+ bottomLeft: '╰',
202
+ bottom: '─',
203
+ bottomRight: '╯'
204
+ },
205
+ thick: {
206
+ topLeft: '┏',
207
+ top: '━',
208
+ topRight: '┓',
209
+ left: '┃',
210
+ right: '┃',
211
+ bottomLeft: '┗',
212
+ bottom: '━',
213
+ bottomRight: '┛'
214
+ },
215
+ double: {
216
+ topLeft: '╔',
217
+ top: '═',
218
+ topRight: '╗',
219
+ left: '║',
220
+ right: '║',
221
+ bottomLeft: '╚',
222
+ bottom: '═',
223
+ bottomRight: '╝'
224
+ }
225
+ }
226
+
227
+ // ── positions ────────────────────────────────────────────────────────────
228
+
229
+ const position = { top: 0, left: 0, center: 0.5, right: 1, bottom: 1 }
230
+
231
+ // CSS-like side shorthand → [top, right, bottom, left].
232
+ function sides(args) {
233
+ const a = args.map((n) => n || 0)
234
+ if (a.length <= 1) return [a[0] || 0, a[0] || 0, a[0] || 0, a[0] || 0]
235
+ if (a.length === 2) return [a[0], a[1], a[0], a[1]]
236
+ if (a.length === 3) return [a[0], a[1], a[2], a[1]]
237
+ return [a[0], a[1], a[2], a[3]]
238
+ }
239
+
240
+ // ── Style ────────────────────────────────────────────────────────────────
241
+
242
+ class Style {
243
+ constructor(props = {}) {
244
+ this.props = props
245
+ }
246
+
247
+ _with(patch) {
248
+ return new Style({ ...this.props, ...patch })
249
+ }
250
+
251
+ bold(v = true) {
252
+ return this._with({ bold: v })
253
+ }
254
+ faint(v = true) {
255
+ return this._with({ faint: v })
256
+ }
257
+ italic(v = true) {
258
+ return this._with({ italic: v })
259
+ }
260
+ underline(v = true) {
261
+ return this._with({ underline: v })
262
+ }
263
+ strikethrough(v = true) {
264
+ return this._with({ strikethrough: v })
265
+ }
266
+ reverse(v = true) {
267
+ return this._with({ reverse: v })
268
+ }
269
+
270
+ foreground(c) {
271
+ return this._with({ fg: c })
272
+ }
273
+ background(c) {
274
+ return this._with({ bg: c })
275
+ }
276
+
277
+ width(n) {
278
+ return this._with({ width: n })
279
+ }
280
+ height(n) {
281
+ return this._with({ height: n })
282
+ }
283
+ align(pos) {
284
+ return this._with({ align: pos })
285
+ }
286
+ alignVertical(pos) {
287
+ return this._with({ alignV: pos })
288
+ }
289
+
290
+ padding(...v) {
291
+ return this._with({ padding: sides(v) })
292
+ }
293
+ margin(...v) {
294
+ return this._with({ margin: sides(v) })
295
+ }
296
+
297
+ border(chars, ...sidesOn) {
298
+ const on = sidesOn.length ? sides(sidesOn).map(Boolean) : [true, true, true, true]
299
+ return this._with({ border: chars, borderSides: on })
300
+ }
301
+ borderForeground(c) {
302
+ return this._with({ borderFg: c })
303
+ }
304
+
305
+ // SGR for text styling, opened once per inner line and closed with RESET.
306
+ _open() {
307
+ const p = this.props
308
+ const params = []
309
+ if (p.bold) params.push(1)
310
+ if (p.faint) params.push(2)
311
+ if (p.italic) params.push(3)
312
+ if (p.underline) params.push(4)
313
+ if (p.reverse) params.push(7)
314
+ if (p.strikethrough) params.push(9)
315
+ params.push(...colorParams(p.fg, false))
316
+ params.push(...colorParams(p.bg, true))
317
+ return sgr(params)
318
+ }
319
+
320
+ render(text) {
321
+ const p = this.props
322
+ const pad = p.padding || [0, 0, 0, 0]
323
+ const mar = p.margin || [0, 0, 0, 0]
324
+ const align = p.align || 0
325
+ let lines = String(text).split('\n')
326
+
327
+ // A rectangular block is required once anything needs to fill horizontally.
328
+ const block =
329
+ !!p.border ||
330
+ (p.bg !== undefined && p.bg !== null) ||
331
+ !!p.width ||
332
+ align !== 0 ||
333
+ pad[0] ||
334
+ pad[1] ||
335
+ pad[2] ||
336
+ pad[3]
337
+
338
+ // 1. width + horizontal alignment
339
+ const contentW = p.width || width(lines.join('\n'))
340
+ if (block) lines = lines.map((l) => padLine(l, contentW, align))
341
+ else lines = lines.map((l) => (lineWidth(l) > contentW ? truncate(l, contentW) : l))
342
+
343
+ // 2. fixed height (vertical alignment)
344
+ if (p.height) lines = fitHeight(lines, p.height, contentW, p.alignV || 0)
345
+
346
+ // 3. horizontal + vertical padding
347
+ const innerW = contentW + pad[1] + pad[3]
348
+ if (pad[1] || pad[3]) {
349
+ const l = ' '.repeat(pad[3])
350
+ const r = ' '.repeat(pad[1])
351
+ lines = lines.map((line) => l + line + r)
352
+ }
353
+ const blank = ' '.repeat(innerW)
354
+ for (let i = 0; i < pad[0]; i++) lines.unshift(blank)
355
+ for (let i = 0; i < pad[2]; i++) lines.push(blank)
356
+
357
+ // 4. text styling — wraps padding too so a background fills the box.
358
+ // Re-apply the block's SGR after every reset *inside* the content, so a
359
+ // background (or any attribute) covers the whole line instead of dying at
360
+ // the first nested span's reset and leaving the remainder unstyled.
361
+ const open = this._open()
362
+ if (open) lines = lines.map((line) => open + line.split(RESET).join(RESET + open) + RESET)
363
+
364
+ // 5. border
365
+ if (p.border) lines = applyBorder(lines, innerW, p.border, p.borderSides, p.borderFg)
366
+
367
+ // 6. margin (transparent)
368
+ if (mar[3] || mar[1]) {
369
+ const l = ' '.repeat(mar[3])
370
+ const r = ' '.repeat(mar[1])
371
+ lines = lines.map((line) => l + line + r)
372
+ }
373
+ const fullW = width(lines.join('\n'))
374
+ const marginBlank = ' '.repeat(fullW)
375
+ for (let i = 0; i < mar[0]; i++) lines.unshift(marginBlank)
376
+ for (let i = 0; i < mar[2]; i++) lines.push(marginBlank)
377
+
378
+ return lines.join('\n')
379
+ }
380
+ }
381
+
382
+ function fitHeight(lines, h, w, posV) {
383
+ if (lines.length >= h) return lines.slice(0, h)
384
+ const extra = h - lines.length
385
+ const before = posV <= 0 ? 0 : posV >= 1 ? extra : Math.floor(extra * posV)
386
+ const blank = ' '.repeat(w)
387
+ return [...Array(before).fill(blank), ...lines, ...Array(extra - before).fill(blank)]
388
+ }
389
+
390
+ function applyBorder(lines, innerW, chars, on, fg) {
391
+ const [t, r, b, l] = on
392
+ const paint = (s) => {
393
+ const params = colorParams(fg, false)
394
+ return params.length ? sgr(params) + s + RESET : s
395
+ }
396
+ const out = []
397
+ if (t) {
398
+ out.push(paint((l ? chars.topLeft : '') + chars.top.repeat(innerW) + (r ? chars.topRight : '')))
399
+ }
400
+ const left = l ? paint(chars.left) : ''
401
+ const right = r ? paint(chars.right) : ''
402
+ for (const line of lines) out.push(left + line + right)
403
+ if (b) {
404
+ out.push(
405
+ paint(
406
+ (l ? chars.bottomLeft : '') + chars.bottom.repeat(innerW) + (r ? chars.bottomRight : '')
407
+ )
408
+ )
409
+ }
410
+ return out
411
+ }
412
+
413
+ // ── joins ──────────────────────────────────────────────────────────────────
414
+
415
+ // Place blocks side by side, aligning their differing heights by `pos`
416
+ // (0 top, 0.5 center, 1 bottom).
417
+ function joinHorizontal(pos, ...blocks) {
418
+ const cols = blocks.map((b) => String(b).split('\n'))
419
+ const widths = cols.map((lines) => width(lines.join('\n')))
420
+ const h = Math.max(...cols.map((lines) => lines.length))
421
+
422
+ const padded = cols.map((lines, i) => {
423
+ const w = widths[i]
424
+ const filled = lines.map((line) => padLine(line, w, 0))
425
+ const extra = h - filled.length
426
+ const before = pos <= 0 ? 0 : pos >= 1 ? extra : Math.floor(extra * pos)
427
+ const blank = ' '.repeat(w)
428
+ return [...Array(before).fill(blank), ...filled, ...Array(extra - before).fill(blank)]
429
+ })
430
+
431
+ const out = []
432
+ for (let row = 0; row < h; row++) out.push(padded.map((c) => c[row]).join(''))
433
+ return out.join('\n')
434
+ }
435
+
436
+ // Stack blocks vertically, aligning their differing widths by `pos`
437
+ // (0 left, 0.5 center, 1 right).
438
+ function joinVertical(pos, ...blocks) {
439
+ const cols = blocks.map((b) => String(b).split('\n'))
440
+ const w = Math.max(...cols.map((lines) => width(lines.join('\n'))))
441
+ const out = []
442
+ for (const lines of cols) {
443
+ for (const line of lines) out.push(padLine(line, w, pos))
444
+ }
445
+ return out.join('\n')
446
+ }
447
+
448
+ // The public entry point is the `style` factory, with helpers attached.
449
+ function style() {
450
+ return new Style()
451
+ }
452
+ style.Style = Style
453
+ style.borders = borders
454
+ style.position = position
455
+ style.joinHorizontal = joinHorizontal
456
+ style.joinVertical = joinVertical
457
+ style.width = width
458
+ style.height = height
459
+ style.truncate = truncate
460
+ style.stripAnsi = stripAnsi
461
+
462
+ module.exports = {
463
+ style,
464
+ Style,
465
+ borders,
466
+ position,
467
+ joinHorizontal,
468
+ joinVertical,
469
+ width,
470
+ height,
471
+ truncate,
472
+ stripAnsi
473
+ }