@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.
Files changed (192) hide show
  1. package/AGENTS.md +65 -0
  2. package/README.md +81 -11
  3. package/dist/cli.d.ts +44 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +147 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/colors.d.ts +77 -7
  8. package/dist/colors.d.ts.map +1 -1
  9. package/dist/colors.js +238 -39
  10. package/dist/colors.js.map +1 -1
  11. package/dist/components/Box.d.ts +6 -0
  12. package/dist/components/Box.d.ts.map +1 -1
  13. package/dist/components/Box.js +5 -0
  14. package/dist/components/Box.js.map +1 -1
  15. package/dist/components/Button.d.ts +12 -2
  16. package/dist/components/Button.d.ts.map +1 -1
  17. package/dist/components/Button.js +35 -24
  18. package/dist/components/Button.js.map +1 -1
  19. package/dist/components/Card.d.ts +24 -2
  20. package/dist/components/Card.d.ts.map +1 -1
  21. package/dist/components/Card.js +56 -14
  22. package/dist/components/Card.js.map +1 -1
  23. package/dist/components/Header.d.ts +11 -1
  24. package/dist/components/Header.d.ts.map +1 -1
  25. package/dist/components/Header.js +12 -8
  26. package/dist/components/Header.js.map +1 -1
  27. package/dist/components/Input.d.ts +10 -1
  28. package/dist/components/Input.d.ts.map +1 -1
  29. package/dist/components/Input.js +172 -41
  30. package/dist/components/Input.js.map +1 -1
  31. package/dist/components/Link.d.ts +21 -0
  32. package/dist/components/Link.d.ts.map +1 -0
  33. package/dist/components/Link.js +29 -0
  34. package/dist/components/Link.js.map +1 -0
  35. package/dist/components/Modal.d.ts +25 -0
  36. package/dist/components/Modal.d.ts.map +1 -1
  37. package/dist/components/Modal.js +83 -24
  38. package/dist/components/Modal.js.map +1 -1
  39. package/dist/components/Progress.d.ts +27 -0
  40. package/dist/components/Progress.d.ts.map +1 -0
  41. package/dist/components/Progress.js +39 -0
  42. package/dist/components/Progress.js.map +1 -0
  43. package/dist/components/Spinner.d.ts +19 -0
  44. package/dist/components/Spinner.d.ts.map +1 -0
  45. package/dist/components/Spinner.js +27 -0
  46. package/dist/components/Spinner.js.map +1 -0
  47. package/dist/components/index.d.ts +3 -0
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/components/index.js +3 -0
  50. package/dist/components/index.js.map +1 -1
  51. package/dist/data/nf-glyphs.d.ts +11 -0
  52. package/dist/data/nf-glyphs.d.ts.map +1 -0
  53. package/dist/data/nf-glyphs.js +535 -0
  54. package/dist/data/nf-glyphs.js.map +1 -0
  55. package/dist/data/nf-names.d.ts +8 -0
  56. package/dist/data/nf-names.d.ts.map +1 -0
  57. package/dist/data/nf-names.js +2 -0
  58. package/dist/data/nf-names.js.map +1 -0
  59. package/dist/demo-registry.d.ts +27 -0
  60. package/dist/demo-registry.d.ts.map +1 -0
  61. package/dist/demo-registry.js +57 -0
  62. package/dist/demo-registry.js.map +1 -0
  63. package/dist/demos/2048.ts +743 -0
  64. package/dist/demos/animation.ts +246 -0
  65. package/dist/demos/demo-layout.ts +124 -0
  66. package/dist/demos/engine.ts +374 -0
  67. package/dist/demos/interaction.ts +91 -0
  68. package/dist/demos/login.ts +203 -0
  69. package/dist/demos/mission-control.ts +558 -0
  70. package/dist/demos/showcase.ts +224 -0
  71. package/dist/detect.d.ts +92 -3
  72. package/dist/detect.d.ts.map +1 -1
  73. package/dist/detect.js +218 -40
  74. package/dist/detect.js.map +1 -1
  75. package/dist/diagnostics.d.ts +73 -0
  76. package/dist/diagnostics.d.ts.map +1 -0
  77. package/dist/diagnostics.js +225 -0
  78. package/dist/diagnostics.js.map +1 -0
  79. package/dist/dsl/context.d.ts +11 -0
  80. package/dist/dsl/context.d.ts.map +1 -0
  81. package/dist/dsl/context.js +563 -0
  82. package/dist/dsl/context.js.map +1 -0
  83. package/dist/dsl/hooks.d.ts +12 -0
  84. package/dist/dsl/hooks.d.ts.map +1 -0
  85. package/dist/dsl/hooks.js +148 -0
  86. package/dist/dsl/hooks.js.map +1 -0
  87. package/dist/dsl/interaction.d.ts +16 -0
  88. package/dist/dsl/interaction.d.ts.map +1 -0
  89. package/dist/dsl/interaction.js +101 -0
  90. package/dist/dsl/interaction.js.map +1 -0
  91. package/dist/dsl/layers.d.ts +14 -0
  92. package/dist/dsl/layers.d.ts.map +1 -0
  93. package/dist/dsl/layers.js +87 -0
  94. package/dist/dsl/layers.js.map +1 -0
  95. package/dist/dsl/motion.d.ts +10 -0
  96. package/dist/dsl/motion.d.ts.map +1 -0
  97. package/dist/dsl/motion.js +150 -0
  98. package/dist/dsl/motion.js.map +1 -0
  99. package/dist/dsl/render.d.ts +22 -0
  100. package/dist/dsl/render.d.ts.map +1 -0
  101. package/dist/dsl/render.js +69 -0
  102. package/dist/dsl/render.js.map +1 -0
  103. package/dist/dsl/types.d.ts +510 -0
  104. package/dist/dsl/types.d.ts.map +1 -0
  105. package/dist/dsl/types.js +33 -0
  106. package/dist/dsl/types.js.map +1 -0
  107. package/dist/dsl.d.ts +13 -154
  108. package/dist/dsl.d.ts.map +1 -1
  109. package/dist/dsl.js +12 -658
  110. package/dist/dsl.js.map +1 -1
  111. package/dist/easing.d.ts +41 -1
  112. package/dist/easing.d.ts.map +1 -1
  113. package/dist/easing.js +105 -2
  114. package/dist/easing.js.map +1 -1
  115. package/dist/geometry.d.ts +39 -0
  116. package/dist/geometry.d.ts.map +1 -1
  117. package/dist/geometry.js +20 -0
  118. package/dist/geometry.js.map +1 -1
  119. package/dist/icons-full.d.ts +5 -0
  120. package/dist/icons-full.d.ts.map +1 -0
  121. package/dist/icons-full.js +31 -0
  122. package/dist/icons-full.js.map +1 -0
  123. package/dist/icons.d.ts +426 -5
  124. package/dist/icons.d.ts.map +1 -1
  125. package/dist/icons.js +144 -15
  126. package/dist/icons.js.map +1 -1
  127. package/dist/index.d.ts +93 -22
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +17 -7
  130. package/dist/index.js.map +1 -1
  131. package/dist/input.d.ts +177 -0
  132. package/dist/input.d.ts.map +1 -0
  133. package/dist/input.js +533 -0
  134. package/dist/input.js.map +1 -0
  135. package/dist/layout.d.ts +111 -6
  136. package/dist/layout.d.ts.map +1 -1
  137. package/dist/layout.js +163 -23
  138. package/dist/layout.js.map +1 -1
  139. package/dist/render.d.ts +33 -1
  140. package/dist/render.d.ts.map +1 -1
  141. package/dist/render.js +431 -116
  142. package/dist/render.js.map +1 -1
  143. package/dist/state.d.ts +137 -3
  144. package/dist/state.d.ts.map +1 -1
  145. package/dist/state.js +25 -0
  146. package/dist/state.js.map +1 -1
  147. package/dist/theme.d.ts +129 -0
  148. package/dist/theme.d.ts.map +1 -0
  149. package/dist/theme.js +167 -0
  150. package/dist/theme.js.map +1 -0
  151. package/dist/themes/catppuccin-mocha.d.ts +19 -0
  152. package/dist/themes/catppuccin-mocha.d.ts.map +1 -0
  153. package/dist/themes/catppuccin-mocha.js +36 -0
  154. package/dist/themes/catppuccin-mocha.js.map +1 -0
  155. package/dist/themes/dracula.d.ts +21 -0
  156. package/dist/themes/dracula.d.ts.map +1 -0
  157. package/dist/themes/dracula.js +37 -0
  158. package/dist/themes/dracula.js.map +1 -0
  159. package/dist/themes/github-light.d.ts +21 -0
  160. package/dist/themes/github-light.d.ts.map +1 -0
  161. package/dist/themes/github-light.js +36 -0
  162. package/dist/themes/github-light.js.map +1 -0
  163. package/dist/themes/index.d.ts +19 -0
  164. package/dist/themes/index.d.ts.map +1 -0
  165. package/dist/themes/index.js +17 -0
  166. package/dist/themes/index.js.map +1 -0
  167. package/dist/themes/nord.d.ts +23 -0
  168. package/dist/themes/nord.d.ts.map +1 -0
  169. package/dist/themes/nord.js +38 -0
  170. package/dist/themes/nord.js.map +1 -0
  171. package/dist/themes/one-dark-pro.d.ts +21 -0
  172. package/dist/themes/one-dark-pro.d.ts.map +1 -0
  173. package/dist/themes/one-dark-pro.js +36 -0
  174. package/dist/themes/one-dark-pro.js.map +1 -0
  175. package/dist/themes/tokyo-night.d.ts +20 -0
  176. package/dist/themes/tokyo-night.d.ts.map +1 -0
  177. package/dist/themes/tokyo-night.js +37 -0
  178. package/dist/themes/tokyo-night.js.map +1 -0
  179. package/dist/utils.d.ts +1 -0
  180. package/dist/utils.d.ts.map +1 -1
  181. package/dist/utils.js +1 -0
  182. package/dist/utils.js.map +1 -1
  183. package/dist/vendor/colors.d.ts +45 -0
  184. package/dist/vendor/colors.d.ts.map +1 -0
  185. package/dist/vendor/colors.js +72 -0
  186. package/dist/vendor/colors.js.map +1 -0
  187. package/llms.txt +79 -0
  188. package/package.json +23 -7
  189. package/dist/data/glyphs.d.ts +0 -5
  190. package/dist/data/glyphs.d.ts.map +0 -1
  191. package/dist/data/glyphs.js +0 -29
  192. 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
- process.stdout.write(ANSI.syncStart + ANSI.clear + ANSI.home + ANSI.syncEnd);
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
- * Diffs back and front buffers surgically.
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 flush(state) {
25
- const writer = Bun.stdout.writer();
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.char = cell.char;
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
- if (bold !== lastBold) {
80
- renderString += bold ? '\x1b[1m' : '\x1b[22m';
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
- if (lastBold) {
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.char = cell.char;
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.write(ANSI.syncStart +
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
- const stop = () => {
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. Restore Terminal Modes
183
- if (process.stdin.isTTY) {
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
- // 3. Emit Restorative ANSI Sequence
193
- let cmd = '';
194
- if (options.mouse)
195
- cmd += ANSI.mouseDisable;
196
- if (options.focus)
197
- cmd += ANSI.focusDisable;
198
- cmd += ANSI.reset + ANSI.clear + ANSI.home;
199
- if (options.alternateBuffer)
200
- cmd += ANSI.mainBuffer;
201
- cmd += ANSI.showCursor;
202
- cmd += ANSI.reset;
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
- state.lastKey = undefined;
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
- console.error(err);
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
- export function applyInputToState(state, data, stop) {
273
- const input = data instanceof Buffer ? data.toString() : String(data);
274
- // 1. Focus Tracking
275
- if (input === '\x1b[I') {
276
- state.hasFocus = true;
277
- if (state.restartLoop)
278
- state.restartLoop();
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
- if (input === '\x1b[O') {
282
- state.hasFocus = false;
283
- if (state.restartLoop)
284
- state.restartLoop();
285
- return;
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
- // 2. Mouse Tracking (SGR protocol)
288
- const match = input.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
289
- if (match) {
290
- state.mouseButton = parseInt(match[1], 10);
291
- state.mouseX = parseInt(match[2], 10) - 1;
292
- state.mouseY = parseInt(match[3], 10) - 1;
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
- if (match[4] === 'M') {
298
- if (state.mouseButton === 64)
299
- state.lastKey = 'wheel_up';
300
- else if (state.mouseButton === 65)
301
- state.lastKey = 'wheel_down';
302
- else if (state.mouseButton === 0)
303
- state.lastKey = 'click';
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
- return;
572
+ state.mouseDownX = undefined;
573
+ state.mouseDownY = undefined;
306
574
  }
307
- // 3. Signal Interception
308
- if (input === '\u0003') {
309
- stop?.(); // Ctrl+C
310
- return;
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
- // 4. Key Normalization
313
- let key = input;
314
- if (input === '\x7f' || input === '\x08')
315
- key = 'backspace';
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