novac 2.0.1 → 2.2.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/LICENSE +1 -1
- package/README.md +1574 -597
- package/bin/novac +468 -171
- package/bin/nvc +522 -0
- package/bin/nvml +78 -17
- package/demo.nv +0 -0
- package/demo_builtins.nv +0 -0
- package/demo_http.nv +0 -0
- package/examples/bf.nv +69 -0
- package/examples/math.nv +21 -0
- package/kits/birdAPI/kitdef.js +954 -0
- package/kits/kitRNG/kitdef.js +740 -0
- package/kits/kitSSH/kitdef.js +1272 -0
- package/kits/kitadb/kitdef.js +606 -0
- package/kits/kitai/kitdef.js +2185 -0
- package/kits/kitansi/kitdef.js +1402 -0
- package/kits/kitcanvas/kitdef.js +914 -0
- package/kits/kitclippy/kitdef.js +925 -0
- package/kits/kitformat/kitdef.js +1485 -0
- package/kits/kitgps/kitdef.js +1862 -0
- package/kits/kitlibproc/kitdef.js +3 -2
- package/kits/kitmatrix/ex.js +19 -0
- package/kits/kitmatrix/kitdef.js +960 -0
- package/kits/kitmorse/kitdef.js +229 -0
- package/kits/kitmpatch/kitdef.js +906 -0
- package/kits/kitnet/kitdef.js +1401 -0
- package/kits/kitnovacweb/README.md +1416 -143
- package/kits/kitnovacweb/kitdef.js +92 -2
- package/kits/kitnovacweb/nvml/executor.js +578 -176
- package/kits/kitnovacweb/nvml/index.js +2 -2
- package/kits/kitnovacweb/nvml/lexer.js +72 -69
- package/kits/kitnovacweb/nvml/parser.js +328 -159
- package/kits/kitnovacweb/nvml/renderer.js +770 -270
- package/kits/kitparse/kitdef.js +1688 -0
- package/kits/kitproto/kitdef.js +613 -0
- package/kits/kitqr/kitdef.js +637 -0
- package/kits/kitregex++/kitdef.js +1353 -0
- package/kits/kitrequire/kitdef.js +1599 -0
- package/kits/kitx11/kitdef.js +1 -0
- package/kits/kitx11/kitx11.js +2472 -0
- package/kits/kitx11/kitx11_conn.js +948 -0
- package/kits/kitx11/kitx11_worker.js +121 -0
- package/kits/libtea/kitdef.js +2691 -0
- package/kits/libterm/ex.js +285 -0
- package/kits/libterm/kitdef.js +1927 -0
- package/novac/LICENSE +21 -0
- package/novac/README.md +1823 -0
- package/novac/bin/novac +950 -0
- package/novac/bin/nvc +522 -0
- package/novac/bin/nvml +542 -0
- package/novac/demo.nv +245 -0
- package/novac/demo_builtins.nv +209 -0
- package/novac/demo_http.nv +62 -0
- package/novac/examples/bf.nv +69 -0
- package/novac/examples/math.nv +21 -0
- package/novac/kits/kitai/kitdef.js +2185 -0
- package/novac/kits/kitansi/kitdef.js +1402 -0
- package/novac/kits/kitformat/kitdef.js +1485 -0
- package/novac/kits/kitgps/kitdef.js +1862 -0
- package/novac/kits/kitlibfs/kitdef.js +231 -0
- package/{examples/example-project/nova_modules → novac/kits}/kitlibproc/kitdef.js +3 -2
- package/novac/kits/kitmatrix/ex.js +19 -0
- package/novac/kits/kitmatrix/kitdef.js +960 -0
- package/novac/kits/kitmpatch/kitdef.js +906 -0
- package/novac/kits/kitnovacweb/README.md +1572 -0
- package/novac/kits/kitnovacweb/demo.nv +12 -0
- package/novac/kits/kitnovacweb/demo.nvml +71 -0
- package/novac/kits/kitnovacweb/index.nova +12 -0
- package/novac/kits/kitnovacweb/kitdef.js +692 -0
- package/novac/kits/kitnovacweb/nova.kit.json +8 -0
- package/novac/kits/kitnovacweb/nvml/executor.js +739 -0
- package/novac/kits/kitnovacweb/nvml/index.js +67 -0
- package/novac/kits/kitnovacweb/nvml/lexer.js +263 -0
- package/novac/kits/kitnovacweb/nvml/parser.js +508 -0
- package/novac/kits/kitnovacweb/nvml/renderer.js +924 -0
- package/novac/kits/kitparse/kitdef.js +1688 -0
- package/novac/kits/kitregex++/kitdef.js +1353 -0
- package/novac/kits/kitrequire/kitdef.js +1599 -0
- package/novac/kits/kitx11/kitdef.js +1 -0
- package/novac/kits/kitx11/kitx11.js +2472 -0
- package/novac/kits/kitx11/kitx11_conn.js +948 -0
- package/novac/kits/kitx11/kitx11_worker.js +121 -0
- package/novac/kits/libtea/tf.js +2691 -0
- package/novac/kits/libterm/ex.js +285 -0
- package/novac/kits/libterm/kitdef.js +1927 -0
- package/novac/node_modules/chalk/license +9 -0
- package/novac/node_modules/chalk/package.json +83 -0
- package/novac/node_modules/chalk/readme.md +297 -0
- package/novac/node_modules/chalk/source/index.d.ts +325 -0
- package/novac/node_modules/chalk/source/index.js +225 -0
- package/novac/node_modules/chalk/source/utilities.js +33 -0
- package/novac/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
- package/novac/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
- package/novac/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
- package/novac/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
- package/novac/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
- package/novac/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
- package/novac/node_modules/commander/LICENSE +22 -0
- package/novac/node_modules/commander/Readme.md +1176 -0
- package/novac/node_modules/commander/esm.mjs +16 -0
- package/novac/node_modules/commander/index.js +24 -0
- package/novac/node_modules/commander/lib/argument.js +150 -0
- package/novac/node_modules/commander/lib/command.js +2777 -0
- package/novac/node_modules/commander/lib/error.js +39 -0
- package/novac/node_modules/commander/lib/help.js +747 -0
- package/novac/node_modules/commander/lib/option.js +380 -0
- package/novac/node_modules/commander/lib/suggestSimilar.js +101 -0
- package/novac/node_modules/commander/package-support.json +19 -0
- package/novac/node_modules/commander/package.json +82 -0
- package/novac/node_modules/commander/typings/esm.d.mts +3 -0
- package/novac/node_modules/commander/typings/index.d.ts +1113 -0
- package/novac/node_modules/node-addon-api/LICENSE.md +9 -0
- package/novac/node_modules/node-addon-api/README.md +95 -0
- package/novac/node_modules/node-addon-api/common.gypi +21 -0
- package/novac/node_modules/node-addon-api/except.gypi +25 -0
- package/novac/node_modules/node-addon-api/index.js +14 -0
- package/novac/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
- package/novac/node_modules/node-addon-api/napi-inl.h +7165 -0
- package/novac/node_modules/node-addon-api/napi.h +3364 -0
- package/novac/node_modules/node-addon-api/node_addon_api.gyp +42 -0
- package/novac/node_modules/node-addon-api/node_api.gyp +9 -0
- package/novac/node_modules/node-addon-api/noexcept.gypi +26 -0
- package/novac/node_modules/node-addon-api/package-support.json +21 -0
- package/novac/node_modules/node-addon-api/package.json +480 -0
- package/novac/node_modules/node-addon-api/tools/README.md +73 -0
- package/novac/node_modules/node-addon-api/tools/check-napi.js +99 -0
- package/novac/node_modules/node-addon-api/tools/clang-format.js +71 -0
- package/novac/node_modules/node-addon-api/tools/conversion.js +301 -0
- package/novac/node_modules/serialize-javascript/LICENSE +27 -0
- package/novac/node_modules/serialize-javascript/README.md +149 -0
- package/novac/node_modules/serialize-javascript/index.js +297 -0
- package/novac/node_modules/serialize-javascript/package.json +33 -0
- package/novac/package.json +27 -0
- package/novac/scripts/update-bin.js +24 -0
- package/novac/src/core/bstd.js +1035 -0
- package/novac/src/core/config.js +155 -0
- package/novac/src/core/describe.js +187 -0
- package/novac/src/core/emitter.js +499 -0
- package/novac/src/core/error.js +86 -0
- package/novac/src/core/executor.js +5606 -0
- package/novac/src/core/formatter.js +686 -0
- package/novac/src/core/lexer.js +1026 -0
- package/novac/src/core/nova_builtins.js +717 -0
- package/novac/src/core/nova_thread_worker.js +166 -0
- package/novac/src/core/parser.js +2181 -0
- package/novac/src/core/types.js +112 -0
- package/novac/src/index.js +28 -0
- package/novac/src/runtime/stdlib.js +244 -0
- package/package.json +6 -3
- package/scripts/update-bin.js +0 -0
- package/src/core/bstd.js +838 -362
- package/src/core/executor.js +2578 -170
- package/src/core/lexer.js +502 -54
- package/src/core/nova_builtins.js +21 -3
- package/src/core/parser.js +413 -72
- package/src/core/types.js +30 -2
- package/src/index.js +0 -0
- package/examples/example-project/README.md +0 -3
- package/examples/example-project/src/main.nova +0 -3
- package/src/core/environment.js +0 -0
- /package/{examples/example-project/bin/example-project.nv → novac/node_modules/node-addon-api/nothing.c} +0 -0
|
@@ -0,0 +1,1927 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ╔═══════════════════════════════════════════════════════════════════════════╗
|
|
5
|
+
* ║ kitdef.js v1.0 ║
|
|
6
|
+
* ║ ║
|
|
7
|
+
* ║ A complete terminal application kit in a single CJS module. ║
|
|
8
|
+
* ║ ║
|
|
9
|
+
* ║ Subsystems ║
|
|
10
|
+
* ║ ─────────────────────────────────────────────────────────────────────── ║
|
|
11
|
+
* ║ tview TVPixel, TView, InputManager — pixel-grid rendering engine ║
|
|
12
|
+
* ║ tshell TShell — spawn / pipe / interact with child processes ║
|
|
13
|
+
* ║ tenv TEnv — typed env-var config with validation & defaults ║
|
|
14
|
+
* ║ tlog TLog — levelled logger that streams into a TView ║
|
|
15
|
+
* ║ ttheme TTheme — named colour/style token system with dark/light ║
|
|
16
|
+
* ║ twidget TWidget — higher-level widgets (menu, list, dialog, input…) ║
|
|
17
|
+
* ║ trouter TRouter — keyboard-driven "screen router" (push/pop/replace) ║
|
|
18
|
+
* ║ ║
|
|
19
|
+
* ║ Top-level export ║
|
|
20
|
+
* ║ ─────────────────────────────────────────────────────────────────────── ║
|
|
21
|
+
* ║ Terminal — one object that boots everything and wires it together ║
|
|
22
|
+
* ║ ║
|
|
23
|
+
* ║ Usage ║
|
|
24
|
+
* ║ const { kitdef } = require('./kitdef'); ║
|
|
25
|
+
* ║ const { Terminal, TView, TShell, TEnv, TLog, ║
|
|
26
|
+
* ║ TTheme, TWidget, TRouter } = kitdef; ║
|
|
27
|
+
* ╚═══════════════════════════════════════════════════════════════════════════╝
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { EventEmitter } = require('events');
|
|
31
|
+
const { spawn, spawnSync } = require('child_process');
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const os = require('os');
|
|
35
|
+
|
|
36
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
37
|
+
// §1 ANSI / colour primitives
|
|
38
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
39
|
+
|
|
40
|
+
const ESC = '\x1b';
|
|
41
|
+
|
|
42
|
+
const ansi = {
|
|
43
|
+
reset: `${ESC}[0m`,
|
|
44
|
+
clear: `${ESC}[2J`,
|
|
45
|
+
clearLine: `${ESC}[2K`,
|
|
46
|
+
altScreenOn: `${ESC}[?1049h`,
|
|
47
|
+
altScreenOff: `${ESC}[?1049l`,
|
|
48
|
+
hideCursor: `${ESC}[?25l`,
|
|
49
|
+
showCursor: `${ESC}[?25h`,
|
|
50
|
+
cursorHome: `${ESC}[H`,
|
|
51
|
+
saveCursor: `${ESC}[s`,
|
|
52
|
+
restoreCursor: `${ESC}[u`,
|
|
53
|
+
mouseOn: `${ESC}[?1000h${ESC}[?1002h${ESC}[?1006h`,
|
|
54
|
+
mouseOff: `${ESC}[?1006l${ESC}[?1002l${ESC}[?1000l`,
|
|
55
|
+
moveTo: (r, c) => `${ESC}[${r};${c}H`,
|
|
56
|
+
fg256: n => `${ESC}[38;5;${n}m`,
|
|
57
|
+
bg256: n => `${ESC}[48;5;${n}m`,
|
|
58
|
+
fg: c => _colorSeq(c, false),
|
|
59
|
+
bg: c => _colorSeq(c, true),
|
|
60
|
+
bold: `${ESC}[1m`,
|
|
61
|
+
dim: `${ESC}[2m`,
|
|
62
|
+
italic: `${ESC}[3m`,
|
|
63
|
+
underline: `${ESC}[4m`,
|
|
64
|
+
blink: `${ESC}[5m`,
|
|
65
|
+
reverse: `${ESC}[7m`,
|
|
66
|
+
strikethrough: `${ESC}[9m`,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const NAMED_COLORS = {
|
|
70
|
+
black:0, red:1, green:2, yellow:3, blue:4, magenta:5,
|
|
71
|
+
cyan:6, white:7, gray:8, grey:8,
|
|
72
|
+
brightred:9, brightgreen:10, brightyellow:11, brightblue:12,
|
|
73
|
+
brightmagenta:13, brightcyan:14, brightwhite:15,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function _colorSeq(color, isBg) {
|
|
77
|
+
if (color == null) return '';
|
|
78
|
+
const p = isBg ? '48' : '38';
|
|
79
|
+
if (typeof color === 'string') {
|
|
80
|
+
const l = color.toLowerCase();
|
|
81
|
+
if (l === 'default' || l === 'reset') return isBg ? `${ESC}[49m` : `${ESC}[39m`;
|
|
82
|
+
if (l in NAMED_COLORS) return `${ESC}[${p};5;${NAMED_COLORS[l]}m`;
|
|
83
|
+
if (/^#[0-9a-f]{6}$/i.test(l)) {
|
|
84
|
+
const r = parseInt(l.slice(1,3),16), g = parseInt(l.slice(3,5),16), b = parseInt(l.slice(5,7),16);
|
|
85
|
+
return `${ESC}[${p};2;${r};${g};${b}m`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(color)) {
|
|
89
|
+
if (color.length === 3) return `${ESC}[${p};2;${color[0]};${color[1]};${color[2]}m`;
|
|
90
|
+
if (color.length === 1) return `${ESC}[${p};5;${color[0]}m`;
|
|
91
|
+
}
|
|
92
|
+
if (typeof color === 'number') return `${ESC}[${p};5;${color}m`;
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
97
|
+
// §2 KEY_MAP (escape sequence → named key)
|
|
98
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
99
|
+
|
|
100
|
+
const KEY_MAP = {
|
|
101
|
+
'\x1b[A':{name:'up'}, '\x1b[B':{name:'down'},
|
|
102
|
+
'\x1b[C':{name:'right'}, '\x1b[D':{name:'left'},
|
|
103
|
+
'\x1b[1;2A':{name:'up',shift:true}, '\x1b[1;2B':{name:'down',shift:true},
|
|
104
|
+
'\x1b[1;2C':{name:'right',shift:true},'\x1b[1;2D':{name:'left',shift:true},
|
|
105
|
+
'\x1b[1;5A':{name:'up',ctrl:true}, '\x1b[1;5B':{name:'down',ctrl:true},
|
|
106
|
+
'\x1b[1;5C':{name:'right',ctrl:true}, '\x1b[1;5D':{name:'left',ctrl:true},
|
|
107
|
+
'\x1b[1;3A':{name:'up',alt:true}, '\x1b[1;3B':{name:'down',alt:true},
|
|
108
|
+
'\x1b[1;3C':{name:'right',alt:true}, '\x1b[1;3D':{name:'left',alt:true},
|
|
109
|
+
'\x1bOP':{name:'f1'},'\x1bOQ':{name:'f2'},'\x1bOR':{name:'f3'},'\x1bOS':{name:'f4'},
|
|
110
|
+
'\x1b[15~':{name:'f5'},'\x1b[17~':{name:'f6'},'\x1b[18~':{name:'f7'},
|
|
111
|
+
'\x1b[19~':{name:'f8'},'\x1b[20~':{name:'f9'},'\x1b[21~':{name:'f10'},
|
|
112
|
+
'\x1b[23~':{name:'f11'},'\x1b[24~':{name:'f12'},
|
|
113
|
+
'\x1b[H':{name:'home'},'\x1b[F':{name:'end'},
|
|
114
|
+
'\x1b[5~':{name:'pageup'},'\x1b[6~':{name:'pagedown'},
|
|
115
|
+
'\x1b[2~':{name:'insert'},'\x1b[3~':{name:'delete'},
|
|
116
|
+
'\x1b[Z':{name:'tab',shift:true},
|
|
117
|
+
'\r':{name:'enter'},'\n':{name:'enter'},'\t':{name:'tab'},
|
|
118
|
+
'\x7f':{name:'backspace'},'\x08':{name:'backspace'},'\x1b':{name:'escape'},
|
|
119
|
+
'\x01':{name:'a',ctrl:true},'\x02':{name:'b',ctrl:true},'\x03':{name:'c',ctrl:true},
|
|
120
|
+
'\x04':{name:'d',ctrl:true},'\x05':{name:'e',ctrl:true},'\x06':{name:'f',ctrl:true},
|
|
121
|
+
'\x07':{name:'g',ctrl:true},'\x0b':{name:'k',ctrl:true},'\x0c':{name:'l',ctrl:true},
|
|
122
|
+
'\x0e':{name:'n',ctrl:true},'\x0f':{name:'o',ctrl:true},'\x10':{name:'p',ctrl:true},
|
|
123
|
+
'\x11':{name:'q',ctrl:true},'\x12':{name:'r',ctrl:true},'\x13':{name:'s',ctrl:true},
|
|
124
|
+
'\x14':{name:'t',ctrl:true},'\x15':{name:'u',ctrl:true},'\x16':{name:'v',ctrl:true},
|
|
125
|
+
'\x17':{name:'w',ctrl:true},'\x18':{name:'x',ctrl:true},'\x19':{name:'y',ctrl:true},
|
|
126
|
+
'\x1a':{name:'z',ctrl:true},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
130
|
+
// §3 BORDER_STYLES
|
|
131
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
132
|
+
|
|
133
|
+
const BORDER_STYLES = {
|
|
134
|
+
single: {tl:'┌',tr:'┐',bl:'└',br:'┘',h:'─',v:'│'},
|
|
135
|
+
double: {tl:'╔',tr:'╗',bl:'╚',br:'╝',h:'═',v:'║'},
|
|
136
|
+
rounded: {tl:'╭',tr:'╮',bl:'╰',br:'╯',h:'─',v:'│'},
|
|
137
|
+
heavy: {tl:'┏',tr:'┓',bl:'┗',br:'┛',h:'━',v:'┃'},
|
|
138
|
+
ascii: {tl:'+',tr:'+',bl:'+',br:'+',h:'-',v:'|'},
|
|
139
|
+
none: {tl:' ',tr:' ',bl:' ',br:' ',h:' ',v:' '},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
143
|
+
// §4 tview — TVPixel · TView · InputManager
|
|
144
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
145
|
+
|
|
146
|
+
// ── TVPixel ───────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
class TVPixel {
|
|
149
|
+
/**
|
|
150
|
+
* A single rendered cell.
|
|
151
|
+
* @param {object} o
|
|
152
|
+
* @param {string} [o.char=' '] display character
|
|
153
|
+
* @param {boolean} [o.isStatic=false] writes ignored when true
|
|
154
|
+
* @param {*} [o.color] [fg,bg] pair or single fg
|
|
155
|
+
* @param {string} [o.style] bold|dim|italic|underline|blink|reverse|strike
|
|
156
|
+
*/
|
|
157
|
+
constructor({ char=' ', isStatic=false, color=[null,null], style=null } = {}) {
|
|
158
|
+
this.char = String(char).slice(0,1) || ' ';
|
|
159
|
+
this.isStatic = Boolean(isStatic);
|
|
160
|
+
if (Array.isArray(color) && color.length === 2) { this.fg=color[0]; this.bg=color[1]; }
|
|
161
|
+
else { this.fg=color; this.bg=null; }
|
|
162
|
+
this.style = style;
|
|
163
|
+
}
|
|
164
|
+
render() {
|
|
165
|
+
let o = '';
|
|
166
|
+
if (this.fg != null) o += ansi.fg(this.fg);
|
|
167
|
+
if (this.bg != null) o += ansi.bg(this.bg);
|
|
168
|
+
if (this.style) o += ({bold:ansi.bold,dim:ansi.dim,italic:ansi.italic,
|
|
169
|
+
underline:ansi.underline,blink:ansi.blink,reverse:ansi.reverse,
|
|
170
|
+
strike:ansi.strikethrough,strikethrough:ansi.strikethrough})[this.style]||'';
|
|
171
|
+
return o + this.char + ansi.reset;
|
|
172
|
+
}
|
|
173
|
+
clone() { return new TVPixel({char:this.char,isStatic:this.isStatic,color:[this.fg,this.bg],style:this.style}); }
|
|
174
|
+
set(char) { if (!this.isStatic) this.char = String(char).slice(0,1)||' '; return this; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── InputManager ─────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
class InputManager extends EventEmitter {
|
|
180
|
+
constructor() {
|
|
181
|
+
super();
|
|
182
|
+
this._views = [];
|
|
183
|
+
this._focused = null;
|
|
184
|
+
this._mouseOn = false;
|
|
185
|
+
this._running = false;
|
|
186
|
+
this._buf = '';
|
|
187
|
+
this._flushTimer = null;
|
|
188
|
+
this._lastClick = {btn:null,row:0,col:0,t:0};
|
|
189
|
+
this._drag = {active:false,btn:null,startRow:0,startCol:0,view:null};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
start(opts = {}) {
|
|
193
|
+
if (this._running) return this;
|
|
194
|
+
this._running = true;
|
|
195
|
+
const stdin = process.stdin;
|
|
196
|
+
stdin.setEncoding('utf8');
|
|
197
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
198
|
+
stdin.resume();
|
|
199
|
+
stdin.on('data', this._onData.bind(this));
|
|
200
|
+
process.stdout.on('resize', this._onTermResize.bind(this));
|
|
201
|
+
if (opts.mouse !== false) this.enableMouse();
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
stop() {
|
|
206
|
+
if (!this._running) return this;
|
|
207
|
+
this._running = false;
|
|
208
|
+
process.stdin.removeAllListeners('data');
|
|
209
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
210
|
+
process.stdin.pause();
|
|
211
|
+
process.stdout.removeAllListeners('resize');
|
|
212
|
+
if (this._mouseOn) this.disableMouse();
|
|
213
|
+
return this;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
enableMouse() { process.stdout.write(ansi.mouseOn); this._mouseOn=true; return this; }
|
|
217
|
+
disableMouse() { process.stdout.write(ansi.mouseOff); this._mouseOn=false; return this; }
|
|
218
|
+
|
|
219
|
+
register(view) {
|
|
220
|
+
if (!this._views.includes(view)) {
|
|
221
|
+
this._views.push(view);
|
|
222
|
+
if (!this._focused) this._focused = view;
|
|
223
|
+
}
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
unregister(view) {
|
|
228
|
+
this._views = this._views.filter(v => v !== view);
|
|
229
|
+
if (this._focused === view) this._focused = this._views[this._views.length-1] ?? null;
|
|
230
|
+
return this;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
focus(view) {
|
|
234
|
+
if (this._focused && this._focused !== view) {
|
|
235
|
+
this._focused._hasFocus = false;
|
|
236
|
+
this._focused.emit('blur');
|
|
237
|
+
this._focused.markDirty?.();
|
|
238
|
+
}
|
|
239
|
+
this._focused = view;
|
|
240
|
+
view._hasFocus = true;
|
|
241
|
+
view.emit('focus');
|
|
242
|
+
view.markDirty?.();
|
|
243
|
+
return this;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_onData(raw) {
|
|
247
|
+
this._buf += raw;
|
|
248
|
+
clearTimeout(this._flushTimer);
|
|
249
|
+
if (!this._buf.startsWith(ESC) || this._buf.length > 1) { this._flush(); }
|
|
250
|
+
else { this._flushTimer = setTimeout(() => this._flush(), 20); }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_flush() {
|
|
254
|
+
while (this._buf.length > 0) {
|
|
255
|
+
const n = this._parseOne(this._buf);
|
|
256
|
+
if (n === 0) break;
|
|
257
|
+
this._buf = this._buf.slice(n);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_parseOne(buf) {
|
|
262
|
+
// SGR mouse
|
|
263
|
+
const sgr = buf.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
|
264
|
+
if (sgr) { this._dispatchMouse({btn:+sgr[1],col:+sgr[2]-1,row:+sgr[3]-1,release:sgr[4]==='m'}); return sgr[0].length; }
|
|
265
|
+
// X10 mouse
|
|
266
|
+
if (buf.startsWith(`${ESC}[M`) && buf.length >= 6) {
|
|
267
|
+
this._dispatchMouse({btn:buf.charCodeAt(3)-32,col:buf.charCodeAt(4)-33,row:buf.charCodeAt(5)-33,release:(buf.charCodeAt(3)-32&3)===3});
|
|
268
|
+
return 6;
|
|
269
|
+
}
|
|
270
|
+
// Escape sequences / keys
|
|
271
|
+
if (buf.startsWith(ESC)) {
|
|
272
|
+
for (let len = Math.min(buf.length,8); len >= 1; len--) {
|
|
273
|
+
const seq = buf.slice(0,len);
|
|
274
|
+
if (KEY_MAP[seq]) { this._dispatchKey({sequence:seq,...KEY_MAP[seq]}); return len; }
|
|
275
|
+
}
|
|
276
|
+
if (buf.length === 1) { this._dispatchKey({sequence:ESC,name:'escape'}); return 1; }
|
|
277
|
+
if (buf.length >= 2) return 2;
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
const ch = buf[0], def = KEY_MAP[ch];
|
|
281
|
+
this._dispatchKey(def ? {sequence:ch,...def} : {sequence:ch,name:ch,char:ch});
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_dispatchKey(key) {
|
|
286
|
+
if (key.name === 'c' && key.ctrl) {
|
|
287
|
+
this.emit('sigint', key);
|
|
288
|
+
if (this.listenerCount('sigint') <= 1) {
|
|
289
|
+
if (this._mouseOn) this.disableMouse();
|
|
290
|
+
process.stdout.write(ansi.showCursor + '\n');
|
|
291
|
+
process.exit(0);
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
this.emit('key', key);
|
|
296
|
+
const f = this._focused;
|
|
297
|
+
if (!f) return;
|
|
298
|
+
f.emit('key', key);
|
|
299
|
+
f.emit(`key:${key.name}`, key);
|
|
300
|
+
if (key.ctrl) f.emit(`key:ctrl+${key.name}`, key);
|
|
301
|
+
if (key.shift) f.emit(`key:shift+${key.name}`, key);
|
|
302
|
+
if (key.alt) f.emit(`key:alt+${key.name}`, key);
|
|
303
|
+
if (['up','down','left','right'].includes(key.name)) {
|
|
304
|
+
f.emit('arrow', key); f.emit(`arrow:${key.name}`, key);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
_dispatchMouse(ev) {
|
|
309
|
+
const {btn,col,row,release} = ev;
|
|
310
|
+
const button=btn&3, shift=!!(btn&4), alt=!!(btn&8), ctrl=!!(btn&16);
|
|
311
|
+
const motion=!!(btn&32), scroll=btn>=64;
|
|
312
|
+
const base = {col,row,button,shift,alt,ctrl};
|
|
313
|
+
this.emit('mouse',{...base,type:'raw',btn});
|
|
314
|
+
|
|
315
|
+
if (scroll) {
|
|
316
|
+
const dir = (btn&1)?'down':'up', target = this._hitTest(row,col);
|
|
317
|
+
const sEv = {...base,type:'scroll',direction:dir};
|
|
318
|
+
this.emit('scroll',sEv);
|
|
319
|
+
if (target) { const l=this._toLocal(target,row,col); target.emit('scroll',{...sEv,...l}); target.emit(`scroll:${dir}`,{...sEv,...l}); }
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (motion && !release) {
|
|
323
|
+
const d = this._drag;
|
|
324
|
+
if (d.active) {
|
|
325
|
+
const dEv={...base,type:'drag',startRow:d.startRow,startCol:d.startCol,deltaRow:row-d.startRow,deltaCol:col-d.startCol};
|
|
326
|
+
this.emit('drag',dEv);
|
|
327
|
+
if (d.view) { const l=this._toLocal(d.view,row,col); d.view.emit('drag',{...dEv,...l}); }
|
|
328
|
+
} else {
|
|
329
|
+
const target=this._hitTest(row,col), mEv={...base,type:'mousemove'};
|
|
330
|
+
this.emit('mousemove',mEv);
|
|
331
|
+
if (target) { const l=this._toLocal(target,row,col); target.emit('mousemove',{...mEv,...l}); }
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (release) {
|
|
336
|
+
const d=this._drag;
|
|
337
|
+
if (d.active) {
|
|
338
|
+
const dEv={...base,type:'dragend',startRow:d.startRow,startCol:d.startCol,deltaRow:row-d.startRow,deltaCol:col-d.startCol};
|
|
339
|
+
this.emit('dragend',dEv);
|
|
340
|
+
if (d.view) { const l=this._toLocal(d.view,row,col); d.view.emit('dragend',{...dEv,...l}); }
|
|
341
|
+
this._drag.active=false; this._drag.view=null;
|
|
342
|
+
}
|
|
343
|
+
const target=this._hitTest(row,col), rEv={...base,type:'mouseup'};
|
|
344
|
+
this.emit('mouseup',rEv);
|
|
345
|
+
if (target) { const l=this._toLocal(target,row,col); target.emit('mouseup',{...rEv,...l}); }
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const target=this._hitTest(row,col);
|
|
349
|
+
if (target && target !== this._focused) this.focus(target);
|
|
350
|
+
const now=Date.now(), lc=this._lastClick;
|
|
351
|
+
const isDbl=(lc.btn===button&&lc.row===row&&lc.col===col&&now-lc.t<400);
|
|
352
|
+
this._lastClick={btn:button,row,col,t:now};
|
|
353
|
+
this._drag={active:true,btn:button,startRow:row,startCol:col,view:target};
|
|
354
|
+
const local=target?this._toLocal(target,row,col):{localRow:row,localCol:col};
|
|
355
|
+
const type=isDbl?'dblclick':'click';
|
|
356
|
+
const cEv={...base,type,view:target,...local};
|
|
357
|
+
this.emit('click',cEv); this.emit(type,cEv);
|
|
358
|
+
if (target) {
|
|
359
|
+
target.emit('click',cEv);
|
|
360
|
+
['click:left','click:middle','click:right'][button] && target.emit(['click:left','click:middle','click:right'][button],cEv);
|
|
361
|
+
if (isDbl) target.emit('dblclick',cEv);
|
|
362
|
+
if (button===2) target.emit('contextmenu',cEv);
|
|
363
|
+
const pixelObj=target.getPixel?.(local.localRow,local.localCol);
|
|
364
|
+
if (pixelObj) target.emit('click:pixel',{...cEv,pixel:pixelObj});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
_onTermResize() {
|
|
369
|
+
const cols=process.stdout.columns||80, rows=process.stdout.rows||24;
|
|
370
|
+
const ev={cols,rows,type:'resize'};
|
|
371
|
+
this.emit('resize',ev);
|
|
372
|
+
for (const v of this._views) v.emit('termresize',ev);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
_hitTest(termRow,termCol) {
|
|
376
|
+
for (let i=this._views.length-1;i>=0;i--) {
|
|
377
|
+
const v=this._views[i]; if (v._hidden) continue;
|
|
378
|
+
const r0=v.offsetRow-1,r1=r0+v.rows-1,c0=v.offsetCol-1,c1=c0+v.cols-1;
|
|
379
|
+
if (termRow>=r0&&termRow<=r1&&termCol>=c0&&termCol<=c1) return v;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
_toLocal(view,row,col) { return {localRow:row-(view.offsetRow-1),localCol:col-(view.offsetCol-1)}; }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const inputManager = new InputManager();
|
|
388
|
+
|
|
389
|
+
// ── TView ─────────────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
class TView extends EventEmitter {
|
|
392
|
+
/**
|
|
393
|
+
* @param {object} opts
|
|
394
|
+
* @param {number} [opts.rows]
|
|
395
|
+
* @param {number} [opts.cols]
|
|
396
|
+
* @param {number} [opts.offsetRow=1]
|
|
397
|
+
* @param {number} [opts.offsetCol=1]
|
|
398
|
+
* @param {number} [opts.fps=30]
|
|
399
|
+
* @param {string} [opts.title]
|
|
400
|
+
* @param {*} [opts.defaultFg]
|
|
401
|
+
* @param {*} [opts.defaultBg]
|
|
402
|
+
* @param {boolean} [opts.border=false]
|
|
403
|
+
* @param {string} [opts.borderStyle='single']
|
|
404
|
+
* @param {boolean} [opts.scrollable=false]
|
|
405
|
+
* @param {boolean} [opts.autoResize=false]
|
|
406
|
+
* @param {boolean} [opts.autoRegister=true]
|
|
407
|
+
*/
|
|
408
|
+
constructor(opts = {}) {
|
|
409
|
+
super();
|
|
410
|
+
const ts = process.stdout;
|
|
411
|
+
this.rows = opts.rows ?? (ts.rows || 24);
|
|
412
|
+
this.cols = opts.cols ?? (ts.columns || 80);
|
|
413
|
+
this.offsetRow = opts.offsetRow ?? 1;
|
|
414
|
+
this.offsetCol = opts.offsetCol ?? 1;
|
|
415
|
+
this.fps = opts.fps ?? 30;
|
|
416
|
+
this.title = opts.title ?? '';
|
|
417
|
+
this.defaultBg = opts.defaultBg ?? null;
|
|
418
|
+
this.defaultFg = opts.defaultFg ?? null;
|
|
419
|
+
this.border = opts.border ?? false;
|
|
420
|
+
this.borderStyle = opts.borderStyle ?? 'single';
|
|
421
|
+
this.scrollable = opts.scrollable ?? false;
|
|
422
|
+
this.autoResize = opts.autoResize ?? false;
|
|
423
|
+
this._scrollOffset = 0;
|
|
424
|
+
this._altScreen = false;
|
|
425
|
+
this._dirty = true;
|
|
426
|
+
this._timer = null;
|
|
427
|
+
this._hidden = false;
|
|
428
|
+
this._hasFocus = false;
|
|
429
|
+
this._promptActive = false;
|
|
430
|
+
this._overlay = null;
|
|
431
|
+
this._animations = [];
|
|
432
|
+
this._dataBindings = [];
|
|
433
|
+
this.grid = this._makeGrid(this.rows, this.cols);
|
|
434
|
+
if (this.autoResize) this.on('termresize',({rows,cols}) => this.resize(rows,cols));
|
|
435
|
+
if (this.fps > 0) this._startRenderLoop();
|
|
436
|
+
if (opts.autoRegister !== false) inputManager.register(this);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Handler shortcuts ──────────────────────────────────────────────────────
|
|
440
|
+
onKey(keys, fn) {
|
|
441
|
+
const pairs = [].concat(keys).map(k => { const ev=`key:${k}`; this.on(ev,fn); return [ev,fn]; });
|
|
442
|
+
return () => pairs.forEach(([ev,h]) => this.removeListener(ev,h));
|
|
443
|
+
}
|
|
444
|
+
onArrow(dir, fn) {
|
|
445
|
+
const ev = dir==='*'?'arrow':`arrow:${dir}`; this.on(ev,fn); return ()=>this.removeListener(ev,fn);
|
|
446
|
+
}
|
|
447
|
+
onClick(fn) { this.on('click',fn); return ()=>this.removeListener('click',fn); }
|
|
448
|
+
onDblClick(fn) { this.on('dblclick',fn); return ()=>this.removeListener('dblclick',fn); }
|
|
449
|
+
onRightClick(fn) { this.on('contextmenu',fn); return ()=>this.removeListener('contextmenu',fn); }
|
|
450
|
+
onMouseUp(fn) { this.on('mouseup',fn); return ()=>this.removeListener('mouseup',fn); }
|
|
451
|
+
onMouseMove(fn) { this.on('mousemove',fn); return ()=>this.removeListener('mousemove',fn); }
|
|
452
|
+
onDrag(fn) { this.on('drag',fn); return ()=>this.removeListener('drag',fn); }
|
|
453
|
+
onDragEnd(fn) { this.on('dragend',fn); return ()=>this.removeListener('dragend',fn); }
|
|
454
|
+
onScroll(dir, fn) {
|
|
455
|
+
const ev=dir==='*'?'scroll':`scroll:${dir}`; this.on(ev,fn); return ()=>this.removeListener(ev,fn);
|
|
456
|
+
}
|
|
457
|
+
onResize(fn) { this.on('termresize',fn); return ()=>this.removeListener('termresize',fn); }
|
|
458
|
+
onFocus(fn) { this.on('focus',fn); return ()=>this.removeListener('focus',fn); }
|
|
459
|
+
onBlur(fn) { this.on('blur',fn); return ()=>this.removeListener('blur',fn); }
|
|
460
|
+
|
|
461
|
+
focus() { inputManager.focus(this); return this; }
|
|
462
|
+
get focused() { return this._hasFocus; }
|
|
463
|
+
|
|
464
|
+
// ── Data binding ───────────────────────────────────────────────────────────
|
|
465
|
+
bindData(source, event, handler) {
|
|
466
|
+
const wrapped = data => { handler(data,this); this.markDirty(); };
|
|
467
|
+
source.on(event, wrapped);
|
|
468
|
+
this._dataBindings.push({source,event,handler:wrapped});
|
|
469
|
+
return () => { source.removeListener(event,wrapped); this._dataBindings=this._dataBindings.filter(b=>b.handler!==wrapped); };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
pipeStream(stream, pixelOpts = {}) {
|
|
473
|
+
let buf = '';
|
|
474
|
+
const stop = this.bindData(stream,'data',chunk => {
|
|
475
|
+
buf += String(chunk);
|
|
476
|
+
const lines = buf.split('\n'); buf = lines.pop();
|
|
477
|
+
for (const line of lines) {
|
|
478
|
+
const row = this.grid.length;
|
|
479
|
+
this.grid.push(Array.from({length:this.cols},()=>new TVPixel({char:' ',color:[this.defaultFg,this.defaultBg]})));
|
|
480
|
+
const display = line.replace(/\x1b\[[^m]*m/g,'').slice(0,this.cols);
|
|
481
|
+
for (let c=0;c<display.length;c++) this.grid[row][c]=new TVPixel({char:display[c],...pixelOpts});
|
|
482
|
+
if (this.scrollable) this.scrollToBottom();
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
stream.once('end',stop); stream.once('close',stop);
|
|
486
|
+
return stop;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── Grid ───────────────────────────────────────────────────────────────────
|
|
490
|
+
_makeGrid(rows,cols) {
|
|
491
|
+
return Array.from({length:rows},()=>Array.from({length:cols},()=>new TVPixel({char:' ',color:[this.defaultFg,this.defaultBg]})));
|
|
492
|
+
}
|
|
493
|
+
_startRenderLoop() {
|
|
494
|
+
this._timer = setInterval(()=>{ if(this._dirty&&!this._hidden) this.render(); }, Math.round(1000/this.fps));
|
|
495
|
+
this._timer.unref?.();
|
|
496
|
+
}
|
|
497
|
+
_stopRenderLoop() { if(this._timer){clearInterval(this._timer);this._timer=null;} }
|
|
498
|
+
|
|
499
|
+
// ── Rendering ──────────────────────────────────────────────────────────────
|
|
500
|
+
render() {
|
|
501
|
+
if (this._hidden) return this;
|
|
502
|
+
const out=[ansi.saveCursor,ansi.hideCursor];
|
|
503
|
+
const iRS=this.border?1:0, iCS=this.border?1:0;
|
|
504
|
+
const iR=this.border?this.rows-2:this.rows, iC=this.border?this.cols-2:this.cols;
|
|
505
|
+
if (this.border) {
|
|
506
|
+
const bs=BORDER_STYLES[this.borderStyle]||BORDER_STYLES.single;
|
|
507
|
+
const fc=this._hasFocus?ansi.fg('brightcyan'):ansi.fg('gray');
|
|
508
|
+
const pad=this.cols-2-this.title.length;
|
|
509
|
+
const top=bs.tl+(this.title?bs.h.repeat(Math.max(0,Math.floor(pad/2)))+this.title+bs.h.repeat(Math.max(0,Math.ceil(pad/2))):bs.h.repeat(this.cols-2))+bs.tr;
|
|
510
|
+
out.push(ansi.moveTo(this.offsetRow,this.offsetCol)+fc+top+ansi.reset);
|
|
511
|
+
for (let r=1;r<this.rows-1;r++) {
|
|
512
|
+
out.push(ansi.moveTo(this.offsetRow+r,this.offsetCol)+fc+bs.v+ansi.reset);
|
|
513
|
+
out.push(ansi.moveTo(this.offsetRow+r,this.offsetCol+this.cols-1)+fc+bs.v+ansi.reset);
|
|
514
|
+
}
|
|
515
|
+
out.push(ansi.moveTo(this.offsetRow+this.rows-1,this.offsetCol)+fc+bs.bl+bs.h.repeat(this.cols-2)+bs.br+ansi.reset);
|
|
516
|
+
}
|
|
517
|
+
for (let r=0;r<iR;r++) {
|
|
518
|
+
const gr=r+this._scrollOffset; if(gr>=this.grid.length) break;
|
|
519
|
+
out.push(ansi.moveTo(this.offsetRow+iRS+r,this.offsetCol+iCS));
|
|
520
|
+
for (let c=0;c<iC;c++) { const p=this.grid[gr]?.[c]; out.push(p?p.render():ansi.reset+' '); }
|
|
521
|
+
}
|
|
522
|
+
if (this._overlay) {
|
|
523
|
+
const ov=this._overlay;
|
|
524
|
+
for (let r=0;r<ov.rows;r++) {
|
|
525
|
+
out.push(ansi.moveTo(this.offsetRow+ov.row+r,this.offsetCol+ov.col));
|
|
526
|
+
for (let c=0;c<ov.cols;c++) { const p=ov.pixels[r]?.[c]; out.push(p?p.render():ansi.reset+' '); }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
out.push(ansi.reset,ansi.restoreCursor,ansi.showCursor);
|
|
530
|
+
process.stdout.write(out.join(''));
|
|
531
|
+
this._dirty=false; this.emit('render');
|
|
532
|
+
return this;
|
|
533
|
+
}
|
|
534
|
+
markDirty() { this._dirty=true; return this; }
|
|
535
|
+
|
|
536
|
+
// ── Pixel/text ─────────────────────────────────────────────────────────────
|
|
537
|
+
setPixel(row,col,pixel) {
|
|
538
|
+
if(row<0||row>=this.grid.length)return this;
|
|
539
|
+
if(col<0||col>=(this.grid[row]?.length||0))return this;
|
|
540
|
+
if(this.grid[row][col].isStatic)return this;
|
|
541
|
+
this.grid[row][col]=pixel instanceof TVPixel?pixel:new TVPixel(pixel);
|
|
542
|
+
this._dirty=true; return this;
|
|
543
|
+
}
|
|
544
|
+
getPixel(row,col) { return this.grid[row]?.[col]??null; }
|
|
545
|
+
writeText(row,col,text,opts={}) {
|
|
546
|
+
return new Promise(res => {
|
|
547
|
+
for(let i=0;i<text.length;i++) this.setPixel(row,col+i,new TVPixel({char:text[i],...opts}));
|
|
548
|
+
return this;
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
writeCentered(row,text,opts={}) { return this.writeText(row,Math.max(0,Math.floor((this.cols-text.length)/2)),text,opts); }
|
|
552
|
+
writeRight(row,text,opts={}) { return this.writeText(row,Math.max(0,this.cols-text.length),text,opts); }
|
|
553
|
+
fill(row,col,rows,cols,opts={}) {
|
|
554
|
+
for(let r=row;r<row+rows;r++) for(let c=col;c<col+cols;c++) this.setPixel(r,c,new TVPixel(opts));
|
|
555
|
+
return this;
|
|
556
|
+
}
|
|
557
|
+
clear() { this.grid=this._makeGrid(this.rows,this.cols); this._dirty=true; return this; }
|
|
558
|
+
clearRow(r) {
|
|
559
|
+
if(r>=0&&r<this.grid.length){ this.grid[r]=Array.from({length:this.cols},()=>new TVPixel({char:' ',color:[this.defaultFg,this.defaultBg]})); this._dirty=true; }
|
|
560
|
+
return this;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ── Drawing ────────────────────────────────────────────────────────────────
|
|
564
|
+
drawBox(row,col,rows,cols,opts={},style='single') {
|
|
565
|
+
const bs=BORDER_STYLES[style]||BORDER_STYLES.single;
|
|
566
|
+
this.setPixel(row,col,new TVPixel({char:bs.tl,...opts}));
|
|
567
|
+
this.setPixel(row,col+cols-1,new TVPixel({char:bs.tr,...opts}));
|
|
568
|
+
this.setPixel(row+rows-1,col,new TVPixel({char:bs.bl,...opts}));
|
|
569
|
+
this.setPixel(row+rows-1,col+cols-1,new TVPixel({char:bs.br,...opts}));
|
|
570
|
+
for(let c=col+1;c<col+cols-1;c++){this.setPixel(row,c,new TVPixel({char:bs.h,...opts}));this.setPixel(row+rows-1,c,new TVPixel({char:bs.h,...opts}));}
|
|
571
|
+
for(let r=row+1;r<row+rows-1;r++){this.setPixel(r,col,new TVPixel({char:bs.v,...opts}));this.setPixel(r,col+cols-1,new TVPixel({char:bs.v,...opts}));}
|
|
572
|
+
return this;
|
|
573
|
+
}
|
|
574
|
+
drawHLine(row,col,len,opts={},style='single'){const h=(BORDER_STYLES[style]||BORDER_STYLES.single).h;for(let c=col;c<col+len;c++)this.setPixel(row,c,new TVPixel({char:h,...opts}));return this;}
|
|
575
|
+
drawVLine(row,col,len,opts={},style='single'){const v=(BORDER_STYLES[style]||BORDER_STYLES.single).v;for(let r=row;r<row+len;r++)this.setPixel(r,col,new TVPixel({char:v,...opts}));return this;}
|
|
576
|
+
|
|
577
|
+
// ── Overlay ────────────────────────────────────────────────────────────────
|
|
578
|
+
setOverlay(row,col,pixels){this._overlay={row,col,rows:pixels.length,cols:pixels[0]?.length??0,pixels};this._dirty=true;return this;}
|
|
579
|
+
clearOverlay(){this._overlay=null;this._dirty=true;return this;}
|
|
580
|
+
|
|
581
|
+
// ── Scroll ─────────────────────────────────────────────────────────────────
|
|
582
|
+
scrollDown(n=1){this._scrollOffset=Math.min(this._scrollOffset+n,Math.max(0,this.grid.length-this.rows));this._dirty=true;return this;}
|
|
583
|
+
scrollUp(n=1) {this._scrollOffset=Math.max(0,this._scrollOffset-n);this._dirty=true;return this;}
|
|
584
|
+
scrollTo(r) {this._scrollOffset=Math.max(0,Math.min(r,this.grid.length-this.rows));this._dirty=true;return this;}
|
|
585
|
+
scrollToBottom(){return this.scrollTo(Math.max(0,this.grid.length-this.rows));}
|
|
586
|
+
pushRow(pixels){this.grid.push(pixels);if(this.scrollable)this.scrollToBottom();this._dirty=true;return this;}
|
|
587
|
+
|
|
588
|
+
// ── Alt screen ─────────────────────────────────────────────────────────────
|
|
589
|
+
renderInAltScreen(){if(!this._altScreen){process.stdout.write(ansi.altScreenOn+ansi.clear+ansi.cursorHome);this._altScreen=true;}return this.render();}
|
|
590
|
+
leaveAltScreen(){if(this._altScreen){process.stdout.write(ansi.altScreenOff);this._altScreen=false;}return this;}
|
|
591
|
+
|
|
592
|
+
hide() {this._hidden=true;return this;}
|
|
593
|
+
show() {this._hidden=false;this._dirty=true;return this;}
|
|
594
|
+
toggle() {return this._hidden?this.show():this.hide();}
|
|
595
|
+
|
|
596
|
+
resize(rows,cols) {
|
|
597
|
+
const g=this._makeGrid(rows,cols);
|
|
598
|
+
for(let r=0;r<Math.min(rows,this.grid.length);r++) for(let c=0;c<Math.min(cols,(this.grid[r]||[]).length);c++) g[r][c]=this.grid[r][c];
|
|
599
|
+
this.rows=rows;this.cols=cols;this.grid=g;this._dirty=true;this.emit('resize',{rows,cols});return this;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ── Prompt ─────────────────────────────────────────────────────────────────
|
|
603
|
+
prompt(promptStr = '> ') {
|
|
604
|
+
return new Promise(resolve => {
|
|
605
|
+
this._promptActive=true;
|
|
606
|
+
const pRow=this.rows-1; let inputStr='', inputCol=promptStr.length;
|
|
607
|
+
this.clearRow(pRow);
|
|
608
|
+
this.writeText(pRow,0,promptStr,{color:['cyan',null]});
|
|
609
|
+
this.render();
|
|
610
|
+
process.stdout.write(ansi.moveTo(this.offsetRow+pRow,this.offsetCol+inputCol)+ansi.showCursor);
|
|
611
|
+
this.focus();
|
|
612
|
+
const onKey = key => {
|
|
613
|
+
if (key.name==='enter') {
|
|
614
|
+
this.removeListener('key',onKey); this._promptActive=false; this.clearRow(pRow); this.markDirty(); resolve(inputStr);
|
|
615
|
+
} else if (key.name==='backspace') {
|
|
616
|
+
if(inputStr.length>0){inputStr=inputStr.slice(0,-1);inputCol--;this.setPixel(pRow,inputCol,new TVPixel({char:' '}));this.render();process.stdout.write(ansi.moveTo(this.offsetRow+pRow,this.offsetCol+inputCol));}
|
|
617
|
+
} else if (key.name==='escape') {
|
|
618
|
+
this.removeListener('key',onKey);this._promptActive=false;this.clearRow(pRow);this.markDirty();resolve(null);
|
|
619
|
+
} else if (key.char&&key.char>=' '&&!key.ctrl&&inputCol<this.cols) {
|
|
620
|
+
this.setPixel(pRow,inputCol,new TVPixel({char:key.char,color:['white',null]}));inputStr+=key.char;inputCol++;this.render();process.stdout.write(ansi.moveTo(this.offsetRow+pRow,this.offsetCol+inputCol));
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
this.on('key',onKey);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── Cut/split ──────────────────────────────────────────────────────────────
|
|
628
|
+
cut(n) {
|
|
629
|
+
if(n<1)throw new RangeError('cut: n>=1'); this._stopRenderLoop();
|
|
630
|
+
const sh=Math.floor(this.rows/n);
|
|
631
|
+
return Array.from({length:n},(_,i)=>{
|
|
632
|
+
const rows=(i===n-1)?this.rows-sh*(n-1):sh;
|
|
633
|
+
return new TView({rows,cols:this.cols,offsetRow:this.offsetRow+i*sh,offsetCol:this.offsetCol,fps:this.fps,defaultBg:this.defaultBg,defaultFg:this.defaultFg});
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
cutHorizontal(n) {
|
|
637
|
+
if(n<1)throw new RangeError('cutHorizontal: n>=1'); this._stopRenderLoop();
|
|
638
|
+
const sw=Math.floor(this.cols/n);
|
|
639
|
+
return Array.from({length:n},(_,i)=>{
|
|
640
|
+
const cols=(i===n-1)?this.cols-sw*(n-1):sw;
|
|
641
|
+
return new TView({rows:this.rows,cols,offsetRow:this.offsetRow,offsetCol:this.offsetCol+i*sw,fps:this.fps,defaultBg:this.defaultBg,defaultFg:this.defaultFg});
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
cutGrid(gr,gc) { return this.cut(gr).map(rv=>rv.cutHorizontal(gc)); }
|
|
645
|
+
cutWeighted(weights) {
|
|
646
|
+
const total=weights.reduce((a,b)=>a+b,0), isPct=total<=1.01&&total>=0.99;
|
|
647
|
+
let cursor=0; this._stopRenderLoop();
|
|
648
|
+
return weights.map((w,i)=>{
|
|
649
|
+
const rows=(i===weights.length-1)?this.rows-cursor:Math.floor(isPct?w*this.rows:w);
|
|
650
|
+
const v=new TView({rows,cols:this.cols,offsetRow:this.offsetRow+cursor,offsetCol:this.offsetCol,fps:this.fps,defaultBg:this.defaultBg,defaultFg:this.defaultFg});
|
|
651
|
+
cursor+=rows; return v;
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ── Animation ──────────────────────────────────────────────────────────────
|
|
656
|
+
animatePixel(row,col,frames,interval=100,opts={}) {
|
|
657
|
+
let i=0; const t=setInterval(()=>this.setPixel(row,col,new TVPixel({char:frames[i++%frames.length],...opts})),interval);
|
|
658
|
+
const stop=()=>clearInterval(t); this._animations.push(stop); return stop;
|
|
659
|
+
}
|
|
660
|
+
marquee(row,text,interval=80,opts={}) {
|
|
661
|
+
const padded=text+' '; let offset=0;
|
|
662
|
+
const t=setInterval(()=>{this.writeText(row,0,(padded.slice(offset%padded.length)+padded).slice(0,this.cols).padEnd(this.cols),opts);offset++;},interval);
|
|
663
|
+
const stop=()=>clearInterval(t); this._animations.push(stop); return stop;
|
|
664
|
+
}
|
|
665
|
+
spinner(row,col,style='dots',interval=80,opts={}) {
|
|
666
|
+
const S={dots:['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'],line:['-','\\','|','/'],arc:['◜','◠','◝','◞','◡','◟'],bounce:['⠁','⠂','⠄','⠂'],pulse:['·','●','◉','●'],arrows:['←','↖','↑','↗','→','↘','↓','↙'],clock:['🕐','🕑','🕒','🕓','🕔','🕕'],bar:['▏','▎','▍','▌','▋','▊','▉','█','▉','▊','▋','▌','▍','▎']};
|
|
667
|
+
return this.animatePixel(row,col,S[style]||S.dots,interval,opts);
|
|
668
|
+
}
|
|
669
|
+
flash(row,col,rows,cols,color='yellow',times=6,interval=150) {
|
|
670
|
+
let count=0,on=true;
|
|
671
|
+
const orig=this.grid.slice(row,row+rows).map(r=>r.slice(col,col+cols).map(p=>p.clone()));
|
|
672
|
+
const t=setInterval(()=>{
|
|
673
|
+
if(on)this.fill(row,col,rows,cols,{color:[null,color]});
|
|
674
|
+
else{for(let r=0;r<rows;r++)for(let c=0;c<cols;c++)this.grid[row+r][col+c]=orig[r][c].clone();this._dirty=true;}
|
|
675
|
+
on=!on;count++;if(count>=times*2)clearInterval(t);
|
|
676
|
+
},interval);
|
|
677
|
+
return ()=>clearInterval(t);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ── Widgets ────────────────────────────────────────────────────────────────
|
|
681
|
+
progressBar(row,col,width,pct,opts={}) {
|
|
682
|
+
const{filledColor='green',emptyColor='gray',filledChar='█',emptyChar='░',showLabel=false}=opts;
|
|
683
|
+
const filled=Math.round(Math.min(1,Math.max(0,pct))*width);
|
|
684
|
+
for(let c=0;c<width;c++){const f=c<filled;this.setPixel(row,col+c,new TVPixel({char:f?filledChar:emptyChar,color:[f?filledColor:emptyColor,null]}));}
|
|
685
|
+
if(showLabel){const lbl=`${Math.round(pct*100)}%`;this.writeText(row,col+Math.floor((width-lbl.length)/2),lbl,{color:['white',null]});}
|
|
686
|
+
this._dirty=true; return this;
|
|
687
|
+
}
|
|
688
|
+
statusBar(left,right='',opts={color:['black','white']}) {
|
|
689
|
+
const spacer=' '.repeat(Math.max(0,this.cols-left.length-right.length));
|
|
690
|
+
this.writeText(this.rows-1,0,(left+spacer+right).slice(0,this.cols),opts); return this;
|
|
691
|
+
}
|
|
692
|
+
table(row,col,data,opts={}) {
|
|
693
|
+
const{headerColor=['black','cyan'],cellColor=['white',null],colWidths}=opts;
|
|
694
|
+
if(!data.length)return this;
|
|
695
|
+
const widths=colWidths??data[0].map((_,ci)=>Math.max(...data.map(r=>(r[ci]??'').length))+2);
|
|
696
|
+
let r=row;
|
|
697
|
+
for(let ri=0;ri<data.length;ri++){let c=col;const isH=ri===0;for(let ci=0;ci<data[ri].length;ci++){const cell=(' '+(data[ri][ci]??'')+' ').padEnd(widths[ci]).slice(0,widths[ci]);this.writeText(r,c,cell,isH?{color:headerColor}:{color:cellColor});c+=widths[ci];}r++;}
|
|
698
|
+
return this;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ── Snapshot/copy ──────────────────────────────────────────────────────────
|
|
702
|
+
snapshot() { return this.grid.map(r=>r.map(p=>p.clone())); }
|
|
703
|
+
restoreSnapshot(snap) {
|
|
704
|
+
for(let r=0;r<snap.length&&r<this.grid.length;r++) for(let c=0;c<snap[r].length&&c<this.grid[r].length;c++) if(!this.grid[r][c].isStatic)this.grid[r][c]=snap[r][c].clone();
|
|
705
|
+
this._dirty=true; return this;
|
|
706
|
+
}
|
|
707
|
+
copyRegion(row,col,rows,cols) { return Array.from({length:rows},(_,r)=>Array.from({length:cols},(_,c)=>this.grid[row+r]?.[col+c]?.clone()??new TVPixel())); }
|
|
708
|
+
pasteRegion(row,col,pixels) { for(let r=0;r<pixels.length;r++)for(let c=0;c<pixels[r].length;c++)this.setPixel(row+r,col+c,pixels[r][c]);return this; }
|
|
709
|
+
|
|
710
|
+
// ── Destroy ────────────────────────────────────────────────────────────────
|
|
711
|
+
destroy() {
|
|
712
|
+
this._stopRenderLoop();
|
|
713
|
+
this._animations.forEach(s=>s()); this._animations=[];
|
|
714
|
+
this._dataBindings.forEach(({source,event,handler})=>source.removeListener(event,handler)); this._dataBindings=[];
|
|
715
|
+
inputManager.unregister(this);
|
|
716
|
+
if(this._altScreen)this.leaveAltScreen();
|
|
717
|
+
this.emit('destroy'); this.removeAllListeners();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
toString() { return this.grid.map(r=>r.map(p=>p.char).join('')).join('\n'); }
|
|
721
|
+
toAnsiString() { return this.grid.map(r=>r.map(p=>p.render()).join('')+ansi.reset).join('\n'); }
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function createFullscreen(opts={}) {
|
|
725
|
+
return new TView({rows:process.stdout.rows||24,cols:process.stdout.columns||80,...opts});
|
|
726
|
+
}
|
|
727
|
+
function px(char,fg=null,bg=null,isStatic=false) { return new TVPixel({char,color:[fg,bg],isStatic}); }
|
|
728
|
+
|
|
729
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
730
|
+
// §5 tshell — child-process runner / interactive shell
|
|
731
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* TShell — spawn programs, pipe their output into TViews, send input,
|
|
735
|
+
* collect results, and run shell pipelines.
|
|
736
|
+
*
|
|
737
|
+
* @example
|
|
738
|
+
* const sh = new TShell({ view: myView });
|
|
739
|
+
* const { stdout } = await sh.run('ls', ['-la']);
|
|
740
|
+
* sh.spawn('tail', ['-f', '/var/log/syslog']); // live stream
|
|
741
|
+
* sh.write('hello\n'); // send stdin
|
|
742
|
+
*/
|
|
743
|
+
class TShell extends EventEmitter {
|
|
744
|
+
/**
|
|
745
|
+
* @param {object} [opts]
|
|
746
|
+
* @param {TView} [opts.view] — pipe stdout into this view
|
|
747
|
+
* @param {object} [opts.env] — extra env vars (merged over process.env)
|
|
748
|
+
* @param {string} [opts.cwd] — working directory
|
|
749
|
+
* @param {*} [opts.pixelOpts] — color/style for piped text
|
|
750
|
+
* @param {string} [opts.shell] — shell binary for .shell() (default: /bin/sh)
|
|
751
|
+
*/
|
|
752
|
+
constructor(opts = {}) {
|
|
753
|
+
super();
|
|
754
|
+
this._view = opts.view ?? null;
|
|
755
|
+
this._env = opts.env ? { ...process.env, ...opts.env } : process.env;
|
|
756
|
+
this._cwd = opts.cwd ?? process.cwd();
|
|
757
|
+
this._pixelOpts = opts.pixelOpts ?? { color: ['brightgreen', null] };
|
|
758
|
+
this._shell = opts.shell ?? (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
|
|
759
|
+
this._proc = null; // currently spawned process
|
|
760
|
+
this._history = []; // command history
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Change the view that output is piped into. */
|
|
764
|
+
setView(view) { this._view = view; return this; }
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Run a command and collect its output (like exec but streaming).
|
|
768
|
+
* Resolves when the process exits.
|
|
769
|
+
* @param {string} cmd
|
|
770
|
+
* @param {string[]} [args=[]]
|
|
771
|
+
* @param {object} [spawnOpts]
|
|
772
|
+
* @returns {Promise<{code, stdout, stderr}>}
|
|
773
|
+
*/
|
|
774
|
+
run(cmd, args = [], spawnOpts = {}) {
|
|
775
|
+
return new Promise((resolve, reject) => {
|
|
776
|
+
const proc = spawn(cmd, args, {
|
|
777
|
+
env: this._env, cwd: this._cwd,
|
|
778
|
+
...spawnOpts,
|
|
779
|
+
});
|
|
780
|
+
let stdout = '', stderr = '';
|
|
781
|
+
proc.stdout?.on('data', d => {
|
|
782
|
+
const s = String(d); stdout += s;
|
|
783
|
+
this._toView(s); this.emit('data', s);
|
|
784
|
+
});
|
|
785
|
+
proc.stderr?.on('data', d => {
|
|
786
|
+
const s = String(d); stderr += s;
|
|
787
|
+
this._toView(s, { color: ['brightyellow', null] }); this.emit('stderr', s);
|
|
788
|
+
});
|
|
789
|
+
proc.on('error', reject);
|
|
790
|
+
proc.on('close', code => {
|
|
791
|
+
this._history.push({ cmd, args, code, ts: Date.now() });
|
|
792
|
+
this.emit('exit', { cmd, args, code });
|
|
793
|
+
resolve({ code, stdout, stderr });
|
|
794
|
+
});
|
|
795
|
+
this._proc = proc;
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Spawn a long-running process (don't await its exit).
|
|
801
|
+
* Stdout is continuously piped to the bound view.
|
|
802
|
+
* @returns {ChildProcess}
|
|
803
|
+
*/
|
|
804
|
+
spawn(cmd, args = [], spawnOpts = {}) {
|
|
805
|
+
const proc = spawn(cmd, args, { env: this._env, cwd: this._cwd, ...spawnOpts });
|
|
806
|
+
proc.stdout?.on('data', d => { const s=String(d); this._toView(s); this.emit('data',s); });
|
|
807
|
+
proc.stderr?.on('data', d => { const s=String(d); this._toView(s,{color:['brightyellow',null]}); this.emit('stderr',s); });
|
|
808
|
+
proc.on('close', code => { this._history.push({cmd,args,code,ts:Date.now()}); this.emit('exit',{cmd,args,code}); });
|
|
809
|
+
proc.on('error', err => this.emit('error', err));
|
|
810
|
+
this._proc = proc;
|
|
811
|
+
return proc;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Execute a shell command string (passed to /bin/sh -c).
|
|
816
|
+
* @param {string} cmdStr — e.g. 'ls -la | head -5'
|
|
817
|
+
* @returns {Promise<{code, stdout, stderr}>}
|
|
818
|
+
*/
|
|
819
|
+
shell(cmdStr) {
|
|
820
|
+
return this.run(this._shell, ['-c', cmdStr]);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Run a command synchronously (blocks the event loop).
|
|
825
|
+
* @returns {{ code, stdout, stderr }}
|
|
826
|
+
*/
|
|
827
|
+
runSync(cmd, args = [], opts = {}) {
|
|
828
|
+
const result = spawnSync(cmd, args, {
|
|
829
|
+
env: this._env, cwd: this._cwd, encoding: 'utf8', ...opts,
|
|
830
|
+
});
|
|
831
|
+
this._history.push({ cmd, args, code: result.status, ts: Date.now() });
|
|
832
|
+
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Send text to the running process's stdin. */
|
|
836
|
+
write(data) {
|
|
837
|
+
if (this._proc?.stdin) { this._proc.stdin.write(data); }
|
|
838
|
+
return this;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Send SIGTERM (or another signal) to the running process. */
|
|
842
|
+
kill(signal = 'SIGTERM') {
|
|
843
|
+
this._proc?.kill(signal); return this;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Change working directory (affects subsequent runs). */
|
|
847
|
+
cd(dir) {
|
|
848
|
+
const resolved = path.resolve(this._cwd, dir);
|
|
849
|
+
if (fs.existsSync(resolved)) { this._cwd = resolved; this.emit('cwd', resolved); }
|
|
850
|
+
else throw new Error(`TShell.cd: directory not found: ${resolved}`);
|
|
851
|
+
return this;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Set/get an environment variable for subsequent runs. */
|
|
855
|
+
setEnv(key, value) { this._env = { ...this._env, [key]: String(value) }; return this; }
|
|
856
|
+
getEnv(key) { return this._env[key]; }
|
|
857
|
+
|
|
858
|
+
/** Returns a copy of the command history array. */
|
|
859
|
+
getHistory() { return [...this._history]; }
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Run a series of commands in sequence, stopping on first non-zero exit.
|
|
863
|
+
* @param {Array<{cmd, args}>} pipeline
|
|
864
|
+
* @returns {Promise<Array<result>>}
|
|
865
|
+
*/
|
|
866
|
+
async sequence(pipeline) {
|
|
867
|
+
const results = [];
|
|
868
|
+
for (const { cmd, args = [] } of pipeline) {
|
|
869
|
+
const r = await this.run(cmd, args);
|
|
870
|
+
results.push(r);
|
|
871
|
+
if (r.code !== 0) break;
|
|
872
|
+
}
|
|
873
|
+
return results;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Run commands in parallel, resolve when all complete.
|
|
878
|
+
* @param {Array<{cmd, args}>} commands
|
|
879
|
+
*/
|
|
880
|
+
parallel(commands) {
|
|
881
|
+
return Promise.all(commands.map(({ cmd, args = [] }) => this.run(cmd, args)));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
_toView(str, overrideOpts) {
|
|
885
|
+
if (!this._view) return;
|
|
886
|
+
const opts = overrideOpts ?? this._pixelOpts;
|
|
887
|
+
// Append each line to the view grid
|
|
888
|
+
const lines = str.split('\n');
|
|
889
|
+
for (let i = 0; i < lines.length; i++) {
|
|
890
|
+
const line = lines[i].replace(/\x1b\[[^m]*m/g, '');
|
|
891
|
+
if (!line && i === lines.length - 1) continue;
|
|
892
|
+
const row = this._view.grid.length;
|
|
893
|
+
this._view.grid.push(Array.from({length:this._view.cols},()=>new TVPixel({char:' '})));
|
|
894
|
+
const display = line.slice(0, this._view.cols);
|
|
895
|
+
for (let c = 0; c < display.length; c++)
|
|
896
|
+
this._view.grid[row][c] = new TVPixel({ char: display[c], ...opts });
|
|
897
|
+
if (this._view.scrollable) this._view.scrollToBottom();
|
|
898
|
+
}
|
|
899
|
+
this._view.markDirty();
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
get cwd() { return this._cwd; }
|
|
903
|
+
get pid() { return this._proc?.pid ?? null; }
|
|
904
|
+
get alive(){ return !!(this._proc && !this._proc.exitCode && !this._proc.killed); }
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
908
|
+
// §6 tenv — typed, validated environment config
|
|
909
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* TEnv — typed env-var system with defaults, validation, transforms,
|
|
913
|
+
* .env file loading, and change-watching.
|
|
914
|
+
*
|
|
915
|
+
* @example
|
|
916
|
+
* const env = new TEnv();
|
|
917
|
+
* env.define('PORT', { type: 'int', default: 3000 });
|
|
918
|
+
* env.define('DEBUG', { type: 'bool', default: false });
|
|
919
|
+
* env.define('LOG_LEVEL', { type: 'enum', values: ['debug','info','warn','error'], default: 'info' });
|
|
920
|
+
* env.define('API_KEY', { type: 'string', required: true });
|
|
921
|
+
* env.load();
|
|
922
|
+
*
|
|
923
|
+
* const port = env.get('PORT'); // → 3000 (number)
|
|
924
|
+
* env.set('DEBUG', true); // programmatic override
|
|
925
|
+
* env.watch('./config.env', 500); // reload on file change
|
|
926
|
+
*/
|
|
927
|
+
class TEnv extends EventEmitter {
|
|
928
|
+
constructor(opts = {}) {
|
|
929
|
+
super();
|
|
930
|
+
this._source = opts.source ?? process.env; // raw string map
|
|
931
|
+
this._schema = new Map(); // name → schema entry
|
|
932
|
+
this._values = new Map(); // name → parsed value
|
|
933
|
+
this._watchers = [];
|
|
934
|
+
this._strict = opts.strict ?? false; // throw on undefined vars
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Define a variable.
|
|
939
|
+
* @param {string} name
|
|
940
|
+
* @param {object} schema
|
|
941
|
+
* @param {string} schema.type — 'string'|'int'|'float'|'bool'|'json'|'list'|'enum'|'path'|'url'
|
|
942
|
+
* @param {*} [schema.default] — value when var is absent
|
|
943
|
+
* @param {boolean} [schema.required] — throw if absent and no default
|
|
944
|
+
* @param {string[]} [schema.values] — allowed values (for enum)
|
|
945
|
+
* @param {function} [schema.validate] — custom (val)=>true|string(error)
|
|
946
|
+
* @param {function} [schema.transform]— (raw)=>transformed
|
|
947
|
+
* @param {string} [schema.desc] — human description
|
|
948
|
+
*/
|
|
949
|
+
define(name, schema = {}) {
|
|
950
|
+
this._schema.set(name, { type: 'string', ...schema });
|
|
951
|
+
return this;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Load and parse all defined variables from the source.
|
|
956
|
+
* @param {string} [dotenvPath] — optional .env file to read first
|
|
957
|
+
*/
|
|
958
|
+
load(dotenvPath) {
|
|
959
|
+
if (dotenvPath) this._parseDotenv(dotenvPath);
|
|
960
|
+
for (const [name, schema] of this._schema) {
|
|
961
|
+
this._resolve(name, schema);
|
|
962
|
+
}
|
|
963
|
+
this.emit('load', Object.fromEntries(this._values));
|
|
964
|
+
return this;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Get a parsed value. Throws if not loaded and strict mode is on. */
|
|
968
|
+
get(name) {
|
|
969
|
+
if (!this._values.has(name)) {
|
|
970
|
+
const schema = this._schema.get(name);
|
|
971
|
+
if (schema) this._resolve(name, schema);
|
|
972
|
+
else if (this._strict) throw new Error(`TEnv: unknown variable "${name}"`);
|
|
973
|
+
else return this._source[name];
|
|
974
|
+
}
|
|
975
|
+
return this._values.get(name);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Programmatically override a value (bypasses env but not validate). */
|
|
979
|
+
set(name, value) {
|
|
980
|
+
const schema = this._schema.get(name) ?? { type: typeof value === 'number' ? 'float' : 'string' };
|
|
981
|
+
const parsed = this._parse(String(value), schema);
|
|
982
|
+
this._values.set(name, parsed);
|
|
983
|
+
this.emit('change', { name, value: parsed });
|
|
984
|
+
return this;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Return all resolved values as a plain object. */
|
|
988
|
+
all() { return Object.fromEntries(this._values); }
|
|
989
|
+
|
|
990
|
+
/** Return a flat diagnostic table (useful for TView.table()). */
|
|
991
|
+
describe() {
|
|
992
|
+
const rows = [['Variable', 'Type', 'Value', 'Default', 'Required', 'Description']];
|
|
993
|
+
for (const [name, schema] of this._schema) {
|
|
994
|
+
rows.push([
|
|
995
|
+
name,
|
|
996
|
+
schema.type ?? 'string',
|
|
997
|
+
String(this._values.has(name) ? this._values.get(name) : '—'),
|
|
998
|
+
String(schema.default ?? '—'),
|
|
999
|
+
schema.required ? 'yes' : 'no',
|
|
1000
|
+
schema.desc ?? '',
|
|
1001
|
+
]);
|
|
1002
|
+
}
|
|
1003
|
+
return rows;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Watch a .env file for changes and reload automatically.
|
|
1008
|
+
* @param {string} filePath
|
|
1009
|
+
* @param {number} [interval=1000] — poll interval ms (fs.watchFile)
|
|
1010
|
+
*/
|
|
1011
|
+
watch(filePath, interval = 1000) {
|
|
1012
|
+
fs.watchFile(filePath, { interval }, () => {
|
|
1013
|
+
this._parseDotenv(filePath);
|
|
1014
|
+
for (const [name, schema] of this._schema) this._resolve(name, schema);
|
|
1015
|
+
this.emit('reload', Object.fromEntries(this._values));
|
|
1016
|
+
});
|
|
1017
|
+
this._watchers.push(filePath);
|
|
1018
|
+
return this;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
unwatch() { this._watchers.forEach(f => fs.unwatchFile(f)); this._watchers = []; return this; }
|
|
1022
|
+
|
|
1023
|
+
// ── internal ──────────────────────────────────────────────────────────────
|
|
1024
|
+
|
|
1025
|
+
_resolve(name, schema) {
|
|
1026
|
+
const raw = this._source[name];
|
|
1027
|
+
if (raw == null || raw === '') {
|
|
1028
|
+
if (schema.required && schema.default == null)
|
|
1029
|
+
throw new Error(`TEnv: required variable "${name}" is not set`);
|
|
1030
|
+
this._values.set(name, schema.default ?? null);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
let value = schema.transform ? schema.transform(raw) : this._parse(raw, schema);
|
|
1034
|
+
if (schema.validate) {
|
|
1035
|
+
const verdict = schema.validate(value);
|
|
1036
|
+
if (verdict !== true && verdict != null)
|
|
1037
|
+
throw new Error(`TEnv: "${name}" failed validation: ${verdict}`);
|
|
1038
|
+
}
|
|
1039
|
+
this._values.set(name, value);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
_parse(raw, schema) {
|
|
1043
|
+
switch (schema.type) {
|
|
1044
|
+
case 'int': return parseInt(raw, 10);
|
|
1045
|
+
case 'float': return parseFloat(raw);
|
|
1046
|
+
case 'bool': return /^(true|1|yes|on)$/i.test(raw);
|
|
1047
|
+
case 'json': return JSON.parse(raw);
|
|
1048
|
+
case 'list': return raw.split(schema.separator ?? ',').map(s => s.trim());
|
|
1049
|
+
case 'path': return path.resolve(raw);
|
|
1050
|
+
case 'url': return new URL(raw).toString();
|
|
1051
|
+
case 'enum': {
|
|
1052
|
+
const vals = schema.values ?? [];
|
|
1053
|
+
if (!vals.includes(raw))
|
|
1054
|
+
throw new Error(`TEnv: "${raw}" not in allowed values [${vals.join(', ')}]`);
|
|
1055
|
+
return raw;
|
|
1056
|
+
}
|
|
1057
|
+
default: return raw;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
_parseDotenv(filePath) {
|
|
1062
|
+
if (!fs.existsSync(filePath)) return;
|
|
1063
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
1064
|
+
for (const line of lines) {
|
|
1065
|
+
const trimmed = line.trim();
|
|
1066
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
1067
|
+
const eq = trimmed.indexOf('=');
|
|
1068
|
+
if (eq < 0) continue;
|
|
1069
|
+
const key = trimmed.slice(0, eq).trim();
|
|
1070
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
1071
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
1072
|
+
(val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
|
|
1073
|
+
this._source[key] = val;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1079
|
+
// §7 tlog — levelled logger with TView integration
|
|
1080
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* TLog — structured levelled logger.
|
|
1084
|
+
* Can write to a TView, to a file, or both.
|
|
1085
|
+
*
|
|
1086
|
+
* @example
|
|
1087
|
+
* const log = new TLog({ view: myView, level: 'debug' });
|
|
1088
|
+
* log.info('Server started', { port: 3000 });
|
|
1089
|
+
* log.error('Connection failed', err);
|
|
1090
|
+
* log.time('init'); // start timer
|
|
1091
|
+
* log.timeEnd('init'); // log elapsed
|
|
1092
|
+
*/
|
|
1093
|
+
class TLog extends EventEmitter {
|
|
1094
|
+
static LEVELS = { trace:0, debug:1, info:2, warn:3, error:4, fatal:5, silent:99 };
|
|
1095
|
+
static COLORS = { trace:'gray', debug:'brightcyan', info:'brightgreen', warn:'brightyellow', error:'brightred', fatal:'magenta' };
|
|
1096
|
+
static ICONS = { trace:'·', debug:'◌', info:'ℹ', warn:'⚠', error:'✖', fatal:'☠' };
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* @param {object} [opts]
|
|
1100
|
+
* @param {TView} [opts.view] — render logs here
|
|
1101
|
+
* @param {string} [opts.level] — minimum level (default 'info')
|
|
1102
|
+
* @param {string} [opts.file] — path to append log file
|
|
1103
|
+
* @param {boolean} [opts.timestamp] — include ISO timestamp (default true)
|
|
1104
|
+
* @param {boolean} [opts.json] — write JSON to file instead of pretty
|
|
1105
|
+
* @param {number} [opts.maxLines] — max lines kept in view grid (default 500)
|
|
1106
|
+
* @param {string} [opts.prefix] — static prefix for all messages
|
|
1107
|
+
*/
|
|
1108
|
+
constructor(opts = {}) {
|
|
1109
|
+
super();
|
|
1110
|
+
this._view = opts.view ?? null;
|
|
1111
|
+
this._level = TLog.LEVELS[opts.level ?? 'info'] ?? TLog.LEVELS.info;
|
|
1112
|
+
this._file = opts.file ?? null;
|
|
1113
|
+
this._timestamp = opts.timestamp ?? true;
|
|
1114
|
+
this._json = opts.json ?? false;
|
|
1115
|
+
this._maxLines = opts.maxLines ?? 500;
|
|
1116
|
+
this._prefix = opts.prefix ?? '';
|
|
1117
|
+
this._timers = new Map();
|
|
1118
|
+
this._fileStream = this._file ? fs.createWriteStream(this._file, { flags: 'a' }) : null;
|
|
1119
|
+
this._lineCount = 0;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
trace(msg, meta) { return this._log('trace', msg, meta); }
|
|
1123
|
+
debug(msg, meta) { return this._log('debug', msg, meta); }
|
|
1124
|
+
info (msg, meta) { return this._log('info', msg, meta); }
|
|
1125
|
+
warn (msg, meta) { return this._log('warn', msg, meta); }
|
|
1126
|
+
error(msg, meta) { return this._log('error', msg, meta); }
|
|
1127
|
+
fatal(msg, meta) { return this._log('fatal', msg, meta); }
|
|
1128
|
+
|
|
1129
|
+
/** Set log level dynamically. */
|
|
1130
|
+
setLevel(level) { this._level = TLog.LEVELS[level] ?? this._level; return this; }
|
|
1131
|
+
|
|
1132
|
+
/** Bind to a TView. */
|
|
1133
|
+
setView(view) { this._view = view; return this; }
|
|
1134
|
+
|
|
1135
|
+
/** Start a named timer. */
|
|
1136
|
+
time(label) { this._timers.set(label, process.hrtime.bigint()); return this; }
|
|
1137
|
+
|
|
1138
|
+
/** Stop a named timer and log the elapsed time. */
|
|
1139
|
+
timeEnd(label) {
|
|
1140
|
+
const start = this._timers.get(label);
|
|
1141
|
+
if (!start) { this.warn(`timeEnd: no timer "${label}"`); return this; }
|
|
1142
|
+
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
|
1143
|
+
this._timers.delete(label);
|
|
1144
|
+
return this.info(`${label}: ${ms.toFixed(2)}ms`);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** Log a group header (indents subsequent lines visually). */
|
|
1148
|
+
group(label) { return this._log('info', `▼ ${label}`); }
|
|
1149
|
+
groupEnd() { return this._log('info', '▲'); }
|
|
1150
|
+
|
|
1151
|
+
/** Dump an object as formatted JSON to the log. */
|
|
1152
|
+
dir(obj, label = '') {
|
|
1153
|
+
const str = JSON.stringify(obj, null, 2);
|
|
1154
|
+
for (const line of (`${label ? label + ': ' : ''}${str}`).split('\n'))
|
|
1155
|
+
this._log('debug', line);
|
|
1156
|
+
return this;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Clear the log view. */
|
|
1160
|
+
clearView() {
|
|
1161
|
+
if (this._view) { this._view.clear(); this._lineCount = 0; }
|
|
1162
|
+
return this;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
_log(level, msg, meta) {
|
|
1166
|
+
if (TLog.LEVELS[level] < this._level) return this;
|
|
1167
|
+
const ts = this._timestamp ? new Date().toISOString().slice(11,23) : '';
|
|
1168
|
+
const icon = TLog.ICONS[level] || ' ';
|
|
1169
|
+
const color= TLog.COLORS[level] || 'white';
|
|
1170
|
+
const metaStr = meta != null
|
|
1171
|
+
? (typeof meta === 'string' ? ' ' + meta : ' ' + JSON.stringify(meta))
|
|
1172
|
+
: '';
|
|
1173
|
+
const prefix = this._prefix ? `[${this._prefix}] ` : '';
|
|
1174
|
+
const line = `${ts ? ts + ' ' : ''}${icon} ${prefix}${msg}${metaStr}`;
|
|
1175
|
+
|
|
1176
|
+
// View output
|
|
1177
|
+
if (this._view) {
|
|
1178
|
+
if (this._lineCount >= this._maxLines) {
|
|
1179
|
+
this._view.grid.shift(); this._lineCount--;
|
|
1180
|
+
}
|
|
1181
|
+
const row = this._view.grid.length;
|
|
1182
|
+
this._view.grid.push(Array.from({length:this._view.cols},()=>new TVPixel({char:' '})));
|
|
1183
|
+
const display = line.slice(0, this._view.cols);
|
|
1184
|
+
for (let c = 0; c < display.length; c++)
|
|
1185
|
+
this._view.grid[row][c] = new TVPixel({ char: display[c], color: [color, null] });
|
|
1186
|
+
if (this._view.scrollable) this._view.scrollToBottom();
|
|
1187
|
+
this._view.markDirty();
|
|
1188
|
+
this._lineCount++;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// File output
|
|
1192
|
+
if (this._fileStream) {
|
|
1193
|
+
const fileLine = this._json
|
|
1194
|
+
? JSON.stringify({ ts: new Date().toISOString(), level, msg, meta }) + '\n'
|
|
1195
|
+
: line + '\n';
|
|
1196
|
+
this._fileStream.write(fileLine);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const entry = { ts: new Date().toISOString(), level, msg, meta };
|
|
1200
|
+
this.emit('log', entry);
|
|
1201
|
+
this.emit(level, entry);
|
|
1202
|
+
return this;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
close() { this._fileStream?.end(); return this; }
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1209
|
+
// §8 ttheme — named colour/style token system
|
|
1210
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* TTheme — token-based colour system. Define named tokens, bundle them into
|
|
1214
|
+
* themes, switch themes at runtime. All TView/TVPixel colour calls
|
|
1215
|
+
* accept raw values, so you just pass theme.get('primary') anywhere.
|
|
1216
|
+
*
|
|
1217
|
+
* @example
|
|
1218
|
+
* const theme = new TTheme();
|
|
1219
|
+
* theme.define('dark', { primary:'brightcyan', bg:'#0d1117', fg:'white', error:'brightred', muted:'gray' });
|
|
1220
|
+
* theme.define('light',{ primary:'blue', bg:'white', fg:'black', error:'red', muted:'gray' });
|
|
1221
|
+
* theme.use('dark');
|
|
1222
|
+
*
|
|
1223
|
+
* view.writeText(0, 0, 'Hello', { color: [theme.get('primary'), theme.get('bg')] });
|
|
1224
|
+
* theme.on('change', name => view.markDirty());
|
|
1225
|
+
*/
|
|
1226
|
+
class TTheme extends EventEmitter {
|
|
1227
|
+
/** Built-in themes */
|
|
1228
|
+
static DARK = {
|
|
1229
|
+
primary:'brightcyan', secondary:'brightmagenta', accent:'brightyellow',
|
|
1230
|
+
success:'brightgreen', warning:'brightyellow', error:'brightred', info:'brightblue',
|
|
1231
|
+
fg:'brightwhite', fgMuted:'gray', bg:'#0d1117', bgAlt:'#161b22',
|
|
1232
|
+
bgHighlight:'#21262d', border:'#30363d', selection:'#1f6feb',
|
|
1233
|
+
};
|
|
1234
|
+
static LIGHT = {
|
|
1235
|
+
primary:'blue', secondary:'magenta', accent:'yellow',
|
|
1236
|
+
success:'green', warning:'yellow', error:'red', info:'blue',
|
|
1237
|
+
fg:'black', fgMuted:'gray', bg:'white', bgAlt:'#f6f8fa',
|
|
1238
|
+
bgHighlight:'#e1e4e8', border:'#d0d7de', selection:'#0969da',
|
|
1239
|
+
};
|
|
1240
|
+
static MONOKAI = {
|
|
1241
|
+
primary:'#a6e22e', secondary:'#fd971f', accent:'#e6db74',
|
|
1242
|
+
success:'#a6e22e', warning:'#fd971f', error:'#f92672', info:'#66d9ef',
|
|
1243
|
+
fg:'#f8f8f2', fgMuted:'#75715e', bg:'#272822', bgAlt:'#3e3d32',
|
|
1244
|
+
bgHighlight:'#49483e', border:'#75715e', selection:'#49483e',
|
|
1245
|
+
};
|
|
1246
|
+
|
|
1247
|
+
constructor(initial = 'dark') {
|
|
1248
|
+
super();
|
|
1249
|
+
this._themes = new Map();
|
|
1250
|
+
this._current = null;
|
|
1251
|
+
this._name = null;
|
|
1252
|
+
// Register built-ins
|
|
1253
|
+
this.define('dark', TTheme.DARK);
|
|
1254
|
+
this.define('light', TTheme.LIGHT);
|
|
1255
|
+
this.define('monokai', TTheme.MONOKAI);
|
|
1256
|
+
this.use(initial);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** Register a named theme (merges with dark base for missing tokens). */
|
|
1260
|
+
define(name, tokens) {
|
|
1261
|
+
this._themes.set(name, { ...TTheme.DARK, ...tokens });
|
|
1262
|
+
return this;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** Switch to a named theme. */
|
|
1266
|
+
use(name) {
|
|
1267
|
+
if (!this._themes.has(name)) throw new Error(`TTheme: unknown theme "${name}"`);
|
|
1268
|
+
this._name = name;
|
|
1269
|
+
this._current = this._themes.get(name);
|
|
1270
|
+
this.emit('change', name, this._current);
|
|
1271
|
+
return this;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** Get a token value from the active theme. */
|
|
1275
|
+
get(token, fallback = null) {
|
|
1276
|
+
return this._current?.[token] ?? fallback;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** Get fg/bg pair as a color array suitable for TVPixel. */
|
|
1280
|
+
pair(fg, bg) { return [this.get(fg), this.get(bg)]; }
|
|
1281
|
+
|
|
1282
|
+
/** All token names of the active theme. */
|
|
1283
|
+
tokens() { return this._current ? Object.keys(this._current) : []; }
|
|
1284
|
+
|
|
1285
|
+
/** All registered theme names. */
|
|
1286
|
+
names() { return [...this._themes.keys()]; }
|
|
1287
|
+
|
|
1288
|
+
/** Active theme name. */
|
|
1289
|
+
get name() { return this._name; }
|
|
1290
|
+
|
|
1291
|
+
/** Active theme raw object. */
|
|
1292
|
+
get theme() { return { ...this._current }; }
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Build a TVPixel pixelOpts object from semantic token names.
|
|
1296
|
+
* @param {string} fgToken — e.g. 'primary'
|
|
1297
|
+
* @param {string} bgToken — e.g. 'bg'
|
|
1298
|
+
* @param {string} [style]
|
|
1299
|
+
*/
|
|
1300
|
+
style(fgToken, bgToken = null, style = null) {
|
|
1301
|
+
return { color: [this.get(fgToken), bgToken ? this.get(bgToken) : null], style };
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1306
|
+
// §9 twidget — higher-level interactive widgets
|
|
1307
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* TWidget — collection of interactive terminal widgets rendered into TViews.
|
|
1311
|
+
*
|
|
1312
|
+
* Widgets:
|
|
1313
|
+
* TWidget.Menu — keyboard/mouse navigable menu
|
|
1314
|
+
* TWidget.List — scrollable multi-select list
|
|
1315
|
+
* TWidget.Dialog — modal confirmation/info dialog
|
|
1316
|
+
* TWidget.TextInput — single-line text input component
|
|
1317
|
+
* TWidget.NumberInput — number spinner
|
|
1318
|
+
* TWidget.Checkbox — toggleable check item
|
|
1319
|
+
* TWidget.Tabs — tab bar + content switching
|
|
1320
|
+
* TWidget.Gauge — animated gauge/meter
|
|
1321
|
+
* TWidget.Notification — timed toast notification
|
|
1322
|
+
*/
|
|
1323
|
+
class TWidget {
|
|
1324
|
+
|
|
1325
|
+
// ── Menu ───────────────────────────────────────────────────────────────────
|
|
1326
|
+
/**
|
|
1327
|
+
* Render a navigable menu into a TView.
|
|
1328
|
+
* Returns a Promise that resolves to the selected item (or null on Escape).
|
|
1329
|
+
*
|
|
1330
|
+
* @param {TView} view
|
|
1331
|
+
* @param {string[]} items
|
|
1332
|
+
* @param {object} [opts]
|
|
1333
|
+
* @param {number} [opts.initialIndex=0]
|
|
1334
|
+
* @param {*} [opts.selectedColor='brightcyan']
|
|
1335
|
+
* @param {*} [opts.normalColor='white']
|
|
1336
|
+
* @param {string} [opts.prefix=' '] — prefix for non-selected items
|
|
1337
|
+
* @param {string} [opts.selPrefix='▶ ']— prefix for selected item
|
|
1338
|
+
*/
|
|
1339
|
+
static menu(view, items, opts = {}) {
|
|
1340
|
+
return new Promise(resolve => {
|
|
1341
|
+
const {
|
|
1342
|
+
initialIndex = 0,
|
|
1343
|
+
selectedColor = 'brightcyan',
|
|
1344
|
+
normalColor = 'white',
|
|
1345
|
+
prefix = ' ',
|
|
1346
|
+
selPrefix = '▶ ',
|
|
1347
|
+
} = opts;
|
|
1348
|
+
let idx = Math.max(0, Math.min(initialIndex, items.length - 1));
|
|
1349
|
+
|
|
1350
|
+
const draw = () => {
|
|
1351
|
+
for (let i = 0; i < items.length && i < view.rows; i++) {
|
|
1352
|
+
const sel = i === idx;
|
|
1353
|
+
const pre = sel ? selPrefix : prefix;
|
|
1354
|
+
const text = (pre + items[i]).padEnd(view.cols).slice(0, view.cols);
|
|
1355
|
+
view.writeText(i, 0, text, {
|
|
1356
|
+
color: [sel ? selectedColor : normalColor, null],
|
|
1357
|
+
style: sel ? 'bold' : null,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
view.markDirty();
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
draw();
|
|
1364
|
+
view.focus();
|
|
1365
|
+
|
|
1366
|
+
const offUp = view.onArrow('up', () => { idx = (idx - 1 + items.length) % items.length; draw(); });
|
|
1367
|
+
const offDown = view.onArrow('down', () => { idx = (idx + 1) % items.length; draw(); });
|
|
1368
|
+
const offEnter = view.onKey('enter', () => done(items[idx]));
|
|
1369
|
+
const offEsc = view.onKey('escape', () => done(null));
|
|
1370
|
+
const offClick = view.onClick(({ localRow }) => {
|
|
1371
|
+
if (localRow >= 0 && localRow < items.length) { idx = localRow; draw(); done(items[idx]); }
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
function done(value) {
|
|
1375
|
+
offUp(); offDown(); offEnter(); offEsc(); offClick();
|
|
1376
|
+
resolve(value);
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// ── List ───────────────────────────────────────────────────────────────────
|
|
1382
|
+
/**
|
|
1383
|
+
* Multi-select scrollable list.
|
|
1384
|
+
* Returns a Promise that resolves to an array of selected item strings.
|
|
1385
|
+
*
|
|
1386
|
+
* @param {TView} view
|
|
1387
|
+
* @param {string[]} items
|
|
1388
|
+
* @param {object} [opts]
|
|
1389
|
+
* @param {boolean} [opts.multiSelect=true]
|
|
1390
|
+
* @param {number[]} [opts.preSelected=[]]
|
|
1391
|
+
*/
|
|
1392
|
+
static list(view, items, opts = {}) {
|
|
1393
|
+
return new Promise(resolve => {
|
|
1394
|
+
const { multiSelect = true, preSelected = [], selectedColor = 'brightcyan', normalColor = 'white' } = opts;
|
|
1395
|
+
let cursor = 0, scroll = 0;
|
|
1396
|
+
const selected = new Set(preSelected);
|
|
1397
|
+
const visRows = view.rows;
|
|
1398
|
+
|
|
1399
|
+
const draw = () => {
|
|
1400
|
+
for (let i = 0; i < visRows; i++) {
|
|
1401
|
+
const idx = i + scroll;
|
|
1402
|
+
if (idx >= items.length) { view.clearRow(i); continue; }
|
|
1403
|
+
const isCursor = idx === cursor;
|
|
1404
|
+
const isSelected = selected.has(idx);
|
|
1405
|
+
const mark = isSelected ? '[✓] ' : '[ ] ';
|
|
1406
|
+
const color = isCursor ? selectedColor : (isSelected ? 'brightgreen' : normalColor);
|
|
1407
|
+
view.writeText(i, 0, (mark + items[idx]).padEnd(view.cols).slice(0, view.cols), { color:[color,null], style: isCursor?'bold':null });
|
|
1408
|
+
}
|
|
1409
|
+
view.markDirty();
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
draw();
|
|
1413
|
+
view.focus();
|
|
1414
|
+
|
|
1415
|
+
const offUp = view.onArrow('up', () => { if(cursor>0){cursor--;if(cursor<scroll)scroll--;} draw(); });
|
|
1416
|
+
const offDown = view.onArrow('down', () => { if(cursor<items.length-1){cursor++;if(cursor>=scroll+visRows)scroll++;} draw(); });
|
|
1417
|
+
const offSpace= view.onKey('space', () => { multiSelect?(selected.has(cursor)?selected.delete(cursor):selected.add(cursor)):(selected.clear(),selected.add(cursor)); draw(); });
|
|
1418
|
+
const offEnter= view.onKey('enter', () => done([...selected].map(i=>items[i])));
|
|
1419
|
+
const offEsc = view.onKey('escape', () => done([]));
|
|
1420
|
+
|
|
1421
|
+
function done(values) { offUp();offDown();offSpace();offEnter();offEsc(); resolve(values); }
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ── Dialog ─────────────────────────────────────────────────────────────────
|
|
1426
|
+
/**
|
|
1427
|
+
* Modal dialog over a view.
|
|
1428
|
+
* @param {TView} view — the view to draw the dialog on
|
|
1429
|
+
* @param {string} title
|
|
1430
|
+
* @param {string} message
|
|
1431
|
+
* @param {string[]} [buttons] — default: ['OK', 'Cancel']
|
|
1432
|
+
* @returns {Promise<string>} — the label of the clicked/selected button
|
|
1433
|
+
*/
|
|
1434
|
+
static dialog(view, title, message, buttons = ['OK', 'Cancel']) {
|
|
1435
|
+
return new Promise(resolve => {
|
|
1436
|
+
const snap = view.snapshot();
|
|
1437
|
+
const dw = Math.min(view.cols - 4, Math.max(title.length + 4, message.length + 4, 30));
|
|
1438
|
+
const dh = 6;
|
|
1439
|
+
const dr = Math.floor((view.rows - dh) / 2);
|
|
1440
|
+
const dc = Math.floor((view.cols - dw) / 2);
|
|
1441
|
+
let btnIdx = 0;
|
|
1442
|
+
|
|
1443
|
+
const draw = () => {
|
|
1444
|
+
view.drawBox(dr, dc, dh, dw, { color: ['brightcyan', null] }, 'double');
|
|
1445
|
+
view.writeText(dr, dc + Math.floor((dw - title.length) / 2), title, { color: ['brightwhite', null], style: 'bold' });
|
|
1446
|
+
view.writeText(dr + 2, dc + Math.floor((dw - message.length) / 2), message, { color: ['white', null] });
|
|
1447
|
+
let btnCol = dc + Math.floor((dw - buttons.reduce((a,b)=>a+b.length+4,0)) / 2);
|
|
1448
|
+
buttons.forEach((btn, i) => {
|
|
1449
|
+
const sel = i === btnIdx;
|
|
1450
|
+
const text = ` [ ${btn} ] `;
|
|
1451
|
+
view.writeText(dr + 4, btnCol, text, { color: [sel ? 'black' : 'white', sel ? 'brightcyan' : null] });
|
|
1452
|
+
btnCol += text.length;
|
|
1453
|
+
});
|
|
1454
|
+
view.markDirty();
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
draw();
|
|
1458
|
+
view.focus();
|
|
1459
|
+
|
|
1460
|
+
const offLeft = view.onArrow('left', () => { btnIdx=(btnIdx-1+buttons.length)%buttons.length; draw(); });
|
|
1461
|
+
const offRight = view.onArrow('right', () => { btnIdx=(btnIdx+1)%buttons.length; draw(); });
|
|
1462
|
+
const offEnter = view.onKey('enter', () => done(buttons[btnIdx]));
|
|
1463
|
+
const offEsc = view.onKey('escape', () => done(buttons[buttons.length-1]));
|
|
1464
|
+
|
|
1465
|
+
function done(label) {
|
|
1466
|
+
offLeft();offRight();offEnter();offEsc();
|
|
1467
|
+
view.restoreSnapshot(snap);
|
|
1468
|
+
resolve(label);
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// ── TextInput ──────────────────────────────────────────────────────────────
|
|
1474
|
+
/**
|
|
1475
|
+
* Inline text-input rendered at (row, col) in a view.
|
|
1476
|
+
* Returns a Promise<string|null>.
|
|
1477
|
+
*
|
|
1478
|
+
* @param {TView} view
|
|
1479
|
+
* @param {number} row
|
|
1480
|
+
* @param {number} col
|
|
1481
|
+
* @param {number} width
|
|
1482
|
+
* @param {object} [opts]
|
|
1483
|
+
* @param {string} [opts.placeholder]
|
|
1484
|
+
* @param {string} [opts.initial]
|
|
1485
|
+
* @param {string} [opts.mask] — char to show instead of actual input (e.g. '*' for passwords)
|
|
1486
|
+
*/
|
|
1487
|
+
static textInput(view, row, col, width, opts = {}) {
|
|
1488
|
+
return new Promise(resolve => {
|
|
1489
|
+
const { placeholder = '', initial = '', mask = null } = opts;
|
|
1490
|
+
let value = initial;
|
|
1491
|
+
let cursor = value.length;
|
|
1492
|
+
|
|
1493
|
+
const draw = () => {
|
|
1494
|
+
const display = mask ? mask.repeat(value.length) : value;
|
|
1495
|
+
const visible = display.slice(Math.max(0, cursor - width + 1));
|
|
1496
|
+
const text = (visible || placeholder).padEnd(width).slice(0, width);
|
|
1497
|
+
const color = value.length ? 'white' : 'gray';
|
|
1498
|
+
view.writeText(row, col, text, { color: [color, '#1a1a2e'] });
|
|
1499
|
+
view.markDirty();
|
|
1500
|
+
process.stdout.write(ansi.moveTo(view.offsetRow + row, view.offsetCol + col + Math.min(cursor, width - 1)));
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
draw(); view.focus();
|
|
1504
|
+
|
|
1505
|
+
const onKey = key => {
|
|
1506
|
+
if (key.name === 'enter') { cleanup(); resolve(value); }
|
|
1507
|
+
else if (key.name === 'escape'){ cleanup(); resolve(null); }
|
|
1508
|
+
else if (key.name === 'backspace' && cursor > 0) { value=value.slice(0,cursor-1)+value.slice(cursor); cursor--; draw(); }
|
|
1509
|
+
else if (key.name === 'delete' && cursor < value.length) { value=value.slice(0,cursor)+value.slice(cursor+1); draw(); }
|
|
1510
|
+
else if (key.name === 'left' && cursor > 0) { cursor--; draw(); }
|
|
1511
|
+
else if (key.name === 'right' && cursor < value.length) { cursor++; draw(); }
|
|
1512
|
+
else if (key.name === 'home') { cursor = 0; draw(); }
|
|
1513
|
+
else if (key.name === 'end') { cursor = value.length; draw(); }
|
|
1514
|
+
else if (key.char && key.char >= ' ' && !key.ctrl) { value=value.slice(0,cursor)+key.char+value.slice(cursor); cursor++; draw(); }
|
|
1515
|
+
};
|
|
1516
|
+
view.on('key', onKey);
|
|
1517
|
+
function cleanup() { view.removeListener('key', onKey); }
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// ── Checkbox ───────────────────────────────────────────────────────────────
|
|
1522
|
+
/**
|
|
1523
|
+
* Toggle-able checkbox at (row, col).
|
|
1524
|
+
* @param {TView} view
|
|
1525
|
+
* @param {number} row
|
|
1526
|
+
* @param {number} col
|
|
1527
|
+
* @param {string} label
|
|
1528
|
+
* @param {boolean} [initial=false]
|
|
1529
|
+
* @returns {{ get: ()=>boolean, set: (v)=>void, off: ()=>void }}
|
|
1530
|
+
*/
|
|
1531
|
+
static checkbox(view, row, col, label, initial = false) {
|
|
1532
|
+
let value = initial;
|
|
1533
|
+
const draw = () => {
|
|
1534
|
+
const box = value ? '[✓]' : '[ ]';
|
|
1535
|
+
view.writeText(row, col, `${box} ${label}`, { color: [value ? 'brightgreen' : 'white', null] });
|
|
1536
|
+
view.markDirty();
|
|
1537
|
+
};
|
|
1538
|
+
draw();
|
|
1539
|
+
const offClick = view.onClick(({ localRow, localCol }) => {
|
|
1540
|
+
if (localRow === row && localCol >= col && localCol < col + 3 + label.length + 1) {
|
|
1541
|
+
value = !value; draw();
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
view.focus();
|
|
1545
|
+
return { get: () => value, set: v => { value = Boolean(v); draw(); }, off: offClick };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// ── Tabs ───────────────────────────────────────────────────────────────────
|
|
1549
|
+
/**
|
|
1550
|
+
* Tab bar rendered at (row) in a view.
|
|
1551
|
+
* Returns a controller { active, setActive, onSwitch }.
|
|
1552
|
+
*
|
|
1553
|
+
* @param {TView} view
|
|
1554
|
+
* @param {number} row — row to draw the tab bar on
|
|
1555
|
+
* @param {string[]} tabNames
|
|
1556
|
+
*/
|
|
1557
|
+
static tabs(view, row, tabNames) {
|
|
1558
|
+
let active = 0;
|
|
1559
|
+
const handlers = [];
|
|
1560
|
+
|
|
1561
|
+
const draw = () => {
|
|
1562
|
+
let col = 0;
|
|
1563
|
+
tabNames.forEach((name, i) => {
|
|
1564
|
+
const isActive = i === active;
|
|
1565
|
+
const text = ` ${name} `;
|
|
1566
|
+
view.writeText(row, col, text, {
|
|
1567
|
+
color: [isActive ? 'black' : 'gray', isActive ? 'brightcyan' : null],
|
|
1568
|
+
style: isActive ? 'bold' : null,
|
|
1569
|
+
});
|
|
1570
|
+
col += text.length + 1;
|
|
1571
|
+
});
|
|
1572
|
+
view.markDirty();
|
|
1573
|
+
};
|
|
1574
|
+
draw();
|
|
1575
|
+
|
|
1576
|
+
const offClick = view.onClick(({ localRow, localCol }) => {
|
|
1577
|
+
if (localRow !== row) return;
|
|
1578
|
+
let col = 0;
|
|
1579
|
+
tabNames.forEach((name, i) => {
|
|
1580
|
+
const w = name.length + 2;
|
|
1581
|
+
if (localCol >= col && localCol < col + w) { active = i; draw(); handlers.forEach(h=>h(i,name)); }
|
|
1582
|
+
col += w + 1;
|
|
1583
|
+
});
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
return {
|
|
1587
|
+
get active() { return active; },
|
|
1588
|
+
setActive(i) { active=i; draw(); },
|
|
1589
|
+
onSwitch(fn) { handlers.push(fn); return ()=>{ const idx=handlers.indexOf(fn); if(idx>-1)handlers.splice(idx,1); }; },
|
|
1590
|
+
destroy() { offClick(); },
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// ── Notification ───────────────────────────────────────────────────────────
|
|
1595
|
+
/**
|
|
1596
|
+
* Show a timed toast notification over a view.
|
|
1597
|
+
*
|
|
1598
|
+
* @param {TView} view
|
|
1599
|
+
* @param {string} message
|
|
1600
|
+
* @param {object} [opts]
|
|
1601
|
+
* @param {number} [opts.duration=2500] — ms before auto-dismiss
|
|
1602
|
+
* @param {string} [opts.type] — 'info'|'success'|'warn'|'error'
|
|
1603
|
+
* @param {string} [opts.position] — 'top'|'bottom' (default 'top')
|
|
1604
|
+
*/
|
|
1605
|
+
static notify(view, message, opts = {}) {
|
|
1606
|
+
const { duration=2500, type='info', position='top' } = opts;
|
|
1607
|
+
const colors = { info:'brightcyan', success:'brightgreen', warn:'brightyellow', error:'brightred' };
|
|
1608
|
+
const icons = { info:'ℹ', success:'✔', warn:'⚠', error:'✖' };
|
|
1609
|
+
const color = colors[type] || 'brightcyan';
|
|
1610
|
+
const icon = icons[type] || 'ℹ';
|
|
1611
|
+
const snap = view.snapshot();
|
|
1612
|
+
const text = ` ${icon} ${message} `;
|
|
1613
|
+
const row = position === 'bottom' ? view.rows - 2 : 1;
|
|
1614
|
+
const col = Math.max(0, Math.floor((view.cols - text.length) / 2));
|
|
1615
|
+
view.writeText(row, col, text.slice(0,view.cols), { color: ['black', color] });
|
|
1616
|
+
view.markDirty();
|
|
1617
|
+
const timer = setTimeout(() => { view.restoreSnapshot(snap); view.markDirty(); }, duration);
|
|
1618
|
+
return () => { clearTimeout(timer); view.restoreSnapshot(snap); view.markDirty(); };
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// ── Gauge ──────────────────────────────────────────────────────────────────
|
|
1622
|
+
/**
|
|
1623
|
+
* Animated radial-style (ASCII) gauge rendered into a region.
|
|
1624
|
+
* @param {TView} view
|
|
1625
|
+
* @param {number} row
|
|
1626
|
+
* @param {number} col
|
|
1627
|
+
* @param {number} [size=5] — radius in chars (width = size*4+1)
|
|
1628
|
+
*/
|
|
1629
|
+
static gauge(view, row, col, size = 5) {
|
|
1630
|
+
let _value = 0;
|
|
1631
|
+
const ARC = ['▁','▂','▃','▄','▅','▆','▇','█'];
|
|
1632
|
+
|
|
1633
|
+
const draw = () => {
|
|
1634
|
+
const w = size * 4 + 1;
|
|
1635
|
+
const filled = Math.round(_value * w);
|
|
1636
|
+
const bar = ARC[7].repeat(filled).padEnd(w, '░').slice(0, w);
|
|
1637
|
+
view.writeText(row, col, bar, { color: [_value>0.7?'brightred':_value>0.4?'brightyellow':'brightgreen', null] });
|
|
1638
|
+
const pct = `${Math.round(_value*100)}%`.padStart(4);
|
|
1639
|
+
view.writeText(row+1, col + Math.floor((w-pct.length)/2), pct, { color: ['white', null] });
|
|
1640
|
+
view.markDirty();
|
|
1641
|
+
};
|
|
1642
|
+
draw();
|
|
1643
|
+
return {
|
|
1644
|
+
set value(v) { _value = Math.max(0, Math.min(1, v)); draw(); },
|
|
1645
|
+
get value() { return _value; },
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1651
|
+
// §10 trouter — keyboard-driven screen router
|
|
1652
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1653
|
+
|
|
1654
|
+
/**
|
|
1655
|
+
* TRouter — push/pop/replace stack-based screen router.
|
|
1656
|
+
*
|
|
1657
|
+
* Each "screen" is a named factory function: (view, router, params) => cleanup.
|
|
1658
|
+
* The cleanup function is called when the screen is popped or replaced.
|
|
1659
|
+
*
|
|
1660
|
+
* @example
|
|
1661
|
+
* const router = new TRouter(screen, inputManager);
|
|
1662
|
+
*
|
|
1663
|
+
* router.register('home', (view, router) => {
|
|
1664
|
+
* view.writeCentered(2, 'Press ENTER to go to settings');
|
|
1665
|
+
* view.onKey('enter', () => router.push('settings'));
|
|
1666
|
+
* return () => view.clear(); // cleanup
|
|
1667
|
+
* });
|
|
1668
|
+
*
|
|
1669
|
+
* router.register('settings', (view, router) => {
|
|
1670
|
+
* view.writeCentered(2, 'Settings — press Backspace to go back');
|
|
1671
|
+
* view.onKey('backspace', () => router.pop());
|
|
1672
|
+
* return () => view.clear();
|
|
1673
|
+
* });
|
|
1674
|
+
*
|
|
1675
|
+
* router.push('home');
|
|
1676
|
+
*/
|
|
1677
|
+
class TRouter extends EventEmitter {
|
|
1678
|
+
/**
|
|
1679
|
+
* @param {TView} rootView — the single view screens render into
|
|
1680
|
+
* @param {InputManager} [im] — defaults to the module singleton
|
|
1681
|
+
*/
|
|
1682
|
+
constructor(rootView, im = inputManager) {
|
|
1683
|
+
super();
|
|
1684
|
+
this._view = rootView;
|
|
1685
|
+
this._im = im;
|
|
1686
|
+
this._screens = new Map(); // name → factory(view, router, params) => cleanup
|
|
1687
|
+
this._stack = []; // [{ name, params, cleanup }]
|
|
1688
|
+
this._current = null;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* Register a screen factory.
|
|
1693
|
+
* @param {string} name
|
|
1694
|
+
* @param {function} factory — (view, router, params) => (cleanup | void)
|
|
1695
|
+
*/
|
|
1696
|
+
register(name, factory) {
|
|
1697
|
+
this._screens.set(name, factory);
|
|
1698
|
+
return this;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
/** Push a new screen onto the stack (the previous screen is paused). */
|
|
1702
|
+
push(name, params = {}) {
|
|
1703
|
+
const factory = this._screens.get(name);
|
|
1704
|
+
if (!factory) throw new Error(`TRouter: unknown screen "${name}"`);
|
|
1705
|
+
this._suspend();
|
|
1706
|
+
this._view.clear();
|
|
1707
|
+
const _cr = factory(this._view, this, params);
|
|
1708
|
+
const cleanup = typeof _cr === 'function' ? _cr : () => {};
|
|
1709
|
+
this._stack.push({ name, params, cleanup });
|
|
1710
|
+
this._current = name;
|
|
1711
|
+
this._view.focus();
|
|
1712
|
+
this.emit('push', { name, params, depth: this._stack.length });
|
|
1713
|
+
this.emit('navigate', { name, params, action: 'push' });
|
|
1714
|
+
return this;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/** Pop the current screen and resume the previous one. */
|
|
1718
|
+
pop(result) {
|
|
1719
|
+
if (this._stack.length <= 1) return this;
|
|
1720
|
+
const top = this._stack.pop();
|
|
1721
|
+
top.cleanup?.();
|
|
1722
|
+
this._view.clear();
|
|
1723
|
+
const prev = this._stack[this._stack.length - 1];
|
|
1724
|
+
this._current = prev.name;
|
|
1725
|
+
// Re-run the previous screen's factory (resume)
|
|
1726
|
+
const _resumed = this._screens.get(prev.name)?.(this._view, this, prev.params);
|
|
1727
|
+
prev.cleanup = typeof _resumed === 'function' ? _resumed : () => {};
|
|
1728
|
+
this._view.focus();
|
|
1729
|
+
this.emit('pop', { from: top.name, to: prev.name, result });
|
|
1730
|
+
this.emit('navigate', { name: prev.name, params: prev.params, action: 'pop', result });
|
|
1731
|
+
return this;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** Replace the current screen without growing the stack. */
|
|
1735
|
+
replace(name, params = {}) {
|
|
1736
|
+
const factory = this._screens.get(name);
|
|
1737
|
+
if (!factory) throw new Error(`TRouter: unknown screen "${name}"`);
|
|
1738
|
+
if (this._stack.length > 0) { const top = this._stack.pop(); top.cleanup?.(); }
|
|
1739
|
+
this._view.clear();
|
|
1740
|
+
const _rr = factory(this._view, this, params);
|
|
1741
|
+
const cleanup = typeof _rr === 'function' ? _rr : () => {};
|
|
1742
|
+
this._stack.push({ name, params, cleanup });
|
|
1743
|
+
this._current = name;
|
|
1744
|
+
this._view.focus();
|
|
1745
|
+
this.emit('replace', { name, params });
|
|
1746
|
+
this.emit('navigate', { name, params, action: 'replace' });
|
|
1747
|
+
return this;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/** Go to a specific depth in the stack (0 = root). */
|
|
1751
|
+
go(delta) {
|
|
1752
|
+
if (delta < 0) { for (let i=0;i<Math.abs(delta)&&this._stack.length>1;i++) this.pop(); }
|
|
1753
|
+
return this;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** Reset to root and push a new screen. */
|
|
1757
|
+
reset(name, params = {}) {
|
|
1758
|
+
while (this._stack.length > 0) { const top=this._stack.pop(); top.cleanup?.(); }
|
|
1759
|
+
this._view.clear();
|
|
1760
|
+
return this.push(name, params);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
get current() { return this._current; }
|
|
1764
|
+
get stackDepth() { return this._stack.length; }
|
|
1765
|
+
get canPop() { return this._stack.length > 1; }
|
|
1766
|
+
|
|
1767
|
+
_suspend() {
|
|
1768
|
+
// Run cleanup of current top without re-rendering previous
|
|
1769
|
+
if (this._stack.length > 0) {
|
|
1770
|
+
const top = this._stack[this._stack.length - 1];
|
|
1771
|
+
top.cleanup?.();
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1777
|
+
// §11 Terminal — top-level bootstrap object
|
|
1778
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* Terminal — boots the entire kit in one call.
|
|
1782
|
+
*
|
|
1783
|
+
* @example
|
|
1784
|
+
* const { kitdef } = require('./kitdef');
|
|
1785
|
+
* const { Terminal } = kitdef;
|
|
1786
|
+
*
|
|
1787
|
+
* const term = new Terminal({ theme: 'dark', mouse: true });
|
|
1788
|
+
* term.on('ready', () => {
|
|
1789
|
+
* const [top, main, status] = term.screen.cutWeighted([0.08, 0.84, 0.08]);
|
|
1790
|
+
* term.log.setView(main);
|
|
1791
|
+
* term.log.info('Terminal ready', { cols: term.cols, rows: term.rows });
|
|
1792
|
+
* });
|
|
1793
|
+
*/
|
|
1794
|
+
class Terminal extends EventEmitter {
|
|
1795
|
+
/**
|
|
1796
|
+
* @param {object} [opts]
|
|
1797
|
+
* @param {boolean} [opts.altScreen=true] — enter alternate screen buffer
|
|
1798
|
+
* @param {boolean} [opts.mouse=true] — enable mouse tracking
|
|
1799
|
+
* @param {string} [opts.theme='dark'] — initial TTheme
|
|
1800
|
+
* @param {string} [opts.logLevel='info'] — TLog level
|
|
1801
|
+
* @param {string} [opts.logFile] — optional log file path
|
|
1802
|
+
* @param {object} [opts.env] — extra env vars for TEnv / TShell
|
|
1803
|
+
* @param {boolean} [opts.exitOnCtrlC=true] — graceful Ctrl-C handler
|
|
1804
|
+
*/
|
|
1805
|
+
constructor(opts = {}) {
|
|
1806
|
+
super();
|
|
1807
|
+
const {
|
|
1808
|
+
altScreen = true,
|
|
1809
|
+
mouse = true,
|
|
1810
|
+
theme = 'dark',
|
|
1811
|
+
logLevel = 'info',
|
|
1812
|
+
logFile,
|
|
1813
|
+
env = {},
|
|
1814
|
+
exitOnCtrlC = true,
|
|
1815
|
+
} = opts;
|
|
1816
|
+
|
|
1817
|
+
// ── Core singletons ────────────────────────────────────────────────────
|
|
1818
|
+
this.input = inputManager;
|
|
1819
|
+
this.theme = new TTheme(theme);
|
|
1820
|
+
this.env = new TEnv({ source: { ...process.env, ...env } });
|
|
1821
|
+
this.log = new TLog({ level: logLevel, file: logFile });
|
|
1822
|
+
this.shell = new TShell({ env });
|
|
1823
|
+
|
|
1824
|
+
// ── Full-screen root view ──────────────────────────────────────────────
|
|
1825
|
+
this.screen = createFullscreen({ autoRegister: false, fps: 30 });
|
|
1826
|
+
this.input.register(this.screen);
|
|
1827
|
+
|
|
1828
|
+
// ── Router (uses root screen) ──────────────────────────────────────────
|
|
1829
|
+
this.router = new TRouter(this.screen, this.input);
|
|
1830
|
+
|
|
1831
|
+
// ── Start input ───────────────────────────────────────────────────────
|
|
1832
|
+
this.input.start({ mouse });
|
|
1833
|
+
if (altScreen) {
|
|
1834
|
+
process.stdout.write(ansi.altScreenOn + ansi.clear + ansi.cursorHome);
|
|
1835
|
+
this._altScreen = true;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// ── Terminal resize → screen resize ───────────────────────────────────
|
|
1839
|
+
this.input.on('resize', ({ rows, cols }) => {
|
|
1840
|
+
this.screen.resize(rows, cols);
|
|
1841
|
+
this.emit('resize', { rows, cols });
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
// ── Graceful exit ─────────────────────────────────────────────────────
|
|
1845
|
+
if (exitOnCtrlC) {
|
|
1846
|
+
this.input.on('sigint', () => this.exit(0));
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// Expose dimensions as getters
|
|
1850
|
+
Object.defineProperty(this, 'rows', { get: () => process.stdout.rows || 24 });
|
|
1851
|
+
Object.defineProperty(this, 'cols', { get: () => process.stdout.columns || 80 });
|
|
1852
|
+
|
|
1853
|
+
setImmediate(() => this.emit('ready', this));
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* Gracefully shut down everything and exit.
|
|
1858
|
+
* @param {number} [code=0]
|
|
1859
|
+
*/
|
|
1860
|
+
exit(code = 0) {
|
|
1861
|
+
this.emit('exit', code);
|
|
1862
|
+
this.input.disableMouse();
|
|
1863
|
+
this.input.stop();
|
|
1864
|
+
this.log.close();
|
|
1865
|
+
if (this._altScreen) process.stdout.write(ansi.altScreenOff);
|
|
1866
|
+
process.stdout.write(ansi.showCursor + '\n');
|
|
1867
|
+
// Give one tick for final writes to flush
|
|
1868
|
+
setImmediate(() => process.exit(code));
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* Create a TView pre-wired to the active theme's colors.
|
|
1873
|
+
* @param {object} opts — passed to TView constructor
|
|
1874
|
+
*/
|
|
1875
|
+
createView(opts = {}) {
|
|
1876
|
+
return new TView({
|
|
1877
|
+
defaultFg: this.theme.get('fg'),
|
|
1878
|
+
defaultBg: this.theme.get('bg'),
|
|
1879
|
+
...opts,
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* Render the whole screen (useful after layout changes).
|
|
1885
|
+
*/
|
|
1886
|
+
render() { this.screen.render(); return this; }
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1890
|
+
// §12 module.exports
|
|
1891
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1892
|
+
|
|
1893
|
+
const kitdef = {
|
|
1894
|
+
// ── tview ─────────────────────────────────────────────────────────────────
|
|
1895
|
+
TVPixel,
|
|
1896
|
+
TView,
|
|
1897
|
+
InputManager,
|
|
1898
|
+
inputManager,
|
|
1899
|
+
createFullscreen,
|
|
1900
|
+
px,
|
|
1901
|
+
ansi,
|
|
1902
|
+
BORDER_STYLES,
|
|
1903
|
+
KEY_MAP,
|
|
1904
|
+
|
|
1905
|
+
// ── tshell ────────────────────────────────────────────────────────────────
|
|
1906
|
+
TShell,
|
|
1907
|
+
|
|
1908
|
+
// ── tenv ──────────────────────────────────────────────────────────────────
|
|
1909
|
+
TEnv,
|
|
1910
|
+
|
|
1911
|
+
// ── tlog ──────────────────────────────────────────────────────────────────
|
|
1912
|
+
TLog,
|
|
1913
|
+
|
|
1914
|
+
// ── ttheme ────────────────────────────────────────────────────────────────
|
|
1915
|
+
TTheme,
|
|
1916
|
+
|
|
1917
|
+
// ── twidget ───────────────────────────────────────────────────────────────
|
|
1918
|
+
TWidget,
|
|
1919
|
+
|
|
1920
|
+
// ── trouter ───────────────────────────────────────────────────────────────
|
|
1921
|
+
TRouter,
|
|
1922
|
+
|
|
1923
|
+
// ── Terminal bootstrap ────────────────────────────────────────────────────
|
|
1924
|
+
Terminal,
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
module.exports = { kitdef };
|