@zakmandhro/bunti 0.1.5 → 0.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/AGENTS.md +65 -0
- package/README.md +81 -11
- package/dist/cli.d.ts +44 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +147 -0
- package/dist/cli.js.map +1 -0
- package/dist/colors.d.ts +77 -7
- package/dist/colors.d.ts.map +1 -1
- package/dist/colors.js +238 -39
- package/dist/colors.js.map +1 -1
- package/dist/components/Box.d.ts +6 -0
- package/dist/components/Box.d.ts.map +1 -1
- package/dist/components/Box.js +5 -0
- package/dist/components/Box.js.map +1 -1
- package/dist/components/Button.d.ts +12 -2
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/Button.js +35 -24
- package/dist/components/Button.js.map +1 -1
- package/dist/components/Card.d.ts +24 -2
- package/dist/components/Card.d.ts.map +1 -1
- package/dist/components/Card.js +56 -14
- package/dist/components/Card.js.map +1 -1
- package/dist/components/Header.d.ts +11 -1
- package/dist/components/Header.d.ts.map +1 -1
- package/dist/components/Header.js +12 -8
- package/dist/components/Header.js.map +1 -1
- package/dist/components/Input.d.ts +10 -1
- package/dist/components/Input.d.ts.map +1 -1
- package/dist/components/Input.js +172 -41
- package/dist/components/Input.js.map +1 -1
- package/dist/components/Link.d.ts +21 -0
- package/dist/components/Link.d.ts.map +1 -0
- package/dist/components/Link.js +29 -0
- package/dist/components/Link.js.map +1 -0
- package/dist/components/Modal.d.ts +25 -0
- package/dist/components/Modal.d.ts.map +1 -1
- package/dist/components/Modal.js +83 -24
- package/dist/components/Modal.js.map +1 -1
- package/dist/components/Progress.d.ts +27 -0
- package/dist/components/Progress.d.ts.map +1 -0
- package/dist/components/Progress.js +39 -0
- package/dist/components/Progress.js.map +1 -0
- package/dist/components/Spinner.d.ts +19 -0
- package/dist/components/Spinner.d.ts.map +1 -0
- package/dist/components/Spinner.js +27 -0
- package/dist/components/Spinner.js.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +3 -0
- package/dist/components/index.js.map +1 -1
- package/dist/data/nf-glyphs.d.ts +11 -0
- package/dist/data/nf-glyphs.d.ts.map +1 -0
- package/dist/data/nf-glyphs.js +535 -0
- package/dist/data/nf-glyphs.js.map +1 -0
- package/dist/data/nf-names.d.ts +8 -0
- package/dist/data/nf-names.d.ts.map +1 -0
- package/dist/data/nf-names.js +2 -0
- package/dist/data/nf-names.js.map +1 -0
- package/dist/demo-registry.d.ts +27 -0
- package/dist/demo-registry.d.ts.map +1 -0
- package/dist/demo-registry.js +57 -0
- package/dist/demo-registry.js.map +1 -0
- package/dist/demos/2048.ts +743 -0
- package/dist/demos/animation.ts +246 -0
- package/dist/demos/demo-layout.ts +124 -0
- package/dist/demos/engine.ts +374 -0
- package/dist/demos/interaction.ts +91 -0
- package/dist/demos/login.ts +203 -0
- package/dist/demos/mission-control.ts +558 -0
- package/dist/demos/showcase.ts +224 -0
- package/dist/detect.d.ts +92 -3
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js +218 -40
- package/dist/detect.js.map +1 -1
- package/dist/diagnostics.d.ts +73 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +225 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/dsl/context.d.ts +11 -0
- package/dist/dsl/context.d.ts.map +1 -0
- package/dist/dsl/context.js +563 -0
- package/dist/dsl/context.js.map +1 -0
- package/dist/dsl/hooks.d.ts +12 -0
- package/dist/dsl/hooks.d.ts.map +1 -0
- package/dist/dsl/hooks.js +148 -0
- package/dist/dsl/hooks.js.map +1 -0
- package/dist/dsl/interaction.d.ts +16 -0
- package/dist/dsl/interaction.d.ts.map +1 -0
- package/dist/dsl/interaction.js +101 -0
- package/dist/dsl/interaction.js.map +1 -0
- package/dist/dsl/layers.d.ts +14 -0
- package/dist/dsl/layers.d.ts.map +1 -0
- package/dist/dsl/layers.js +87 -0
- package/dist/dsl/layers.js.map +1 -0
- package/dist/dsl/motion.d.ts +10 -0
- package/dist/dsl/motion.d.ts.map +1 -0
- package/dist/dsl/motion.js +150 -0
- package/dist/dsl/motion.js.map +1 -0
- package/dist/dsl/render.d.ts +22 -0
- package/dist/dsl/render.d.ts.map +1 -0
- package/dist/dsl/render.js +69 -0
- package/dist/dsl/render.js.map +1 -0
- package/dist/dsl/types.d.ts +510 -0
- package/dist/dsl/types.d.ts.map +1 -0
- package/dist/dsl/types.js +33 -0
- package/dist/dsl/types.js.map +1 -0
- package/dist/dsl.d.ts +13 -154
- package/dist/dsl.d.ts.map +1 -1
- package/dist/dsl.js +12 -658
- package/dist/dsl.js.map +1 -1
- package/dist/easing.d.ts +41 -1
- package/dist/easing.d.ts.map +1 -1
- package/dist/easing.js +105 -2
- package/dist/easing.js.map +1 -1
- package/dist/geometry.d.ts +39 -0
- package/dist/geometry.d.ts.map +1 -1
- package/dist/geometry.js +20 -0
- package/dist/geometry.js.map +1 -1
- package/dist/icons-full.d.ts +5 -0
- package/dist/icons-full.d.ts.map +1 -0
- package/dist/icons-full.js +31 -0
- package/dist/icons-full.js.map +1 -0
- package/dist/icons.d.ts +426 -5
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +144 -15
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +93 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -7
- package/dist/index.js.map +1 -1
- package/dist/input.d.ts +177 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +533 -0
- package/dist/input.js.map +1 -0
- package/dist/layout.d.ts +111 -6
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +163 -23
- package/dist/layout.js.map +1 -1
- package/dist/render.d.ts +33 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +431 -116
- package/dist/render.js.map +1 -1
- package/dist/state.d.ts +137 -3
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +25 -0
- package/dist/state.js.map +1 -1
- package/dist/theme.d.ts +129 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +167 -0
- package/dist/theme.js.map +1 -0
- package/dist/themes/catppuccin-mocha.d.ts +19 -0
- package/dist/themes/catppuccin-mocha.d.ts.map +1 -0
- package/dist/themes/catppuccin-mocha.js +36 -0
- package/dist/themes/catppuccin-mocha.js.map +1 -0
- package/dist/themes/dracula.d.ts +21 -0
- package/dist/themes/dracula.d.ts.map +1 -0
- package/dist/themes/dracula.js +37 -0
- package/dist/themes/dracula.js.map +1 -0
- package/dist/themes/github-light.d.ts +21 -0
- package/dist/themes/github-light.d.ts.map +1 -0
- package/dist/themes/github-light.js +36 -0
- package/dist/themes/github-light.js.map +1 -0
- package/dist/themes/index.d.ts +19 -0
- package/dist/themes/index.d.ts.map +1 -0
- package/dist/themes/index.js +17 -0
- package/dist/themes/index.js.map +1 -0
- package/dist/themes/nord.d.ts +23 -0
- package/dist/themes/nord.d.ts.map +1 -0
- package/dist/themes/nord.js +38 -0
- package/dist/themes/nord.js.map +1 -0
- package/dist/themes/one-dark-pro.d.ts +21 -0
- package/dist/themes/one-dark-pro.d.ts.map +1 -0
- package/dist/themes/one-dark-pro.js +36 -0
- package/dist/themes/one-dark-pro.js.map +1 -0
- package/dist/themes/tokyo-night.d.ts +20 -0
- package/dist/themes/tokyo-night.d.ts.map +1 -0
- package/dist/themes/tokyo-night.js +37 -0
- package/dist/themes/tokyo-night.js.map +1 -0
- package/dist/utils.d.ts +1 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +1 -0
- package/dist/utils.js.map +1 -1
- package/dist/vendor/colors.d.ts +45 -0
- package/dist/vendor/colors.d.ts.map +1 -0
- package/dist/vendor/colors.js +72 -0
- package/dist/vendor/colors.js.map +1 -0
- package/llms.txt +79 -0
- package/package.json +23 -7
- package/dist/data/glyphs.d.ts +0 -5
- package/dist/data/glyphs.d.ts.map +0 -1
- package/dist/data/glyphs.js +0 -29
- package/dist/data/glyphs.js.map +0 -1
package/dist/render.js
CHANGED
|
@@ -1,7 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bunti Functional Rendering & Diffing
|
|
3
3
|
*/
|
|
4
|
+
import { hintKeyboardStdinNotTTY } from './diagnostics';
|
|
5
|
+
import { createKeyEvent, HeldKeyTracker, InputTokenizer, } from './input';
|
|
4
6
|
import { ANSI, resizeScreen } from './state';
|
|
7
|
+
/**
|
|
8
|
+
* Crash safety: every screen with an active render loop is tracked here so
|
|
9
|
+
* that process-level failure handlers can restore the terminal no matter how
|
|
10
|
+
* the process goes down (throw, unhandled rejection, exit, SIGHUP).
|
|
11
|
+
*/
|
|
12
|
+
const activeScreens = new Set();
|
|
13
|
+
/**
|
|
14
|
+
* Emits the restorative ANSI sequence for a screen (mouse/focus tracking off,
|
|
15
|
+
* reset, clear, back to main buffer, cursor visible) and disables raw mode.
|
|
16
|
+
* Idempotent: safe to call any number of times per loop session.
|
|
17
|
+
*/
|
|
18
|
+
export function restoreTerminal(state) {
|
|
19
|
+
if (state.isRestored)
|
|
20
|
+
return;
|
|
21
|
+
state.isRestored = true;
|
|
22
|
+
const options = state.options;
|
|
23
|
+
let cmd = '';
|
|
24
|
+
if (state.pointerShape === 'pointer') {
|
|
25
|
+
cmd += OSC_POINTER_DEFAULT;
|
|
26
|
+
state.pointerShape = 'default';
|
|
27
|
+
}
|
|
28
|
+
if (options.mouse)
|
|
29
|
+
cmd += ANSI.mouseDisable;
|
|
30
|
+
if (options.focus)
|
|
31
|
+
cmd += ANSI.focusDisable;
|
|
32
|
+
cmd += ANSI.reset + ANSI.clear + ANSI.home;
|
|
33
|
+
if (options.alternateBuffer)
|
|
34
|
+
cmd += ANSI.mainBuffer;
|
|
35
|
+
cmd += ANSI.showCursor;
|
|
36
|
+
cmd += ANSI.reset;
|
|
37
|
+
try {
|
|
38
|
+
process.stdout.write(cmd);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// stdout may already be gone (e.g. terminal closed on SIGHUP)
|
|
42
|
+
}
|
|
43
|
+
if (process.stdin.isTTY) {
|
|
44
|
+
try {
|
|
45
|
+
process.stdin.setRawMode(false);
|
|
46
|
+
process.stdin.pause();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// stdin may already be destroyed
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function restoreAllScreens() {
|
|
54
|
+
for (const screen of activeScreens)
|
|
55
|
+
restoreTerminal(screen);
|
|
56
|
+
}
|
|
57
|
+
function formatError(err) {
|
|
58
|
+
if (err instanceof Error)
|
|
59
|
+
return err.stack ?? `${err.name}: ${err.message}`;
|
|
60
|
+
return String(err);
|
|
61
|
+
}
|
|
62
|
+
/** Restore first, then report on the main screen, then die like the runtime would. */
|
|
63
|
+
const onUncaughtException = (err) => {
|
|
64
|
+
restoreAllScreens();
|
|
65
|
+
process.stderr.write(`${formatError(err)}\n`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
};
|
|
68
|
+
const onUnhandledRejection = (reason) => {
|
|
69
|
+
restoreAllScreens();
|
|
70
|
+
process.stderr.write(`${formatError(reason)}\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
};
|
|
73
|
+
/** 'exit' must stay synchronous: restore only, never exit() or async work. */
|
|
74
|
+
const onProcessExit = () => {
|
|
75
|
+
restoreAllScreens();
|
|
76
|
+
};
|
|
77
|
+
const onSighup = () => {
|
|
78
|
+
restoreAllScreens();
|
|
79
|
+
process.exit(129); // 128 + SIGHUP(1), matching default signal death
|
|
80
|
+
};
|
|
81
|
+
let crashHandlersInstalled = false;
|
|
82
|
+
function installCrashHandlers() {
|
|
83
|
+
if (crashHandlersInstalled)
|
|
84
|
+
return;
|
|
85
|
+
crashHandlersInstalled = true;
|
|
86
|
+
process.on('uncaughtException', onUncaughtException);
|
|
87
|
+
process.on('unhandledRejection', onUnhandledRejection);
|
|
88
|
+
process.on('exit', onProcessExit);
|
|
89
|
+
process.on('SIGHUP', onSighup);
|
|
90
|
+
}
|
|
91
|
+
function removeCrashHandlers() {
|
|
92
|
+
if (!crashHandlersInstalled)
|
|
93
|
+
return;
|
|
94
|
+
crashHandlersInstalled = false;
|
|
95
|
+
process.removeListener('uncaughtException', onUncaughtException);
|
|
96
|
+
process.removeListener('unhandledRejection', onUnhandledRejection);
|
|
97
|
+
process.removeListener('exit', onProcessExit);
|
|
98
|
+
process.removeListener('SIGHUP', onSighup);
|
|
99
|
+
}
|
|
5
100
|
export function syncScreenSize(state) {
|
|
6
101
|
const width = process.stdout.columns || 80;
|
|
7
102
|
const height = process.stdout.rows || 24;
|
|
@@ -15,18 +110,77 @@ export function isResizeSettled(state, now = Date.now()) {
|
|
|
15
110
|
return !state.isResizing || now >= (state.resizeSettlesAt ?? 0);
|
|
16
111
|
}
|
|
17
112
|
export function clearTerminalForResize(state) {
|
|
18
|
-
|
|
113
|
+
const sync = state.syncOutput !== false;
|
|
114
|
+
process.stdout.write((sync ? ANSI.syncStart : '') +
|
|
115
|
+
ANSI.clear +
|
|
116
|
+
ANSI.home +
|
|
117
|
+
(sync ? ANSI.syncEnd : ''));
|
|
19
118
|
state.needsFullRedraw = true;
|
|
20
119
|
}
|
|
120
|
+
/** Copies a back-buffer cell's fields onto its front-buffer counterpart. */
|
|
121
|
+
function syncFrontCell(front, cell) {
|
|
122
|
+
front.char = cell.char;
|
|
123
|
+
front.fg = cell.fg;
|
|
124
|
+
front.bg = cell.bg;
|
|
125
|
+
front.fgCode = cell.fgCode;
|
|
126
|
+
front.bgCode = cell.bgCode;
|
|
127
|
+
front.bold = cell.bold;
|
|
128
|
+
front.italic = cell.italic;
|
|
129
|
+
front.underline = cell.underline;
|
|
130
|
+
front.dim = cell.dim;
|
|
131
|
+
front.strike = cell.strike;
|
|
132
|
+
front.skip = cell.skip;
|
|
133
|
+
}
|
|
134
|
+
/** OSC 22 mouse-cursor shapes (xterm pointer-shape protocol). */
|
|
135
|
+
const OSC_POINTER = '\x1b]22;pointer\x1b\\';
|
|
136
|
+
const OSC_POINTER_DEFAULT = '\x1b]22;default\x1b\\';
|
|
21
137
|
/**
|
|
22
|
-
*
|
|
138
|
+
* OSC 22 pointer cursor: emits the 'pointer' shape when any hitbox
|
|
139
|
+
* registered this frame is under the mouse, and resets to 'default' on the
|
|
140
|
+
* hover-off edge. Emission is edge-triggered (once per state change, never
|
|
141
|
+
* per frame). Gated off for Apple Terminal, which does not understand
|
|
142
|
+
* OSC 22; everywhere else an unsupported sequence is harmlessly ignored.
|
|
23
143
|
*/
|
|
24
|
-
export function
|
|
25
|
-
|
|
144
|
+
export function updatePointerShape(state) {
|
|
145
|
+
if (!state.options.mouse)
|
|
146
|
+
return;
|
|
147
|
+
if (state.terminal?.app === 'apple-terminal')
|
|
148
|
+
return;
|
|
149
|
+
let hovered = false;
|
|
150
|
+
for (const box of state.hitboxes.values()) {
|
|
151
|
+
if (state.mouseX >= box.x &&
|
|
152
|
+
state.mouseX < box.x + box.width &&
|
|
153
|
+
state.mouseY >= box.y &&
|
|
154
|
+
state.mouseY < box.y + box.height) {
|
|
155
|
+
hovered = true;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const shape = hovered ? 'pointer' : 'default';
|
|
160
|
+
if (shape === (state.pointerShape ?? 'default'))
|
|
161
|
+
return;
|
|
162
|
+
state.pointerShape = shape;
|
|
163
|
+
try {
|
|
164
|
+
process.stdout.write(hovered ? OSC_POINTER : OSC_POINTER_DEFAULT);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// stdout may already be gone during teardown
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Diffs back and front buffers and assembles the ANSI update string.
|
|
172
|
+
* Mutates the front buffer and the state's SGR trackers exactly like a real
|
|
173
|
+
* flush, but performs no I/O — flush() writes the result, tests assert on it.
|
|
174
|
+
*/
|
|
175
|
+
export function renderFrame(state) {
|
|
26
176
|
let renderString = state.needsFullRedraw ? ANSI.clear + ANSI.home : '';
|
|
27
177
|
let lastFg = state.lastFg;
|
|
28
178
|
let lastBg = state.lastBg;
|
|
29
179
|
let lastBold = state.lastBold ?? false;
|
|
180
|
+
let lastItalic = state.lastItalic ?? false;
|
|
181
|
+
let lastUnderline = state.lastUnderline ?? false;
|
|
182
|
+
let lastDim = state.lastDim ?? false;
|
|
183
|
+
let lastStrike = state.lastStrike ?? false;
|
|
30
184
|
const width = state.width;
|
|
31
185
|
const height = state.height;
|
|
32
186
|
for (let y = 0; y < height; y++) {
|
|
@@ -41,6 +195,10 @@ export function flush(state) {
|
|
|
41
195
|
b.fg !== f.fg ||
|
|
42
196
|
b.bg !== f.bg ||
|
|
43
197
|
!!b.bold !== !!f.bold ||
|
|
198
|
+
!!b.italic !== !!f.italic ||
|
|
199
|
+
!!b.underline !== !!f.underline ||
|
|
200
|
+
!!b.dim !== !!f.dim ||
|
|
201
|
+
!!b.strike !== !!f.strike ||
|
|
44
202
|
!!b.skip !== !!f.skip) {
|
|
45
203
|
if (firstDirty === -1)
|
|
46
204
|
firstDirty = x;
|
|
@@ -53,39 +211,62 @@ export function flush(state) {
|
|
|
53
211
|
const idx = rowOffset + x;
|
|
54
212
|
const cell = state.backBuffer[idx];
|
|
55
213
|
const front = state.frontBuffer[idx];
|
|
56
|
-
if (cell.skip) {
|
|
57
|
-
front
|
|
58
|
-
front.fg = cell.fg;
|
|
59
|
-
front.bg = cell.bg;
|
|
60
|
-
front.fgCode = cell.fgCode;
|
|
61
|
-
front.bgCode = cell.bgCode;
|
|
62
|
-
front.bold = cell.bold;
|
|
63
|
-
front.skip = cell.skip;
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
if (cell.char === '') {
|
|
67
|
-
front.char = '';
|
|
68
|
-
front.fg = cell.fg;
|
|
69
|
-
front.bg = cell.bg;
|
|
70
|
-
front.fgCode = cell.fgCode;
|
|
71
|
-
front.bgCode = cell.bgCode;
|
|
72
|
-
front.bold = cell.bold;
|
|
73
|
-
front.skip = cell.skip;
|
|
214
|
+
if (cell.skip || cell.char === '') {
|
|
215
|
+
syncFrontCell(front, cell);
|
|
74
216
|
continue;
|
|
75
217
|
}
|
|
76
218
|
const fg = cell.fgCode;
|
|
77
219
|
const bg = cell.bgCode;
|
|
78
220
|
const bold = !!cell.bold;
|
|
79
|
-
|
|
80
|
-
|
|
221
|
+
const dim = !!cell.dim;
|
|
222
|
+
const italic = !!cell.italic;
|
|
223
|
+
const underline = !!cell.underline;
|
|
224
|
+
const strike = !!cell.strike;
|
|
225
|
+
if (bold !== lastBold || dim !== lastDim) {
|
|
226
|
+
// SGR 22 clears BOTH bold and dim, so they are emitted jointly:
|
|
227
|
+
// if either must turn off, emit 22 and re-assert the survivor.
|
|
228
|
+
if ((lastBold && !bold) || (lastDim && !dim)) {
|
|
229
|
+
renderString += '\x1b[22m';
|
|
230
|
+
if (bold)
|
|
231
|
+
renderString += '\x1b[1m';
|
|
232
|
+
if (dim)
|
|
233
|
+
renderString += '\x1b[2m';
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
if (bold && !lastBold)
|
|
237
|
+
renderString += '\x1b[1m';
|
|
238
|
+
if (dim && !lastDim)
|
|
239
|
+
renderString += '\x1b[2m';
|
|
240
|
+
}
|
|
81
241
|
lastBold = bold;
|
|
242
|
+
lastDim = dim;
|
|
243
|
+
}
|
|
244
|
+
if (italic !== lastItalic) {
|
|
245
|
+
renderString += italic ? '\x1b[3m' : '\x1b[23m';
|
|
246
|
+
lastItalic = italic;
|
|
247
|
+
}
|
|
248
|
+
if (underline !== lastUnderline) {
|
|
249
|
+
renderString += underline ? '\x1b[4m' : '\x1b[24m';
|
|
250
|
+
lastUnderline = underline;
|
|
251
|
+
}
|
|
252
|
+
if (strike !== lastStrike) {
|
|
253
|
+
renderString += strike ? '\x1b[9m' : '\x1b[29m';
|
|
254
|
+
lastStrike = strike;
|
|
82
255
|
}
|
|
83
256
|
if (fg !== lastFg || bg !== lastBg) {
|
|
84
257
|
if (fg === undefined && bg === undefined) {
|
|
85
258
|
renderString += '\x1b[0m';
|
|
86
|
-
|
|
259
|
+
// SGR 0 wipes every attribute; re-assert the active ones.
|
|
260
|
+
if (lastBold)
|
|
87
261
|
renderString += '\x1b[1m';
|
|
88
|
-
|
|
262
|
+
if (lastDim)
|
|
263
|
+
renderString += '\x1b[2m';
|
|
264
|
+
if (lastItalic)
|
|
265
|
+
renderString += '\x1b[3m';
|
|
266
|
+
if (lastUnderline)
|
|
267
|
+
renderString += '\x1b[4m';
|
|
268
|
+
if (lastStrike)
|
|
269
|
+
renderString += '\x1b[9m';
|
|
89
270
|
}
|
|
90
271
|
else {
|
|
91
272
|
// Foreground
|
|
@@ -123,38 +304,57 @@ export function flush(state) {
|
|
|
123
304
|
lastBg = bg;
|
|
124
305
|
}
|
|
125
306
|
renderString += cell.char;
|
|
126
|
-
front
|
|
127
|
-
front.fg = cell.fg;
|
|
128
|
-
front.bg = cell.bg;
|
|
129
|
-
front.fgCode = cell.fgCode;
|
|
130
|
-
front.bgCode = cell.bgCode;
|
|
131
|
-
front.bold = cell.bold;
|
|
132
|
-
front.skip = cell.skip;
|
|
307
|
+
syncFrontCell(front, cell);
|
|
133
308
|
}
|
|
134
309
|
}
|
|
135
310
|
}
|
|
311
|
+
state.needsFullRedraw = false;
|
|
312
|
+
state.lastFg = lastFg;
|
|
313
|
+
state.lastBg = lastBg;
|
|
314
|
+
state.lastBold = lastBold;
|
|
315
|
+
state.lastItalic = lastItalic;
|
|
316
|
+
state.lastUnderline = lastUnderline;
|
|
317
|
+
state.lastDim = lastDim;
|
|
318
|
+
state.lastStrike = lastStrike;
|
|
319
|
+
return renderString;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Diffs back and front buffers surgically and writes the update to stdout.
|
|
323
|
+
*/
|
|
324
|
+
export function flush(state) {
|
|
325
|
+
const renderString = renderFrame(state);
|
|
136
326
|
if (renderString) {
|
|
137
|
-
writer.
|
|
327
|
+
const writer = Bun.stdout.writer();
|
|
328
|
+
// Synchronized-output wrap (mode 2026) is gated on the detected profile:
|
|
329
|
+
// emitted unless the terminal was positively identified as lacking it
|
|
330
|
+
// (state.syncOutput === false). Absent/unknown profiles keep the wrap.
|
|
331
|
+
const sync = state.syncOutput !== false;
|
|
332
|
+
writer.write((sync ? ANSI.syncStart : '') +
|
|
138
333
|
(state.options.hideCursor ? ANSI.hideCursor : '') +
|
|
139
334
|
renderString +
|
|
140
|
-
ANSI.syncEnd);
|
|
335
|
+
(sync ? ANSI.syncEnd : ''));
|
|
141
336
|
writer.flush();
|
|
142
337
|
}
|
|
143
|
-
state.needsFullRedraw = false;
|
|
144
|
-
state.lastFg = lastFg;
|
|
145
|
-
state.lastBg = lastBg;
|
|
146
|
-
state.lastBold = lastBold;
|
|
147
338
|
}
|
|
148
339
|
/**
|
|
149
340
|
* Starts the render loop for a given screen state.
|
|
150
341
|
*/
|
|
151
342
|
export function loop(state, renderCallback) {
|
|
343
|
+
if (activeScreens.size > 0) {
|
|
344
|
+
throw new Error('A Bunti render loop is already active in this process. ' +
|
|
345
|
+
'Stop it first (requestStop or Ctrl+C) before calling render() again.');
|
|
346
|
+
}
|
|
152
347
|
const options = state.options;
|
|
348
|
+
// Dev hint: keyboard input silently does nothing on piped stdin.
|
|
349
|
+
hintKeyboardStdinNotTTY(options.keyboard === true, process.stdin.isTTY === true);
|
|
350
|
+
state.isRestored = false;
|
|
351
|
+
activeScreens.add(state);
|
|
352
|
+
installCrashHandlers();
|
|
153
353
|
if (options.alternateBuffer)
|
|
154
354
|
process.stdout.write(ANSI.alternateBuffer);
|
|
155
355
|
if (options.hideCursor)
|
|
156
356
|
process.stdout.write(ANSI.hideCursor);
|
|
157
|
-
return new Promise((resolve) => {
|
|
357
|
+
return new Promise((resolve, reject) => {
|
|
158
358
|
let stopped = false;
|
|
159
359
|
let interval = null;
|
|
160
360
|
const setupInput = () => {
|
|
@@ -172,37 +372,37 @@ export function loop(state, renderCallback) {
|
|
|
172
372
|
}
|
|
173
373
|
};
|
|
174
374
|
const inputHandler = (data) => applyInputToState(state, data, stop);
|
|
175
|
-
|
|
375
|
+
/**
|
|
376
|
+
* Tears the loop down exactly once: clears the interval, removes every
|
|
377
|
+
* listener this loop registered, restores the terminal, then settles the
|
|
378
|
+
* promise (rejecting with `failure.error` when a frame crashed).
|
|
379
|
+
*/
|
|
380
|
+
const finish = (failure) => {
|
|
176
381
|
if (stopped)
|
|
177
382
|
return;
|
|
178
383
|
stopped = true;
|
|
179
384
|
state.isStopped = true;
|
|
180
385
|
if (interval)
|
|
181
386
|
clearInterval(interval);
|
|
182
|
-
// 1.
|
|
183
|
-
|
|
184
|
-
process.stdin.setRawMode(false);
|
|
185
|
-
process.stdin.pause();
|
|
186
|
-
}
|
|
187
|
-
// 2. Remove Listeners
|
|
387
|
+
// 1. Remove Listeners
|
|
388
|
+
state.inputTokenizer?.dispose();
|
|
188
389
|
process.stdin.removeListener('data', inputHandler);
|
|
189
390
|
process.removeListener('SIGWINCH', resizeHandler);
|
|
190
391
|
process.removeListener('SIGINT', stop);
|
|
191
392
|
process.removeListener('SIGTERM', stop);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
process.stdout.write(cmd);
|
|
204
|
-
resolve();
|
|
393
|
+
activeScreens.delete(state);
|
|
394
|
+
if (activeScreens.size === 0)
|
|
395
|
+
removeCrashHandlers();
|
|
396
|
+
// 2. Restore Terminal Modes + Emit Restorative ANSI Sequence
|
|
397
|
+
restoreTerminal(state);
|
|
398
|
+
if (failure) {
|
|
399
|
+
reject(failure.error);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
resolve();
|
|
403
|
+
}
|
|
205
404
|
};
|
|
405
|
+
const stop = () => finish(null);
|
|
206
406
|
const resizeHandler = () => {
|
|
207
407
|
resizeScreen(state);
|
|
208
408
|
clearTerminalForResize(state);
|
|
@@ -229,15 +429,41 @@ export function loop(state, renderCallback) {
|
|
|
229
429
|
state.isResizing = false;
|
|
230
430
|
if (stopped || state.isStopped)
|
|
231
431
|
return;
|
|
432
|
+
drainFrameInput(state);
|
|
433
|
+
// Frame timing: loop() owns the clock. dt is clamped so a
|
|
434
|
+
// suspended process doesn't produce a giant catch-up step.
|
|
435
|
+
const now = Date.now();
|
|
436
|
+
state.dt =
|
|
437
|
+
state.lastFrameAt === undefined
|
|
438
|
+
? 0
|
|
439
|
+
: Math.min(100, now - state.lastFrameAt);
|
|
440
|
+
state.lastFrameAt = now;
|
|
441
|
+
state.frameCount = (state.frameCount ?? 0) + 1;
|
|
232
442
|
renderCallback(state);
|
|
233
443
|
if (stopped || state.isStopped)
|
|
234
444
|
return;
|
|
235
445
|
flush(state);
|
|
236
|
-
|
|
446
|
+
// OSC 22 mouse-cursor shape reacts to this frame's hitboxes.
|
|
447
|
+
updatePointerShape(state);
|
|
237
448
|
} while (tickAgain);
|
|
238
449
|
}
|
|
239
450
|
catch (err) {
|
|
240
|
-
|
|
451
|
+
// Never write errors into the alt screen and never keep looping over
|
|
452
|
+
// a broken frame. Either the app opts into continuing via onError,
|
|
453
|
+
// or we tear down (restoring the terminal) and reject the loop
|
|
454
|
+
// promise so the error surfaces once, on the main screen.
|
|
455
|
+
let shouldContinue = false;
|
|
456
|
+
if (options.onError) {
|
|
457
|
+
try {
|
|
458
|
+
shouldContinue = options.onError(err) === 'continue';
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
shouldContinue = false; // a broken error handler never saves the loop
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (!shouldContinue) {
|
|
465
|
+
finish({ error: err });
|
|
466
|
+
}
|
|
241
467
|
}
|
|
242
468
|
finally {
|
|
243
469
|
ticking = false;
|
|
@@ -269,67 +495,156 @@ export function loop(state, renderCallback) {
|
|
|
269
495
|
restartLoop();
|
|
270
496
|
});
|
|
271
497
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
498
|
+
function requestTick(state) {
|
|
499
|
+
state.requestTick?.();
|
|
500
|
+
}
|
|
501
|
+
function heldTracker(state) {
|
|
502
|
+
state.heldKeys ??= new HeldKeyTracker({
|
|
503
|
+
holdWindowMs: state.options.holdWindowMs,
|
|
504
|
+
});
|
|
505
|
+
return state.heldKeys;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Classifies a key event (press vs repeat), enqueues it, and mirrors the
|
|
509
|
+
* legacy `state.lastKey` slot. Ctrl+C keeps its stop/SIGINT behavior and
|
|
510
|
+
* never enters the queue.
|
|
511
|
+
*/
|
|
512
|
+
function enqueueKeyEvent(state, event, stop) {
|
|
513
|
+
if (event.ctrl && event.key === 'c') {
|
|
514
|
+
stop?.(); // Ctrl+C
|
|
279
515
|
return;
|
|
280
516
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
517
|
+
const classified = heldTracker(state).record(event);
|
|
518
|
+
state.keyQueue.push(classified);
|
|
519
|
+
// Back-compat: lastKey only ever carries unmodified key names, so ctrl
|
|
520
|
+
// bytes no longer leak into text consumers like Input.
|
|
521
|
+
if (!classified.ctrl && !classified.alt && classified.kind !== 'release') {
|
|
522
|
+
state.lastKey = classified.key;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* SGR mouse semantics:
|
|
527
|
+
* - wheel (bit 64): wheel_up / wheel_down key events, no press state
|
|
528
|
+
* - motion (bit 32): coordinates only (drags keep the pressed button)
|
|
529
|
+
* - press 'M': records the press origin; no click yet
|
|
530
|
+
* - release 'm': emits 'click' ONCE at the press-origin coordinates
|
|
531
|
+
*/
|
|
532
|
+
function dispatchMouse(state, token, stop) {
|
|
533
|
+
const button = token.button;
|
|
534
|
+
state.mouseX = token.x - 1;
|
|
535
|
+
state.mouseY = token.y - 1;
|
|
536
|
+
if (button & 64) {
|
|
537
|
+
// Wheel
|
|
538
|
+
state.mouseButton = button;
|
|
539
|
+
if (token.action === 'press') {
|
|
540
|
+
if (button === 64) {
|
|
541
|
+
enqueueKeyEvent(state, createKeyEvent('wheel_up', token.raw), stop);
|
|
542
|
+
}
|
|
543
|
+
else if (button === 65) {
|
|
544
|
+
enqueueKeyEvent(state, createKeyEvent('wheel_down', token.raw), stop);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
else if (button & 32) {
|
|
549
|
+
// Motion. Base 3 = no buttons held (pure move): keep press state as-is.
|
|
550
|
+
const base = button & 3;
|
|
551
|
+
if (base !== 3)
|
|
552
|
+
state.mouseButton = base;
|
|
286
553
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
state.isMouseDown = match[4] === 'M';
|
|
294
|
-
if (state.requestTick) {
|
|
295
|
-
state.requestTick();
|
|
554
|
+
else if (token.action === 'press') {
|
|
555
|
+
state.mouseButton = button;
|
|
556
|
+
state.isMouseDown = true;
|
|
557
|
+
if ((button & 3) === 0) {
|
|
558
|
+
state.mouseDownX = state.mouseX;
|
|
559
|
+
state.mouseDownY = state.mouseY;
|
|
296
560
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
state.mouseButton = button;
|
|
564
|
+
state.isMouseDown = false;
|
|
565
|
+
if ((button & 3) === 0 &&
|
|
566
|
+
state.mouseDownX !== undefined &&
|
|
567
|
+
state.mouseDownY !== undefined) {
|
|
568
|
+
state.clickX = state.mouseDownX;
|
|
569
|
+
state.clickY = state.mouseDownY;
|
|
570
|
+
enqueueKeyEvent(state, createKeyEvent('click', token.raw), stop);
|
|
304
571
|
}
|
|
305
|
-
|
|
572
|
+
state.mouseDownX = undefined;
|
|
573
|
+
state.mouseDownY = undefined;
|
|
306
574
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
575
|
+
}
|
|
576
|
+
function dispatchInputTokens(state, tokens, stop) {
|
|
577
|
+
for (const token of tokens) {
|
|
578
|
+
switch (token.type) {
|
|
579
|
+
case 'focus': {
|
|
580
|
+
state.hasFocus = token.focused;
|
|
581
|
+
state.restartLoop?.();
|
|
582
|
+
continue; // restartLoop already forces a tick
|
|
583
|
+
}
|
|
584
|
+
case 'response': {
|
|
585
|
+
state.terminalResponses.push(token.response);
|
|
586
|
+
// Bounded: consumers drain; a probe-less app must not leak.
|
|
587
|
+
if (state.terminalResponses.length > 64) {
|
|
588
|
+
state.terminalResponses.shift();
|
|
589
|
+
}
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
case 'mouse': {
|
|
593
|
+
dispatchMouse(state, token, stop);
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
case 'key': {
|
|
597
|
+
enqueueKeyEvent(state, token.event, stop);
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
requestTick(state);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Feeds a raw stdin chunk through the screen's stateful tokenizer and
|
|
606
|
+
* dispatches the resulting tokens. Partial escape sequences are carried
|
|
607
|
+
* across calls; a bare ESC flushes as 'escape' via the tokenizer timer.
|
|
608
|
+
*/
|
|
609
|
+
export function applyInputToState(state, data, stop) {
|
|
610
|
+
const input = data instanceof Buffer ? data.toString() : String(data);
|
|
611
|
+
state.inputTokenizer ??= new InputTokenizer({
|
|
612
|
+
escTimeoutMs: state.options.escTimeoutMs,
|
|
613
|
+
});
|
|
614
|
+
const tokenizer = state.inputTokenizer;
|
|
615
|
+
// Rebind each call so timer flushes see the latest stop handler.
|
|
616
|
+
tokenizer.onFlush = (tokens) => dispatchInputTokens(state, tokens, stop);
|
|
617
|
+
dispatchInputTokens(state, tokenizer.push(input), stop);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Moves queued KeyEvents into this frame's `state.keys`, appends synthetic
|
|
621
|
+
* held-key releases, and recomputes the legacy `state.lastKey` slot (first
|
|
622
|
+
* unmodified press/repeat of the frame). Runs once per render tick, before
|
|
623
|
+
* the render callback.
|
|
624
|
+
*/
|
|
625
|
+
export function drainFrameInput(state) {
|
|
626
|
+
const frame = state.keyQueue.length > 0 ? state.keyQueue.splice(0) : [];
|
|
627
|
+
if (state.heldKeys) {
|
|
628
|
+
const releases = state.heldKeys.collectReleases();
|
|
629
|
+
if (releases.length > 0)
|
|
630
|
+
frame.push(...releases);
|
|
631
|
+
}
|
|
632
|
+
state.keys = frame;
|
|
633
|
+
let lastKey;
|
|
634
|
+
let hasClick = false;
|
|
635
|
+
for (const event of frame) {
|
|
636
|
+
if (event.kind === 'release')
|
|
637
|
+
continue;
|
|
638
|
+
if (event.key === 'click')
|
|
639
|
+
hasClick = true;
|
|
640
|
+
if (lastKey === undefined && !event.ctrl && !event.alt) {
|
|
641
|
+
lastKey = event.key;
|
|
642
|
+
}
|
|
311
643
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
else if (input === '\r' || input === '\n')
|
|
317
|
-
key = 'enter';
|
|
318
|
-
else if (input === '\t')
|
|
319
|
-
key = 'tab';
|
|
320
|
-
else if (input === '\x1b[A')
|
|
321
|
-
key = 'up';
|
|
322
|
-
else if (input === '\x1b[B')
|
|
323
|
-
key = 'down';
|
|
324
|
-
else if (input === '\x1b[C')
|
|
325
|
-
key = 'right';
|
|
326
|
-
else if (input === '\x1b[D')
|
|
327
|
-
key = 'left';
|
|
328
|
-
else if (input === '\x1b')
|
|
329
|
-
key = 'escape';
|
|
330
|
-
state.lastKey = key;
|
|
331
|
-
if (state.requestTick) {
|
|
332
|
-
state.requestTick();
|
|
644
|
+
state.lastKey = lastKey;
|
|
645
|
+
if (!hasClick) {
|
|
646
|
+
state.clickX = undefined;
|
|
647
|
+
state.clickY = undefined;
|
|
333
648
|
}
|
|
334
649
|
}
|
|
335
650
|
//# sourceMappingURL=render.js.map
|