ink-native 0.1.0 → 0.3.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/README.md CHANGED
@@ -127,7 +127,7 @@ window.on("close", () => process.exit(0));
127
127
 
128
128
  ## Direct Framebuffer Access
129
129
 
130
- For high-framerate graphics (emulators, games, video players), you can write pixels directly to the framebuffer while pausing the Ink event loop:
130
+ For high-framerate graphics (emulators, games, video players), you can write pixels directly to the framebuffer while pausing Ink:
131
131
 
132
132
  ```tsx
133
133
  import { createStreams, packColor } from "ink-native";
@@ -147,12 +147,25 @@ const { stdin, stdout, window, renderer } = createStreams({
147
147
 
148
148
  render(<App />, { stdin, stdout });
149
149
 
150
- // Pause Ink's event loop to take over rendering
150
+ // Pause Ink to take over rendering
151
151
  window.pause();
152
152
 
153
153
  const fb = renderer.getFramebuffer();
154
154
 
155
- // Game loop you control timing and rendering
155
+ // Keyboard events keep firing when paused
156
+ window.on("keydown", (event) => {
157
+ if (event.key === "q") {
158
+ clearInterval(gameLoop);
159
+ window.resume(); // hand control back to Ink
160
+ }
161
+ });
162
+
163
+ window.on("close", () => {
164
+ clearInterval(gameLoop);
165
+ process.exit(0);
166
+ });
167
+
168
+ // Game loop — just render, events are handled automatically
156
169
  const gameLoop = setInterval(() => {
157
170
  // Write pixels directly (0xAARRGGBB format)
158
171
  for (let y = 100; y < 200; y++) {
@@ -161,24 +174,8 @@ const gameLoop = setInterval(() => {
161
174
  }
162
175
  }
163
176
 
164
- // Present the framebuffer and poll window events
177
+ // Copy to native buffer (the event loop presents it)
165
178
  renderer.present();
166
- const { keyEvents, mod } = renderer.processEventsAndPresent();
167
-
168
- // Handle input
169
- for (const event of keyEvents) {
170
- const seq = renderer.keyEventToSequence(event, mod);
171
- if (seq === "q") {
172
- clearInterval(gameLoop);
173
- window.resume(); // hand control back to Ink
174
- }
175
- }
176
-
177
- if (renderer.shouldClose()) {
178
- clearInterval(gameLoop);
179
- window.close();
180
- process.exit(0);
181
- }
182
179
  }, 16); // ~60fps
183
180
  ```
184
181
 
@@ -211,31 +208,24 @@ const startEmulator = () => {
211
208
  window.pause();
212
209
 
213
210
  const fb = renderer.getFramebuffer();
211
+ let emuLoop: ReturnType<typeof setInterval>;
212
+
213
+ // Keyboard events keep firing when paused
214
+ window.on("keydown", (event) => {
215
+ if (event.key === "Escape") {
216
+ // Return to menu
217
+ clearInterval(emuLoop);
218
+ renderer.clear();
219
+ window.resume(); // hand control back to Ink
220
+ }
221
+ });
214
222
 
215
- const emuLoop = setInterval(() => {
223
+ emuLoop = setInterval(() => {
216
224
  // Write emulator frame directly to the framebuffer
217
225
  renderEmulatorFrame(fb.pixels, fb.width, fb.height);
218
226
 
219
- // Present and poll events
227
+ // Copy to native buffer (the event loop presents it)
220
228
  renderer.present();
221
- const { keyEvents, mod } = renderer.processEventsAndPresent();
222
-
223
- for (const event of keyEvents) {
224
- const seq = renderer.keyEventToSequence(event, mod);
225
- if (seq === "\x1b") {
226
- // Escape pressed — return to menu
227
- clearInterval(emuLoop);
228
- renderer.clear();
229
- window.resume(); // hand control back to Ink
230
- return;
231
- }
232
- }
233
-
234
- if (renderer.shouldClose()) {
235
- clearInterval(emuLoop);
236
- window.close();
237
- process.exit(0);
238
- }
239
229
  }, 16);
240
230
  };
241
231
  ```
@@ -248,9 +238,9 @@ The framebuffer is shared — Ink renders to it when active, and you write pixel
248
238
  | --------------------------- | ------------------------------------------------------- |
249
239
  | `packColor(r, g, b)` | Pack RGB values into `0xAARRGGBB` pixel format |
250
240
  | `renderer.getFramebuffer()` | Get `{ pixels, width, height }` — the live pixel buffer |
251
- | `window.pause()` | Stop Ink's event loop for manual rendering |
252
- | `window.resume()` | Restart Ink's event loop |
253
- | `window.isPaused()` | Check if the event loop is paused |
241
+ | `window.pause()` | Pause Ink (events keep firing) |
242
+ | `window.resume()` | Resume Ink |
243
+ | `window.isPaused()` | Check if Ink is paused |
254
244
 
255
245
  ## API
256
246
 
@@ -286,8 +276,9 @@ Event emitter for window lifecycle and input events.
286
276
 
287
277
  #### Events
288
278
 
279
+ - `keydown` -- Emitted when a key is pressed (with `NativeKeyboardEvent` payload)
280
+ - `keyup` -- Emitted when a key is released (with `NativeKeyboardEvent` payload)
289
281
  - `close` -- Emitted when the window is closed
290
- - `key` -- Emitted on keyboard events
291
282
  - `resize` -- Emitted when the window is resized (with `{ columns, rows }`)
292
283
  - `sigint` -- Emitted on Ctrl+C (if a listener is registered; otherwise sends SIGINT to the process)
293
284
 
@@ -299,13 +290,64 @@ Event emitter for window lifecycle and input events.
299
290
  - `clear()` -- Clear the screen
300
291
  - `close()` -- Close the window
301
292
  - `isClosed()` -- Check if the window is closed
302
- - `pause()` -- Pause the Ink event loop for manual rendering
303
- - `resume()` -- Resume the Ink event loop
304
- - `isPaused()` -- Check if the event loop is paused
293
+ - `pause()` -- Pause Ink for manual rendering (keydown/keyup/resize/close events keep firing)
294
+ - `resume()` -- Resume Ink
295
+ - `isPaused()` -- Check if Ink is paused
296
+ - `processEvents()` -- Manually poll events and present the framebuffer (for custom render loops that need explicit control)
305
297
 
306
- ## Keyboard Support
298
+ ## Keyboard Events
307
299
 
308
- The following keys are mapped to terminal sequences:
300
+ The `window` emits `keydown` and `keyup` events with a `NativeKeyboardEvent` payload:
301
+
302
+ ```typescript
303
+ import { createStreams, type NativeKeyboardEvent } from "ink-native";
304
+
305
+ const { window } = createStreams({ title: "My Game" });
306
+
307
+ window.on("keydown", (event: NativeKeyboardEvent) => {
308
+ console.log(event.key); // "a", "A", "Enter", "ArrowUp", "Shift"
309
+ console.log(event.code); // "KeyA", "Enter", "ArrowUp", "ShiftLeft"
310
+ console.log(event.ctrlKey); // true if Ctrl is held
311
+ console.log(event.type); // "keydown"
312
+ });
313
+
314
+ window.on("keyup", (event: NativeKeyboardEvent) => {
315
+ console.log(event.key, "released");
316
+ });
317
+ ```
318
+
319
+ #### `NativeKeyboardEvent`
320
+
321
+ | Property | Type | Description |
322
+ | ---------- | ------------------------ | ------------------------------------------------------- |
323
+ | `key` | `string` | The key value: `"a"`, `"A"`, `"Enter"`, `"Shift"` |
324
+ | `code` | `string` | Physical key code: `"KeyA"`, `"Enter"`, `"ShiftLeft"` |
325
+ | `ctrlKey` | `boolean` | Whether Ctrl is held |
326
+ | `shiftKey` | `boolean` | Whether Shift is held |
327
+ | `altKey` | `boolean` | Whether Alt is held |
328
+ | `metaKey` | `boolean` | Whether Meta/Command is held |
329
+ | `repeat` | `false` | Always `false` (fenster only reports transitions) |
330
+ | `type` | `"keydown" \| "keyup"` | Whether the key was pressed or released |
331
+
332
+ Modifier keys fire their own events with left/right distinction — `event.code` will be `"ShiftLeft"` or `"ShiftRight"`, while `event.key` gives the generic name `"Shift"`.
333
+
334
+ #### `isNativeKeyboardEvent(value)`
335
+
336
+ Type guard to check if a value is a `NativeKeyboardEvent`:
337
+
338
+ ```typescript
339
+ import { isNativeKeyboardEvent } from "ink-native";
340
+
341
+ window.on("keydown", (event) => {
342
+ if (isNativeKeyboardEvent(event)) {
343
+ // event is typed as NativeKeyboardEvent
344
+ }
345
+ });
346
+ ```
347
+
348
+ ### Terminal Sequences
349
+
350
+ In addition to `keydown`/`keyup` events, key presses are also mapped to terminal escape sequences and pushed to `stdin` for Ink's built-in key handling:
309
351
 
310
352
  - Arrow keys (Up, Down, Left, Right)
311
353
  - Enter, Escape, Backspace, Tab, Delete
@@ -327,6 +369,11 @@ import {
327
369
  InputStream,
328
370
  OutputStream,
329
371
 
372
+ // Keyboard events
373
+ createKeyboardEvent,
374
+ isNativeKeyboardEvent,
375
+ type NativeKeyboardEvent,
376
+
330
377
  // Renderer
331
378
  UiRenderer,
332
379
  packColor,
@@ -123867,13 +123867,66 @@ var FENSTER_KEY_HOME = 2;
123867
123867
  var FENSTER_KEY_PAGEUP = 3;
123868
123868
  var FENSTER_KEY_PAGEDOWN = 4;
123869
123869
  var FENSTER_KEY_END = 5;
123870
+ var FENSTER_KEY_INSERT = 26;
123870
123871
  var FENSTER_KEY_A = 65;
123872
+ var FENSTER_KEY_B = 66;
123873
+ var FENSTER_KEY_C = 67;
123874
+ var FENSTER_KEY_D = 68;
123875
+ var FENSTER_KEY_E = 69;
123876
+ var FENSTER_KEY_F = 70;
123877
+ var FENSTER_KEY_G = 71;
123878
+ var FENSTER_KEY_H = 72;
123879
+ var FENSTER_KEY_I = 73;
123880
+ var FENSTER_KEY_J = 74;
123881
+ var FENSTER_KEY_K = 75;
123882
+ var FENSTER_KEY_L = 76;
123883
+ var FENSTER_KEY_M = 77;
123884
+ var FENSTER_KEY_N = 78;
123885
+ var FENSTER_KEY_O = 79;
123886
+ var FENSTER_KEY_P = 80;
123887
+ var FENSTER_KEY_Q = 81;
123888
+ var FENSTER_KEY_R = 82;
123889
+ var FENSTER_KEY_S = 83;
123890
+ var FENSTER_KEY_T = 84;
123891
+ var FENSTER_KEY_U = 85;
123892
+ var FENSTER_KEY_V = 86;
123893
+ var FENSTER_KEY_W = 87;
123894
+ var FENSTER_KEY_X = 88;
123895
+ var FENSTER_KEY_Y = 89;
123871
123896
  var FENSTER_KEY_Z = 90;
123897
+ var FENSTER_KEY_0 = 48;
123898
+ var FENSTER_KEY_1 = 49;
123899
+ var FENSTER_KEY_2 = 50;
123900
+ var FENSTER_KEY_3 = 51;
123901
+ var FENSTER_KEY_4 = 52;
123902
+ var FENSTER_KEY_5 = 53;
123903
+ var FENSTER_KEY_6 = 54;
123904
+ var FENSTER_KEY_7 = 55;
123905
+ var FENSTER_KEY_8 = 56;
123906
+ var FENSTER_KEY_9 = 57;
123907
+ var FENSTER_KEY_APOSTROPHE = 39;
123908
+ var FENSTER_KEY_COMMA = 44;
123909
+ var FENSTER_KEY_MINUS = 45;
123910
+ var FENSTER_KEY_PERIOD = 46;
123911
+ var FENSTER_KEY_SLASH = 47;
123912
+ var FENSTER_KEY_SEMICOLON = 59;
123913
+ var FENSTER_KEY_EQUAL = 61;
123872
123914
  var FENSTER_KEY_BRACKET_LEFT = 91;
123873
123915
  var FENSTER_KEY_BACKSLASH = 92;
123874
123916
  var FENSTER_KEY_BRACKET_RIGHT = 93;
123917
+ var FENSTER_KEY_GRAVE = 96;
123918
+ var FENSTER_KEY_SHIFT_LEFT = 128;
123919
+ var FENSTER_KEY_SHIFT_RIGHT = 129;
123920
+ var FENSTER_KEY_CONTROL_LEFT = 130;
123921
+ var FENSTER_KEY_CONTROL_RIGHT = 131;
123922
+ var FENSTER_KEY_ALT_LEFT = 132;
123923
+ var FENSTER_KEY_ALT_RIGHT = 133;
123924
+ var FENSTER_KEY_META_LEFT = 134;
123925
+ var FENSTER_KEY_META_RIGHT = 135;
123875
123926
  var FENSTER_MOD_CTRL = 1;
123876
123927
  var FENSTER_MOD_SHIFT = 2;
123928
+ var FENSTER_MOD_ALT = 4;
123929
+ var FENSTER_MOD_META = 8;
123877
123930
  var KEYS_ARRAY_SIZE = 256;
123878
123931
  var INT32_BYTES = 4;
123879
123932
  var FENSTER_LIB_PATHS = {
@@ -123888,6 +123941,10 @@ var findFensterLibrary = () => {
123888
123941
  if (!libName) {
123889
123942
  return null;
123890
123943
  }
123944
+ try {
123945
+ return fileURLToPath(import.meta.resolve(`ink-native/${libName}`));
123946
+ } catch {
123947
+ }
123891
123948
  const currentDir = dirname(fileURLToPath(import.meta.url));
123892
123949
  const projectRoot = resolve(currentDir, "../..");
123893
123950
  return resolve(projectRoot, libName);
@@ -124134,9 +124191,249 @@ var InputStream = class extends Readable {
124134
124191
  }
124135
124192
  };
124136
124193
 
124194
+ // src/KeyboardEvent/consts.ts
124195
+ var FENSTER_TO_CODE = {
124196
+ // Navigation / control
124197
+ [FENSTER_KEY_BACKSPACE]: "Backspace",
124198
+ [FENSTER_KEY_TAB]: "Tab",
124199
+ [FENSTER_KEY_RETURN]: "Enter",
124200
+ [FENSTER_KEY_UP]: "ArrowUp",
124201
+ [FENSTER_KEY_DOWN]: "ArrowDown",
124202
+ [FENSTER_KEY_RIGHT]: "ArrowRight",
124203
+ [FENSTER_KEY_LEFT]: "ArrowLeft",
124204
+ [FENSTER_KEY_ESCAPE]: "Escape",
124205
+ [FENSTER_KEY_SPACE]: "Space",
124206
+ [FENSTER_KEY_DELETE]: "Delete",
124207
+ [FENSTER_KEY_HOME]: "Home",
124208
+ [FENSTER_KEY_PAGEUP]: "PageUp",
124209
+ [FENSTER_KEY_PAGEDOWN]: "PageDown",
124210
+ [FENSTER_KEY_END]: "End",
124211
+ [FENSTER_KEY_INSERT]: "Insert",
124212
+ // Letters (A-Z → KeyA-KeyZ)
124213
+ [FENSTER_KEY_A]: "KeyA",
124214
+ [FENSTER_KEY_B]: "KeyB",
124215
+ [FENSTER_KEY_C]: "KeyC",
124216
+ [FENSTER_KEY_D]: "KeyD",
124217
+ [FENSTER_KEY_E]: "KeyE",
124218
+ [FENSTER_KEY_F]: "KeyF",
124219
+ [FENSTER_KEY_G]: "KeyG",
124220
+ [FENSTER_KEY_H]: "KeyH",
124221
+ [FENSTER_KEY_I]: "KeyI",
124222
+ [FENSTER_KEY_J]: "KeyJ",
124223
+ [FENSTER_KEY_K]: "KeyK",
124224
+ [FENSTER_KEY_L]: "KeyL",
124225
+ [FENSTER_KEY_M]: "KeyM",
124226
+ [FENSTER_KEY_N]: "KeyN",
124227
+ [FENSTER_KEY_O]: "KeyO",
124228
+ [FENSTER_KEY_P]: "KeyP",
124229
+ [FENSTER_KEY_Q]: "KeyQ",
124230
+ [FENSTER_KEY_R]: "KeyR",
124231
+ [FENSTER_KEY_S]: "KeyS",
124232
+ [FENSTER_KEY_T]: "KeyT",
124233
+ [FENSTER_KEY_U]: "KeyU",
124234
+ [FENSTER_KEY_V]: "KeyV",
124235
+ [FENSTER_KEY_W]: "KeyW",
124236
+ [FENSTER_KEY_X]: "KeyX",
124237
+ [FENSTER_KEY_Y]: "KeyY",
124238
+ [FENSTER_KEY_Z]: "KeyZ",
124239
+ // Digits (0-9 → Digit0-Digit9)
124240
+ [FENSTER_KEY_0]: "Digit0",
124241
+ [FENSTER_KEY_1]: "Digit1",
124242
+ [FENSTER_KEY_2]: "Digit2",
124243
+ [FENSTER_KEY_3]: "Digit3",
124244
+ [FENSTER_KEY_4]: "Digit4",
124245
+ [FENSTER_KEY_5]: "Digit5",
124246
+ [FENSTER_KEY_6]: "Digit6",
124247
+ [FENSTER_KEY_7]: "Digit7",
124248
+ [FENSTER_KEY_8]: "Digit8",
124249
+ [FENSTER_KEY_9]: "Digit9",
124250
+ // Symbols
124251
+ [FENSTER_KEY_APOSTROPHE]: "Quote",
124252
+ [FENSTER_KEY_COMMA]: "Comma",
124253
+ [FENSTER_KEY_MINUS]: "Minus",
124254
+ [FENSTER_KEY_PERIOD]: "Period",
124255
+ [FENSTER_KEY_SLASH]: "Slash",
124256
+ [FENSTER_KEY_SEMICOLON]: "Semicolon",
124257
+ [FENSTER_KEY_EQUAL]: "Equal",
124258
+ [FENSTER_KEY_BRACKET_LEFT]: "BracketLeft",
124259
+ [FENSTER_KEY_BACKSLASH]: "Backslash",
124260
+ [FENSTER_KEY_BRACKET_RIGHT]: "BracketRight",
124261
+ [FENSTER_KEY_GRAVE]: "Backquote",
124262
+ // Modifier keys (left/right)
124263
+ [FENSTER_KEY_SHIFT_LEFT]: "ShiftLeft",
124264
+ [FENSTER_KEY_SHIFT_RIGHT]: "ShiftRight",
124265
+ [FENSTER_KEY_CONTROL_LEFT]: "ControlLeft",
124266
+ [FENSTER_KEY_CONTROL_RIGHT]: "ControlRight",
124267
+ [FENSTER_KEY_ALT_LEFT]: "AltLeft",
124268
+ [FENSTER_KEY_ALT_RIGHT]: "AltRight",
124269
+ [FENSTER_KEY_META_LEFT]: "MetaLeft",
124270
+ [FENSTER_KEY_META_RIGHT]: "MetaRight"
124271
+ };
124272
+ var FENSTER_TO_KEY = {
124273
+ // Navigation / control
124274
+ [FENSTER_KEY_BACKSPACE]: "Backspace",
124275
+ [FENSTER_KEY_TAB]: "Tab",
124276
+ [FENSTER_KEY_RETURN]: "Enter",
124277
+ [FENSTER_KEY_UP]: "ArrowUp",
124278
+ [FENSTER_KEY_DOWN]: "ArrowDown",
124279
+ [FENSTER_KEY_RIGHT]: "ArrowRight",
124280
+ [FENSTER_KEY_LEFT]: "ArrowLeft",
124281
+ [FENSTER_KEY_ESCAPE]: "Escape",
124282
+ [FENSTER_KEY_SPACE]: " ",
124283
+ [FENSTER_KEY_DELETE]: "Delete",
124284
+ [FENSTER_KEY_HOME]: "Home",
124285
+ [FENSTER_KEY_PAGEUP]: "PageUp",
124286
+ [FENSTER_KEY_PAGEDOWN]: "PageDown",
124287
+ [FENSTER_KEY_END]: "End",
124288
+ [FENSTER_KEY_INSERT]: "Insert",
124289
+ // Letters (A-Z → a-z lowercase)
124290
+ [FENSTER_KEY_A]: "a",
124291
+ [FENSTER_KEY_B]: "b",
124292
+ [FENSTER_KEY_C]: "c",
124293
+ [FENSTER_KEY_D]: "d",
124294
+ [FENSTER_KEY_E]: "e",
124295
+ [FENSTER_KEY_F]: "f",
124296
+ [FENSTER_KEY_G]: "g",
124297
+ [FENSTER_KEY_H]: "h",
124298
+ [FENSTER_KEY_I]: "i",
124299
+ [FENSTER_KEY_J]: "j",
124300
+ [FENSTER_KEY_K]: "k",
124301
+ [FENSTER_KEY_L]: "l",
124302
+ [FENSTER_KEY_M]: "m",
124303
+ [FENSTER_KEY_N]: "n",
124304
+ [FENSTER_KEY_O]: "o",
124305
+ [FENSTER_KEY_P]: "p",
124306
+ [FENSTER_KEY_Q]: "q",
124307
+ [FENSTER_KEY_R]: "r",
124308
+ [FENSTER_KEY_S]: "s",
124309
+ [FENSTER_KEY_T]: "t",
124310
+ [FENSTER_KEY_U]: "u",
124311
+ [FENSTER_KEY_V]: "v",
124312
+ [FENSTER_KEY_W]: "w",
124313
+ [FENSTER_KEY_X]: "x",
124314
+ [FENSTER_KEY_Y]: "y",
124315
+ [FENSTER_KEY_Z]: "z",
124316
+ // Digits (0-9)
124317
+ [FENSTER_KEY_0]: "0",
124318
+ [FENSTER_KEY_1]: "1",
124319
+ [FENSTER_KEY_2]: "2",
124320
+ [FENSTER_KEY_3]: "3",
124321
+ [FENSTER_KEY_4]: "4",
124322
+ [FENSTER_KEY_5]: "5",
124323
+ [FENSTER_KEY_6]: "6",
124324
+ [FENSTER_KEY_7]: "7",
124325
+ [FENSTER_KEY_8]: "8",
124326
+ [FENSTER_KEY_9]: "9",
124327
+ // Symbols (unshifted)
124328
+ [FENSTER_KEY_APOSTROPHE]: "'",
124329
+ [FENSTER_KEY_COMMA]: ",",
124330
+ [FENSTER_KEY_MINUS]: "-",
124331
+ [FENSTER_KEY_PERIOD]: ".",
124332
+ [FENSTER_KEY_SLASH]: "/",
124333
+ [FENSTER_KEY_SEMICOLON]: ";",
124334
+ [FENSTER_KEY_EQUAL]: "=",
124335
+ [FENSTER_KEY_BRACKET_LEFT]: "[",
124336
+ [FENSTER_KEY_BACKSLASH]: "\\",
124337
+ [FENSTER_KEY_BRACKET_RIGHT]: "]",
124338
+ [FENSTER_KEY_GRAVE]: "`",
124339
+ // Modifier keys (left/right)
124340
+ [FENSTER_KEY_SHIFT_LEFT]: "Shift",
124341
+ [FENSTER_KEY_SHIFT_RIGHT]: "Shift",
124342
+ [FENSTER_KEY_CONTROL_LEFT]: "Control",
124343
+ [FENSTER_KEY_CONTROL_RIGHT]: "Control",
124344
+ [FENSTER_KEY_ALT_LEFT]: "Alt",
124345
+ [FENSTER_KEY_ALT_RIGHT]: "Alt",
124346
+ [FENSTER_KEY_META_LEFT]: "Meta",
124347
+ [FENSTER_KEY_META_RIGHT]: "Meta"
124348
+ };
124349
+ var SHIFTED_KEY = {
124350
+ // Letters (a-z → A-Z)
124351
+ [FENSTER_KEY_A]: "A",
124352
+ [FENSTER_KEY_B]: "B",
124353
+ [FENSTER_KEY_C]: "C",
124354
+ [FENSTER_KEY_D]: "D",
124355
+ [FENSTER_KEY_E]: "E",
124356
+ [FENSTER_KEY_F]: "F",
124357
+ [FENSTER_KEY_G]: "G",
124358
+ [FENSTER_KEY_H]: "H",
124359
+ [FENSTER_KEY_I]: "I",
124360
+ [FENSTER_KEY_J]: "J",
124361
+ [FENSTER_KEY_K]: "K",
124362
+ [FENSTER_KEY_L]: "L",
124363
+ [FENSTER_KEY_M]: "M",
124364
+ [FENSTER_KEY_N]: "N",
124365
+ [FENSTER_KEY_O]: "O",
124366
+ [FENSTER_KEY_P]: "P",
124367
+ [FENSTER_KEY_Q]: "Q",
124368
+ [FENSTER_KEY_R]: "R",
124369
+ [FENSTER_KEY_S]: "S",
124370
+ [FENSTER_KEY_T]: "T",
124371
+ [FENSTER_KEY_U]: "U",
124372
+ [FENSTER_KEY_V]: "V",
124373
+ [FENSTER_KEY_W]: "W",
124374
+ [FENSTER_KEY_X]: "X",
124375
+ [FENSTER_KEY_Y]: "Y",
124376
+ [FENSTER_KEY_Z]: "Z",
124377
+ // Digits (0-9 → shifted symbols)
124378
+ [FENSTER_KEY_0]: ")",
124379
+ [FENSTER_KEY_1]: "!",
124380
+ [FENSTER_KEY_2]: "@",
124381
+ [FENSTER_KEY_3]: "#",
124382
+ [FENSTER_KEY_4]: "$",
124383
+ [FENSTER_KEY_5]: "%",
124384
+ [FENSTER_KEY_6]: "^",
124385
+ [FENSTER_KEY_7]: "&",
124386
+ [FENSTER_KEY_8]: "*",
124387
+ [FENSTER_KEY_9]: "(",
124388
+ // Symbols (shifted)
124389
+ [FENSTER_KEY_APOSTROPHE]: '"',
124390
+ [FENSTER_KEY_COMMA]: "<",
124391
+ [FENSTER_KEY_MINUS]: "_",
124392
+ [FENSTER_KEY_PERIOD]: ">",
124393
+ [FENSTER_KEY_SLASH]: "?",
124394
+ [FENSTER_KEY_SEMICOLON]: ":",
124395
+ [FENSTER_KEY_EQUAL]: "+",
124396
+ [FENSTER_KEY_BRACKET_LEFT]: "{",
124397
+ [FENSTER_KEY_BACKSLASH]: "|",
124398
+ [FENSTER_KEY_BRACKET_RIGHT]: "}",
124399
+ [FENSTER_KEY_GRAVE]: "~"
124400
+ };
124401
+
124402
+ // src/KeyboardEvent/types.ts
124403
+ import { isBoolean, isPlainObject, isString } from "remeda";
124404
+ var isNativeKeyboardEvent = (value) => {
124405
+ if (!isPlainObject(value)) {
124406
+ return false;
124407
+ }
124408
+ return isString(value["key"]) && isString(value["code"]) && isBoolean(value["ctrlKey"]) && isBoolean(value["shiftKey"]) && isBoolean(value["altKey"]) && isBoolean(value["metaKey"]) && value["repeat"] === false && (value["type"] === "keydown" || value["type"] === "keyup");
124409
+ };
124410
+
124411
+ // src/KeyboardEvent/index.ts
124412
+ var createKeyboardEvent = (event, mod) => {
124413
+ const code = FENSTER_TO_CODE[event.keyIndex];
124414
+ if (code === void 0) {
124415
+ return null;
124416
+ }
124417
+ const shiftKey = (mod & FENSTER_MOD_SHIFT) !== 0;
124418
+ const key = (shiftKey ? SHIFTED_KEY[event.keyIndex] : void 0) ?? FENSTER_TO_KEY[event.keyIndex];
124419
+ if (key === void 0) {
124420
+ return null;
124421
+ }
124422
+ return {
124423
+ key,
124424
+ code,
124425
+ ctrlKey: (mod & FENSTER_MOD_CTRL) !== 0,
124426
+ shiftKey,
124427
+ altKey: (mod & FENSTER_MOD_ALT) !== 0,
124428
+ metaKey: (mod & FENSTER_MOD_META) !== 0,
124429
+ repeat: false,
124430
+ type: event.pressed ? "keydown" : "keyup"
124431
+ };
124432
+ };
124433
+
124137
124434
  // src/OutputStream/index.ts
124138
124435
  import { Writable } from "stream";
124139
- import { isString } from "remeda";
124436
+ import { isString as isString2 } from "remeda";
124140
124437
 
124141
124438
  // src/OutputStream/consts.ts
124142
124439
  var DEFAULT_COLUMNS = 80;
@@ -124178,7 +124475,7 @@ var OutputStream = class extends Writable {
124178
124475
  */
124179
124476
  _write(chunk, _encoding, callback) {
124180
124477
  try {
124181
- const text = isString(chunk) ? chunk : chunk.toString("utf8");
124478
+ const text = isString2(chunk) ? chunk : chunk.toString("utf8");
124182
124479
  this.uiRenderer.processAnsi(text);
124183
124480
  this.uiRenderer.present();
124184
124481
  callback(null);
@@ -124233,9 +124530,53 @@ var FENSTER_REFRESH_RATE = 60;
124233
124530
  var ALPHA_MASK = 4278190080;
124234
124531
  var RED_SHIFT = 16;
124235
124532
  var GREEN_SHIFT = 8;
124236
- var CTRL_KEY_OFFSET = 96;
124533
+ var CTRL_KEY_OFFSET = 64;
124237
124534
  var ASCII_PRINTABLE_START = 32;
124238
124535
  var ASCII_PRINTABLE_END = 126;
124536
+ var SHIFTED_SYMBOLS = {
124537
+ 48: ")",
124538
+ // 0 → )
124539
+ 49: "!",
124540
+ // 1 → !
124541
+ 50: "@",
124542
+ // 2 → @
124543
+ 51: "#",
124544
+ // 3 → #
124545
+ 52: "$",
124546
+ // 4 → $
124547
+ 53: "%",
124548
+ // 5 → %
124549
+ 54: "^",
124550
+ // 6 → ^
124551
+ 55: "&",
124552
+ // 7 → &
124553
+ 56: "*",
124554
+ // 8 → *
124555
+ 57: "(",
124556
+ // 9 → (
124557
+ 45: "_",
124558
+ // - → _
124559
+ 61: "+",
124560
+ // = → +
124561
+ 91: "{",
124562
+ // [ → {
124563
+ 93: "}",
124564
+ // ] → }
124565
+ 92: "|",
124566
+ // \ → |
124567
+ 59: ":",
124568
+ // ; → :
124569
+ 39: '"',
124570
+ // ' → "
124571
+ 96: "~",
124572
+ // ` → ~
124573
+ 44: "<",
124574
+ // , → <
124575
+ 46: ">",
124576
+ // . → >
124577
+ 47: "?"
124578
+ // / → ?
124579
+ };
124239
124580
 
124240
124581
  // src/UiRenderer/index.ts
124241
124582
  var packColor = (r, g, b) => (ALPHA_MASK | r << RED_SHIFT | g << GREEN_SHIFT | b) >>> 0;
@@ -124471,6 +124812,8 @@ var UiRenderer = class {
124471
124812
  return shift ? "\x1B[Z" : " ";
124472
124813
  case FENSTER_KEY_DELETE:
124473
124814
  return "\x1B[3~";
124815
+ case FENSTER_KEY_INSERT:
124816
+ return "\x1B[2~";
124474
124817
  case FENSTER_KEY_SPACE:
124475
124818
  return ctrl ? "\0" : " ";
124476
124819
  }
@@ -124489,11 +124832,16 @@ var UiRenderer = class {
124489
124832
  }
124490
124833
  }
124491
124834
  if (key >= ASCII_PRINTABLE_START && key <= ASCII_PRINTABLE_END) {
124492
- let char = String.fromCharCode(key);
124493
- if (shift && key >= FENSTER_KEY_A && key <= FENSTER_KEY_Z) {
124494
- char = char.toUpperCase();
124835
+ if (key >= FENSTER_KEY_A && key <= FENSTER_KEY_Z) {
124836
+ return shift ? String.fromCharCode(key) : String.fromCharCode(key).toLowerCase();
124837
+ }
124838
+ if (shift) {
124839
+ const shifted = SHIFTED_SYMBOLS[key];
124840
+ if (shifted) {
124841
+ return shifted;
124842
+ }
124495
124843
  }
124496
- return char;
124844
+ return String.fromCharCode(key);
124497
124845
  }
124498
124846
  return null;
124499
124847
  }
@@ -124771,22 +125119,36 @@ var Window = class extends EventEmitter {
124771
125119
  }
124772
125120
  const { keyEvents, mod, resized } = this.renderer.processEventsAndPresent();
124773
125121
  for (const event of keyEvents) {
124774
- const sequence = this.renderer.keyEventToSequence(event, mod);
124775
- if (sequence) {
124776
- if (sequence === "") {
124777
- if (this.listenerCount("sigint") > 0) {
124778
- this.emit("sigint");
124779
- } else {
124780
- process.kill(process.pid, "SIGINT");
125122
+ const kbEvent = createKeyboardEvent(event, mod);
125123
+ if (event.pressed) {
125124
+ const sequence = this.renderer.keyEventToSequence(event, mod);
125125
+ if (sequence) {
125126
+ if (sequence === "") {
125127
+ if (this.listenerCount("sigint") > 0) {
125128
+ this.emit("sigint");
125129
+ } else {
125130
+ process.kill(process.pid, "SIGINT");
125131
+ }
125132
+ if (kbEvent) {
125133
+ this.emit("keydown", kbEvent);
125134
+ }
125135
+ continue;
124781
125136
  }
124782
- continue;
125137
+ if (!this.paused) {
125138
+ this.inputStream.pushKey(sequence);
125139
+ }
125140
+ }
125141
+ if (kbEvent) {
125142
+ this.emit("keydown", kbEvent);
124783
125143
  }
124784
- this.inputStream.pushKey(sequence);
124785
- this.emit("key", event);
125144
+ } else if (kbEvent) {
125145
+ this.emit("keyup", kbEvent);
124786
125146
  }
124787
125147
  }
124788
125148
  if (resized) {
124789
- this.outputStream.notifyResize();
125149
+ if (!this.paused) {
125150
+ this.outputStream.notifyResize();
125151
+ }
124790
125152
  this.emit("resize", this.renderer.getDimensions());
124791
125153
  }
124792
125154
  if (this.renderer.shouldClose()) {
@@ -124829,30 +125191,35 @@ var Window = class extends EventEmitter {
124829
125191
  return this.closed;
124830
125192
  }
124831
125193
  /**
124832
- * Pause the Ink event loop so the caller can take over rendering.
125194
+ * Process pending events and present the framebuffer.
124833
125195
  *
124834
- * While paused, call `renderer.processEventsAndPresent()` manually
124835
- * in your own loop to poll events and present the framebuffer.
125196
+ * Call this from your own render loop when the event loop is paused.
125197
+ * Polls OS events, emits keydown/keyup, handles resize and close
125198
+ * equivalent to what the internal event loop does each iteration.
125199
+ */
125200
+ processEvents() {
125201
+ this.runEventLoopIteration();
125202
+ }
125203
+ /**
125204
+ * Pause Ink so the caller can take over rendering.
125205
+ *
125206
+ * The event loop keeps running — keydown, keyup, resize, and close
125207
+ * events continue to fire. Only Ink's input stream is paused.
124836
125208
  */
124837
125209
  pause() {
124838
125210
  if (this.paused) {
124839
125211
  return;
124840
125212
  }
124841
125213
  this.paused = true;
124842
- if (this.eventLoopHandle) {
124843
- clearInterval(this.eventLoopHandle);
124844
- this.eventLoopHandle = null;
124845
- }
124846
125214
  }
124847
125215
  /**
124848
- * Resume the Ink event loop after pausing.
125216
+ * Resume Ink after pausing.
124849
125217
  */
124850
125218
  resume() {
124851
125219
  if (!this.paused) {
124852
125220
  return;
124853
125221
  }
124854
125222
  this.paused = false;
124855
- this.startEventLoop();
124856
125223
  }
124857
125224
  /**
124858
125225
  * Check if the event loop is paused
@@ -124907,6 +125274,8 @@ export {
124907
125274
  getFenster,
124908
125275
  isFensterAvailable,
124909
125276
  InputStream,
125277
+ isNativeKeyboardEvent,
125278
+ createKeyboardEvent,
124910
125279
  OutputStream,
124911
125280
  packColor,
124912
125281
  UiRenderer,
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  createStreams
4
- } from "./chunk-3NCUBVFY.js";
4
+ } from "./chunk-FE3HR4I4.js";
5
5
 
6
6
  // src/cli.tsx
7
7
  import { parseArgs } from "util";
package/dist/index.d.ts CHANGED
@@ -324,6 +324,36 @@ declare class InputStream extends Readable {
324
324
  unref(): this;
325
325
  }
326
326
 
327
+ /** Keyboard event from the native window */
328
+ interface NativeKeyboardEvent {
329
+ /** Key value: "a", "A", "Enter", "ArrowUp", "!" */
330
+ readonly key: string;
331
+ /** Physical key code: "KeyA", "Digit1", "ArrowUp" */
332
+ readonly code: string;
333
+ readonly ctrlKey: boolean;
334
+ readonly shiftKey: boolean;
335
+ readonly altKey: boolean;
336
+ readonly metaKey: boolean;
337
+ /** Always false — fenster only reports transitions, not held state */
338
+ readonly repeat: false;
339
+ readonly type: "keydown" | "keyup";
340
+ }
341
+ declare const isNativeKeyboardEvent: (value: unknown) => value is NativeKeyboardEvent;
342
+
343
+ /**
344
+ * KeyboardEvent
345
+ *
346
+ * Converts fenster key events into NativeKeyboardEvent objects,
347
+ * providing keydown/keyup events for games and interactive apps.
348
+ */
349
+
350
+ /**
351
+ * Create a NativeKeyboardEvent from a fenster key event and modifier bitmask.
352
+ *
353
+ * Returns null for unmapped key indices.
354
+ */
355
+ declare const createKeyboardEvent: (event: FensterKeyEvent, mod: number) => NativeKeyboardEvent | null;
356
+
327
357
  /**
328
358
  * OutputStream Types
329
359
  */
@@ -659,14 +689,22 @@ declare class Window extends EventEmitter {
659
689
  */
660
690
  isClosed(): boolean;
661
691
  /**
662
- * Pause the Ink event loop so the caller can take over rendering.
692
+ * Process pending events and present the framebuffer.
693
+ *
694
+ * Call this from your own render loop when the event loop is paused.
695
+ * Polls OS events, emits keydown/keyup, handles resize and close —
696
+ * equivalent to what the internal event loop does each iteration.
697
+ */
698
+ processEvents(): void;
699
+ /**
700
+ * Pause Ink so the caller can take over rendering.
663
701
  *
664
- * While paused, call `renderer.processEventsAndPresent()` manually
665
- * in your own loop to poll events and present the framebuffer.
702
+ * The event loop keeps running — keydown, keyup, resize, and close
703
+ * events continue to fire. Only Ink's input stream is paused.
666
704
  */
667
705
  pause(): void;
668
706
  /**
669
- * Resume the Ink event loop after pausing.
707
+ * Resume Ink after pausing.
670
708
  */
671
709
  resume(): void;
672
710
  /**
@@ -711,4 +749,4 @@ declare class Window extends EventEmitter {
711
749
  */
712
750
  declare const createStreams: (options?: StreamsOptions) => Streams;
713
751
 
714
- export { AnsiParser, BitmapFontRenderer, type Color, type DrawCommand, Fenster, type FensterKeyEvent, type FensterPointer, type Framebuffer, InputStream, OutputStream, type ProcessEventsResult, type Streams, type StreamsOptions, UiRenderer, type UiRendererOptions, Window, createStreams, getFenster, isFensterAvailable, packColor };
752
+ export { AnsiParser, BitmapFontRenderer, type Color, type DrawCommand, Fenster, type FensterKeyEvent, type FensterPointer, type Framebuffer, InputStream, type NativeKeyboardEvent, OutputStream, type ProcessEventsResult, type Streams, type StreamsOptions, UiRenderer, type UiRendererOptions, Window, createKeyboardEvent, createStreams, getFenster, isFensterAvailable, isNativeKeyboardEvent, packColor };
package/dist/index.js CHANGED
@@ -6,11 +6,13 @@ import {
6
6
  OutputStream,
7
7
  UiRenderer,
8
8
  Window,
9
+ createKeyboardEvent,
9
10
  createStreams,
10
11
  getFenster,
11
12
  isFensterAvailable,
13
+ isNativeKeyboardEvent,
12
14
  packColor
13
- } from "./chunk-3NCUBVFY.js";
15
+ } from "./chunk-FE3HR4I4.js";
14
16
  export {
15
17
  AnsiParser,
16
18
  BitmapFontRenderer,
@@ -19,8 +21,10 @@ export {
19
21
  OutputStream,
20
22
  UiRenderer,
21
23
  Window,
24
+ createKeyboardEvent,
22
25
  createStreams,
23
26
  getFenster,
24
27
  isFensterAvailable,
28
+ isNativeKeyboardEvent,
25
29
  packColor
26
30
  };
Binary file
package/native/fenster.h CHANGED
@@ -172,6 +172,24 @@ FENSTER_API int fenster_loop(struct fenster *f) {
172
172
  f->mod = (mod & 0xc) | ((mod & 1) << 1) | ((mod >> 1) & 1);
173
173
  return 0;
174
174
  }
175
+ case 12: { /* NSEventTypeFlagsChanged — modifier key press/release */
176
+ NSUInteger k = msg(NSUInteger, ev, "keyCode");
177
+ NSUInteger flags = msg(NSUInteger, ev, "modifierFlags");
178
+ NSUInteger mod = flags >> 17;
179
+ f->mod = (mod & 0xc) | ((mod & 1) << 1) | ((mod >> 1) & 1);
180
+ /* Device-dependent flags distinguish left/right modifiers */
181
+ switch (k) {
182
+ case 56: f->keys[128] = !!(flags & 0x00000002); break; /* Left Shift */
183
+ case 60: f->keys[129] = !!(flags & 0x00000004); break; /* Right Shift */
184
+ case 59: f->keys[130] = !!(flags & 0x00000001); break; /* Left Control */
185
+ case 62: f->keys[131] = !!(flags & 0x00002000); break; /* Right Control */
186
+ case 58: f->keys[132] = !!(flags & 0x00000020); break; /* Left Alt */
187
+ case 61: f->keys[133] = !!(flags & 0x00000040); break; /* Right Alt */
188
+ case 55: f->keys[134] = !!(flags & 0x00000008); break; /* Left Meta */
189
+ case 54: f->keys[135] = !!(flags & 0x00000010); break; /* Right Meta */
190
+ }
191
+ return 0;
192
+ }
175
193
  }
176
194
  msg1(void, NSApp, "sendEvent:", id, ev);
177
195
  /* Poll content view frame for resize */
@@ -240,7 +258,17 @@ static LRESULT CALLBACK fenster_wndproc(HWND hwnd, UINT msg, WPARAM wParam,
240
258
  ((GetKeyState(VK_SHIFT) & 0x8000) >> 14) |
241
259
  ((GetKeyState(VK_MENU) & 0x8000) >> 13) |
242
260
  (((GetKeyState(VK_LWIN) | GetKeyState(VK_RWIN)) & 0x8000) >> 12);
243
- f->keys[FENSTER_KEYCODES[HIWORD(lParam) & 0x1ff]] = !((lParam >> 31) & 1);
261
+ int pressed = !((lParam >> 31) & 1);
262
+ f->keys[FENSTER_KEYCODES[HIWORD(lParam) & 0x1ff]] = pressed;
263
+ /* Left/right modifier key tracking */
264
+ f->keys[128] = !!(GetKeyState(VK_LSHIFT) & 0x8000);
265
+ f->keys[129] = !!(GetKeyState(VK_RSHIFT) & 0x8000);
266
+ f->keys[130] = !!(GetKeyState(VK_LCONTROL) & 0x8000);
267
+ f->keys[131] = !!(GetKeyState(VK_RCONTROL) & 0x8000);
268
+ f->keys[132] = !!(GetKeyState(VK_LMENU) & 0x8000);
269
+ f->keys[133] = !!(GetKeyState(VK_RMENU) & 0x8000);
270
+ f->keys[134] = !!(GetKeyState(VK_LWIN) & 0x8000);
271
+ f->keys[135] = !!(GetKeyState(VK_RWIN) & 0x8000);
244
272
  } break;
245
273
  case WM_SIZE:
246
274
  f->width = LOWORD(lParam);
@@ -320,7 +348,7 @@ FENSTER_API int fenster_loop(struct fenster *f) {
320
348
  }
321
349
  #else
322
350
  // clang-format off
323
- static int FENSTER_KEYCODES[124] = {XK_BackSpace,8,XK_Delete,127,XK_Down,18,XK_End,5,XK_Escape,27,XK_Home,2,XK_Insert,26,XK_Left,20,XK_Page_Down,4,XK_Page_Up,3,XK_Return,10,XK_Right,19,XK_Tab,9,XK_Up,17,XK_apostrophe,39,XK_backslash,92,XK_bracketleft,91,XK_bracketright,93,XK_comma,44,XK_equal,61,XK_grave,96,XK_minus,45,XK_period,46,XK_semicolon,59,XK_slash,47,XK_space,32,XK_a,65,XK_b,66,XK_c,67,XK_d,68,XK_e,69,XK_f,70,XK_g,71,XK_h,72,XK_i,73,XK_j,74,XK_k,75,XK_l,76,XK_m,77,XK_n,78,XK_o,79,XK_p,80,XK_q,81,XK_r,82,XK_s,83,XK_t,84,XK_u,85,XK_v,86,XK_w,87,XK_x,88,XK_y,89,XK_z,90,XK_0,48,XK_1,49,XK_2,50,XK_3,51,XK_4,52,XK_5,53,XK_6,54,XK_7,55,XK_8,56,XK_9,57};
351
+ static int FENSTER_KEYCODES[140] = {XK_BackSpace,8,XK_Delete,127,XK_Down,18,XK_End,5,XK_Escape,27,XK_Home,2,XK_Insert,26,XK_Left,20,XK_Page_Down,4,XK_Page_Up,3,XK_Return,10,XK_Right,19,XK_Tab,9,XK_Up,17,XK_apostrophe,39,XK_backslash,92,XK_bracketleft,91,XK_bracketright,93,XK_comma,44,XK_equal,61,XK_grave,96,XK_minus,45,XK_period,46,XK_semicolon,59,XK_slash,47,XK_space,32,XK_a,65,XK_b,66,XK_c,67,XK_d,68,XK_e,69,XK_f,70,XK_g,71,XK_h,72,XK_i,73,XK_j,74,XK_k,75,XK_l,76,XK_m,77,XK_n,78,XK_o,79,XK_p,80,XK_q,81,XK_r,82,XK_s,83,XK_t,84,XK_u,85,XK_v,86,XK_w,87,XK_x,88,XK_y,89,XK_z,90,XK_0,48,XK_1,49,XK_2,50,XK_3,51,XK_4,52,XK_5,53,XK_6,54,XK_7,55,XK_8,56,XK_9,57,XK_Shift_L,128,XK_Shift_R,129,XK_Control_L,130,XK_Control_R,131,XK_Alt_L,132,XK_Alt_R,133,XK_Super_L,134,XK_Super_R,135};
324
352
  // clang-format on
325
353
  FENSTER_API int fenster_open(struct fenster *f) {
326
354
  f->dpy = XOpenDisplay(NULL);
@@ -363,7 +391,7 @@ FENSTER_API int fenster_loop(struct fenster *f) {
363
391
  case KeyRelease: {
364
392
  int m = ev.xkey.state;
365
393
  int k = XkbKeycodeToKeysym(f->dpy, ev.xkey.keycode, 0, 0);
366
- for (unsigned int i = 0; i < 124; i += 2) {
394
+ for (unsigned int i = 0; i < 140; i += 2) {
367
395
  if (FENSTER_KEYCODES[i] == k) {
368
396
  f->keys[FENSTER_KEYCODES[i + 1]] = (ev.type == KeyPress);
369
397
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-native",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Render Ink terminal apps in a native window",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,7 +9,8 @@
9
9
  ".": {
10
10
  "import": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts"
12
- }
12
+ },
13
+ "./native/*": "./native/*"
13
14
  },
14
15
  "bin": {
15
16
  "ink-native": "./dist/cli.js"