emoemu 0.1.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 (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. package/vitest.config.ts +13 -0
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Gamepad Context for Shared Input Management
3
+ *
4
+ * Provides a single GamepadManager instance shared across all UI components.
5
+ * Uses a focus stack to determine which component receives input - the most
6
+ * recently mounted component with gamepad handlers takes priority.
7
+ */
8
+
9
+ import { createContext, useContext, useEffect, useRef, useCallback, useMemo, type ReactNode } from 'react';
10
+ import { GamepadManager } from '../../input/GamepadManager';
11
+ import { StandardButton } from '../../core/button';
12
+ import {
13
+ INITIAL_DELAY_MS,
14
+ INITIAL_REPEAT_MS,
15
+ MIN_REPEAT_MS,
16
+ ACCELERATION_TIME_MS,
17
+ EASE_CUBIC_FACTOR,
18
+ EASE_CUBIC_DIVISOR,
19
+ } from './consts';
20
+
21
+ export * from './consts';
22
+
23
+ export interface GamepadCallbacks {
24
+ onUp?: () => void;
25
+ onDown?: () => void;
26
+ onLeft?: () => void;
27
+ onRight?: () => void;
28
+ onConfirm?: () => void; // A button
29
+ onCancel?: () => void; // B button
30
+ onStart?: () => void; // Start button
31
+ onGuide?: () => void; // Guide/Xbox/Home button
32
+ }
33
+
34
+ type Direction = 'up' | 'down' | 'left' | 'right';
35
+
36
+ const DIRECTION_CALLBACKS: Record<Direction, keyof Pick<GamepadCallbacks, 'onUp' | 'onDown' | 'onLeft' | 'onRight'>> = {
37
+ up: 'onUp',
38
+ down: 'onDown',
39
+ left: 'onLeft',
40
+ right: 'onRight',
41
+ };
42
+
43
+ const fireDirectionalCallback = (callbacks: GamepadCallbacks, direction: Direction): void => {
44
+ callbacks[DIRECTION_CALLBACKS[direction]]?.();
45
+ };
46
+
47
+ interface RepeatState {
48
+ direction: Direction;
49
+ startTime: number;
50
+ timeoutId: ReturnType<typeof setTimeout> | null;
51
+ }
52
+
53
+ interface GamepadContextValue {
54
+ register: (id: string, callbacks: GamepadCallbacks) => void;
55
+ unregister: (id: string) => void;
56
+ }
57
+
58
+ const GamepadContext = createContext<GamepadContextValue | null>(null);
59
+
60
+ // Counter for generating unique IDs
61
+ let idCounter = 0;
62
+ const generateId = (): string => `gamepad-handler-${++idCounter}`;
63
+
64
+ interface GamepadProviderProps {
65
+ children: ReactNode;
66
+ enabled?: boolean;
67
+ }
68
+
69
+ /**
70
+ * GamepadProvider - Wraps the app to provide shared gamepad input
71
+ *
72
+ * Maintains a stack of registered handlers. The most recently registered
73
+ * handler (top of stack) receives all input events.
74
+ */
75
+ export const GamepadProvider = ({ children, enabled = true }: GamepadProviderProps) => {
76
+ const managerRef = useRef<GamepadManager | null>(null);
77
+ const handlersRef = useRef<Map<string, GamepadCallbacks>>(new Map());
78
+ const stackRef = useRef<string[]>([]);
79
+ const repeatStateRef = useRef<RepeatState | null>(null);
80
+
81
+ // Get the currently active callbacks (top of stack)
82
+ const getActiveCallbacks = useCallback((): GamepadCallbacks | null => {
83
+ const stack = stackRef.current;
84
+ if (stack.length === 0) {return null;}
85
+ const activeId = stack[stack.length - 1];
86
+ return handlersRef.current.get(activeId) ?? null;
87
+ }, []);
88
+
89
+ // Calculate repeat interval based on hold duration
90
+ const getRepeatInterval = useCallback((heldDuration: number): number => {
91
+ if (heldDuration < INITIAL_DELAY_MS) {
92
+ return INITIAL_DELAY_MS - heldDuration;
93
+ }
94
+ const accelerationProgress = Math.min(
95
+ 1,
96
+ (heldDuration - INITIAL_DELAY_MS) / ACCELERATION_TIME_MS
97
+ );
98
+ const easedProgress = accelerationProgress * accelerationProgress *
99
+ (EASE_CUBIC_FACTOR - EASE_CUBIC_DIVISOR * accelerationProgress);
100
+ return INITIAL_REPEAT_MS - (INITIAL_REPEAT_MS - MIN_REPEAT_MS) * easedProgress;
101
+ }, []);
102
+
103
+ // Fire callback for direction and schedule next repeat
104
+ const fireAndSchedule = useCallback((direction: Direction) => {
105
+ const state = repeatStateRef.current;
106
+ if (!state || state.direction !== direction) {return;}
107
+
108
+ const cb = getActiveCallbacks();
109
+ if (cb) {
110
+ fireDirectionalCallback(cb, direction);
111
+ }
112
+
113
+ const heldDuration = Date.now() - state.startTime;
114
+ const interval = getRepeatInterval(heldDuration);
115
+ state.timeoutId = setTimeout(() => fireAndSchedule(direction), interval);
116
+ }, [getActiveCallbacks, getRepeatInterval]);
117
+
118
+ // Start repeat for a direction
119
+ const startRepeat = useCallback((direction: Direction) => {
120
+ // Cancel any existing repeat
121
+ const state = repeatStateRef.current;
122
+ if (state?.timeoutId) {
123
+ clearTimeout(state.timeoutId);
124
+ }
125
+
126
+ // Fire callback immediately
127
+ const cb = getActiveCallbacks();
128
+ if (cb) {
129
+ fireDirectionalCallback(cb, direction);
130
+ }
131
+
132
+ // Start repeat state
133
+ repeatStateRef.current = {
134
+ direction,
135
+ startTime: Date.now(),
136
+ timeoutId: setTimeout(() => fireAndSchedule(direction), INITIAL_DELAY_MS),
137
+ };
138
+ }, [getActiveCallbacks, fireAndSchedule]);
139
+
140
+ // Stop any active repeat
141
+ const stopRepeat = useCallback(() => {
142
+ const state = repeatStateRef.current;
143
+ if (state?.timeoutId) {
144
+ clearTimeout(state.timeoutId);
145
+ }
146
+ repeatStateRef.current = null;
147
+ }, []);
148
+
149
+ // Map button to direction
150
+ const buttonToDirection = useCallback((button: StandardButton): Direction | null => {
151
+ switch (button) {
152
+ case StandardButton.Up:
153
+ case StandardButton.LeftStickUp:
154
+ return 'up';
155
+ case StandardButton.Down:
156
+ case StandardButton.LeftStickDown:
157
+ return 'down';
158
+ case StandardButton.Left:
159
+ case StandardButton.LeftStickLeft:
160
+ return 'left';
161
+ case StandardButton.Right:
162
+ case StandardButton.LeftStickRight:
163
+ return 'right';
164
+ default:
165
+ return null;
166
+ }
167
+ }, []);
168
+
169
+ // Initialize GamepadManager
170
+ useEffect(() => {
171
+ if (!enabled) {return;}
172
+
173
+ const manager = new GamepadManager();
174
+ managerRef.current = manager;
175
+
176
+ manager.onButtonChange = (_port, button, pressed) => {
177
+ const direction = buttonToDirection(button);
178
+
179
+ if (direction) {
180
+ if (pressed) {
181
+ startRepeat(direction);
182
+ } else if (repeatStateRef.current?.direction === direction) {
183
+ stopRepeat();
184
+ }
185
+ } else if (pressed) {
186
+ const cb = getActiveCallbacks();
187
+ if (cb) {
188
+ switch (button) {
189
+ case StandardButton.A:
190
+ cb.onConfirm?.();
191
+ break;
192
+ case StandardButton.B:
193
+ cb.onCancel?.();
194
+ break;
195
+ case StandardButton.Start:
196
+ cb.onStart?.();
197
+ break;
198
+ case StandardButton.Guide:
199
+ cb.onGuide?.();
200
+ break;
201
+ }
202
+ }
203
+ }
204
+ };
205
+
206
+ manager.start();
207
+
208
+ return () => {
209
+ stopRepeat();
210
+ manager.stop();
211
+ managerRef.current = null;
212
+ };
213
+ }, [enabled, buttonToDirection, startRepeat, stopRepeat, getActiveCallbacks]);
214
+
215
+ // Register a new handler (pushes to top of stack)
216
+ const register = useCallback((id: string, callbacks: GamepadCallbacks) => {
217
+ handlersRef.current.set(id, callbacks);
218
+ // Remove from stack if already present, then add to top
219
+ stackRef.current = stackRef.current.filter(i => i !== id);
220
+ stackRef.current.push(id);
221
+ }, []);
222
+
223
+ // Unregister a handler (removes from stack)
224
+ const unregister = useCallback((id: string) => {
225
+ handlersRef.current.delete(id);
226
+ stackRef.current = stackRef.current.filter(i => i !== id);
227
+ // Stop repeat if the active handler was removed
228
+ stopRepeat();
229
+ }, [stopRepeat]);
230
+
231
+ // Memoize context value to prevent unnecessary effect re-runs in consumers
232
+ const contextValue = useMemo<GamepadContextValue>(() => ({
233
+ register,
234
+ unregister,
235
+ }), [register, unregister]);
236
+
237
+ return (
238
+ <GamepadContext.Provider value={contextValue}>
239
+ {children}
240
+ </GamepadContext.Provider>
241
+ );
242
+ };
243
+
244
+ /**
245
+ * useGamepadContext - Hook for components to receive gamepad input
246
+ *
247
+ * Automatically registers on mount and unregisters on unmount.
248
+ * The most recently mounted component receives input (focus stack).
249
+ *
250
+ * @param callbacks Object with callback functions for different inputs
251
+ * @param enabled Whether this handler should be active (default: true)
252
+ */
253
+ export const useGamepadContext = (callbacks: GamepadCallbacks, enabled: boolean = true): void => {
254
+ const context = useContext(GamepadContext);
255
+ const idRef = useRef<string>(generateId());
256
+ const callbacksRef = useRef(callbacks);
257
+
258
+ // Keep callbacks ref up to date
259
+ callbacksRef.current = callbacks;
260
+
261
+ useEffect(() => {
262
+ if (!context || !enabled) {return;}
263
+
264
+ const id = idRef.current;
265
+
266
+ // Create a wrapper that always calls the latest callbacks
267
+ const wrappedCallbacks: GamepadCallbacks = {
268
+ onUp: () => callbacksRef.current.onUp?.(),
269
+ onDown: () => callbacksRef.current.onDown?.(),
270
+ onLeft: () => callbacksRef.current.onLeft?.(),
271
+ onRight: () => callbacksRef.current.onRight?.(),
272
+ onConfirm: () => callbacksRef.current.onConfirm?.(),
273
+ onCancel: () => callbacksRef.current.onCancel?.(),
274
+ onStart: () => callbacksRef.current.onStart?.(),
275
+ onGuide: () => callbacksRef.current.onGuide?.(),
276
+ };
277
+
278
+ context.register(id, wrappedCallbacks);
279
+
280
+ return () => {
281
+ context.unregister(id);
282
+ };
283
+ }, [context, enabled]);
284
+ };
285
+
286
+ /**
287
+ * Note on usage:
288
+ *
289
+ * - Components rendered within the main App tree (RomBrowser, SettingsPanel,
290
+ * ConfirmResetDialog, DirectoryInput) should use useGamepadContext.
291
+ *
292
+ * - Standalone dialogs that create their own Ink render() call (CoreSelector,
293
+ * SaveStateDialog, CorruptedStateDialog) should continue using the original
294
+ * useGamepad hook from useGamepad.ts, as they're outside the context tree.
295
+ */
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Native Dialog Utilities
3
+ *
4
+ * Renders Ink dialogs in native window mode using the shared NativeWindowManager
5
+ * streams. In terminal mode, renders to the standard terminal.
6
+ */
7
+ import { render, type Instance } from 'ink';
8
+ import type { ReactNode } from 'react';
9
+ import { getWindowManager, isFensterAvailable } from '../../rendering/nativeUi';
10
+ import { logger } from '../../utils/logger';
11
+ import { cleanupInkInstance } from '../../utils/terminal';
12
+
13
+ export interface DialogRenderOptions {
14
+ /** Whether to use native window mode (default: auto-detect from video driver) */
15
+ nativeMode?: boolean;
16
+ /** Dialog title (for the native window) */
17
+ title?: string;
18
+ /** Scale factor for native mode (null = auto-detect from display) */
19
+ scaleFactor?: number | null;
20
+ }
21
+
22
+ interface NativeDialogContext {
23
+ instance: Instance;
24
+ cleanup: () => void;
25
+ }
26
+
27
+ export const isNativeModeAvailable = (): boolean => {
28
+ return isFensterAvailable();
29
+ };
30
+
31
+ export const renderDialog = (
32
+ component: ReactNode,
33
+ options: DialogRenderOptions = {},
34
+ ): Promise<NativeDialogContext> => {
35
+ const useNative = options.nativeMode && isNativeModeAvailable();
36
+ if (useNative) {
37
+ return renderDialogNative(component, options);
38
+ }
39
+ const instance = render(component);
40
+ return Promise.resolve({
41
+ instance,
42
+ cleanup: () => {
43
+ // Terminal mode cleanup is handled by Ink
44
+ },
45
+ });
46
+ };
47
+
48
+ const renderDialogNative = (
49
+ component: ReactNode,
50
+ options: DialogRenderOptions,
51
+ ): Promise<NativeDialogContext> => {
52
+ return new Promise((resolve, reject) => {
53
+ try {
54
+ const windowManager = getWindowManager();
55
+ if (!windowManager.isInitialized()) {
56
+ windowManager.init({ title: options.title ?? 'emoemu', scaleFactor: options.scaleFactor });
57
+ }
58
+ windowManager.setMode('ui');
59
+
60
+ const stdin = windowManager.getStdin();
61
+ const stdout = windowManager.getStdout();
62
+ const window = windowManager.getWindow();
63
+
64
+ // Clear before rendering to avoid artifacts from the previous view.
65
+ windowManager.clearScreen();
66
+
67
+ const onClose = () => {
68
+ stdin.push('\x1b'); // Send escape to trigger exit
69
+ };
70
+ window.on('close', onClose);
71
+
72
+ logger.info('Native dialog mode enabled (shared window)', 'Native-UI');
73
+
74
+ const instance = render(component, {
75
+ exitOnCtrlC: false,
76
+ stdout: stdout as unknown as NodeJS.WriteStream,
77
+ stdin: stdin as unknown as NodeJS.ReadStream,
78
+ });
79
+
80
+ const cleanup = () => {
81
+ // Detach this dialog's close listener; DO NOT close the shared window.
82
+ window.off('close', onClose);
83
+ windowManager.getRenderer().reset();
84
+ };
85
+
86
+ resolve({ instance, cleanup });
87
+ } catch (error) {
88
+ logger.warn(`Native dialog failed: ${error}`, 'Native-UI');
89
+ reject(error);
90
+ }
91
+ });
92
+ };
93
+
94
+ export const showDialog = async (
95
+ component: ReactNode,
96
+ options: DialogRenderOptions = {},
97
+ ): Promise<Instance> => {
98
+ const { instance, cleanup } = await renderDialog(component, options);
99
+ void instance.waitUntilExit().then(() => {
100
+ cleanup();
101
+ });
102
+ return instance;
103
+ };
104
+
105
+ export const launchDialog = <T,>(
106
+ createComponent: (onChoice: (value: T) => void) => ReactNode,
107
+ defaultValue: T,
108
+ options: DialogRenderOptions = {},
109
+ ): Promise<T> => new Promise((resolve) => {
110
+ let choice = defaultValue;
111
+ const component = createComponent((value) => {
112
+ choice = value;
113
+ });
114
+ void renderDialog(component, options).then(({ instance, cleanup }) => {
115
+ void instance.waitUntilExit().then(() => {
116
+ cleanup();
117
+ cleanupInkInstance(instance, resolve, choice);
118
+ });
119
+ });
120
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Netplay Disconnected Dialog Component
3
+ *
4
+ * Shows when netplay connection is lost and offers reconnect option.
5
+ */
6
+
7
+ import { Box, Text } from 'ink';
8
+ import { DialogOptionsList } from '../DialogOptionsList';
9
+ import { DialogContainer } from '../DialogContainer';
10
+ import { useDialogNavigation } from '../hooks/useDialogNavigation';
11
+ import { launchDialog, type DialogRenderOptions } from '../NativeDialog';
12
+
13
+ export interface DisconnectInfo {
14
+ reason: string;
15
+ host?: string;
16
+ port?: number;
17
+ }
18
+
19
+ export type DisconnectChoice = 'reconnect' | 'menu' | 'exit';
20
+
21
+ interface NetplayDisconnectedDialogProps {
22
+ info: DisconnectInfo;
23
+ onChoice: (choice: DisconnectChoice) => void;
24
+ }
25
+
26
+ const NetplayDisconnectedDialog = ({ info, onChoice }: NetplayDisconnectedDialogProps) => {
27
+ const options: { label: string; choice: DisconnectChoice; color: string }[] = [
28
+ { label: 'Try to Reconnect', choice: 'reconnect', color: 'green' },
29
+ { label: 'Back to Menu', choice: 'menu', color: 'gray' },
30
+ ];
31
+
32
+ const { selectedIndex } = useDialogNavigation({
33
+ itemCount: options.length,
34
+ onSelect: (index) => onChoice(options[index].choice),
35
+ onCancel: () => onChoice('menu'),
36
+ onCtrlC: () => onChoice('exit'),
37
+ });
38
+
39
+ // Format host info if available
40
+ const hostInfo = info.host ? `${info.host}${info.port ? `:${info.port}` : ''}` : null;
41
+
42
+ return (
43
+ <DialogContainer>
44
+ {(boxWidth) => (
45
+ <>
46
+ {/* Header */}
47
+ <Box
48
+ flexDirection="column"
49
+ borderStyle="round"
50
+ borderColor="red"
51
+ paddingX={2}
52
+ paddingY={1}
53
+ width={boxWidth}
54
+ >
55
+ <Box justifyContent="center" marginBottom={1}>
56
+ <Text bold color="red">{'\u26A0'} Disconnected</Text>
57
+ </Box>
58
+
59
+ {/* Host info if available */}
60
+ {hostInfo && (
61
+ <Box marginBottom={1}>
62
+ <Text color="gray">Host: </Text>
63
+ <Text color="white">{hostInfo}</Text>
64
+ </Box>
65
+ )}
66
+
67
+ {/* Disconnect reason */}
68
+ <Box>
69
+ <Text color="gray">Reason: </Text>
70
+ <Text color="yellow">{info.reason}</Text>
71
+ </Box>
72
+ </Box>
73
+
74
+ <DialogOptionsList options={options} selectedIndex={selectedIndex} boxWidth={boxWidth} escLabel="Back" />
75
+ </>
76
+ )}
77
+ </DialogContainer>
78
+ );
79
+ };
80
+
81
+ /**
82
+ * Show the netplay disconnected dialog and get user's choice
83
+ */
84
+ export const showNetplayDisconnectedDialog = (
85
+ info: DisconnectInfo,
86
+ options: DialogRenderOptions = {}
87
+ ): Promise<DisconnectChoice> => launchDialog<DisconnectChoice>(
88
+ (onChoice) => <NetplayDisconnectedDialog info={info} onChoice={onChoice} />,
89
+ 'menu',
90
+ { ...options, title: options.title ?? 'emoemu - Disconnected' },
91
+ );
92
+
93
+ export default NetplayDisconnectedDialog;
@@ -0,0 +1,2 @@
1
+ /** Minimum width for the pause menu dialog box */
2
+ export const PAUSE_MENU_MIN_WIDTH = 40;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Netplay Pause Menu Component
3
+ *
4
+ * Shows when ESC is pressed during netplay connection/gameplay.
5
+ * Allows user to resume or disconnect from the session.
6
+ */
7
+
8
+ import { Box, Text } from 'ink';
9
+ import { DialogOptionsList } from '../DialogOptionsList';
10
+ import { DialogContainer } from '../DialogContainer';
11
+ import { useDialogNavigation } from '../hooks/useDialogNavigation';
12
+ import { launchDialog, type DialogRenderOptions } from '../NativeDialog';
13
+ import { PAUSE_MENU_MIN_WIDTH } from './consts';
14
+
15
+ export * from './consts';
16
+
17
+ export type PauseMenuChoice = 'resume' | 'disconnect';
18
+
19
+ interface NetplayPauseMenuProps {
20
+ gameName?: string;
21
+ isConnecting?: boolean;
22
+ onChoice: (choice: PauseMenuChoice) => void;
23
+ }
24
+
25
+ const NetplayPauseMenu = ({ gameName, isConnecting, onChoice }: NetplayPauseMenuProps) => {
26
+ const options: { label: string; choice: PauseMenuChoice; color: string }[] = [
27
+ { label: isConnecting ? 'Continue Connecting' : 'Resume Game', choice: 'resume', color: 'green' },
28
+ { label: 'Back to Browser', choice: 'disconnect', color: 'yellow' },
29
+ ];
30
+
31
+ const { selectedIndex } = useDialogNavigation({
32
+ itemCount: options.length,
33
+ onSelect: (index) => onChoice(options[index].choice),
34
+ onCancel: () => onChoice('resume'),
35
+ onCtrlC: () => onChoice('disconnect'),
36
+ });
37
+
38
+ return (
39
+ <DialogContainer minWidth={PAUSE_MENU_MIN_WIDTH}>
40
+ {(boxWidth) => (
41
+ <>
42
+ {/* Header */}
43
+ <Box
44
+ flexDirection="column"
45
+ borderStyle="round"
46
+ borderColor="cyan"
47
+ paddingX={2}
48
+ paddingY={1}
49
+ width={boxWidth}
50
+ >
51
+ <Box justifyContent="center" marginBottom={1}>
52
+ <Text bold color="cyan">{'\u23F8'} Paused</Text>
53
+ </Box>
54
+
55
+ {/* Game name if available */}
56
+ {gameName && (
57
+ <Box justifyContent="center">
58
+ <Text color="white">{gameName}</Text>
59
+ </Box>
60
+ )}
61
+
62
+ {/* Status */}
63
+ <Box justifyContent="center" marginTop={1}>
64
+ <Text color="gray">
65
+ {isConnecting ? 'Connecting to netplay session...' : 'Netplay session active'}
66
+ </Text>
67
+ </Box>
68
+ </Box>
69
+
70
+ <DialogOptionsList options={options} selectedIndex={selectedIndex} boxWidth={boxWidth} prompt={false} escLabel="Resume" />
71
+ </>
72
+ )}
73
+ </DialogContainer>
74
+ );
75
+ };
76
+
77
+ export interface PauseMenuOptions extends DialogRenderOptions {
78
+ gameName?: string;
79
+ isConnecting?: boolean;
80
+ }
81
+
82
+ /**
83
+ * Show the netplay pause menu and get user's choice
84
+ */
85
+ export const showNetplayPauseMenu = (options: PauseMenuOptions = {}): Promise<PauseMenuChoice> => launchDialog<PauseMenuChoice>(
86
+ (onChoice) => (
87
+ <NetplayPauseMenu
88
+ gameName={options.gameName}
89
+ isConnecting={options.isConnecting}
90
+ onChoice={onChoice}
91
+ />
92
+ ),
93
+ 'resume',
94
+ { nativeMode: options.nativeMode, title: options.title ?? 'emoemu - Paused' },
95
+ );
96
+
97
+ export default NetplayPauseMenu;
@@ -0,0 +1,24 @@
1
+ // Port number constants
2
+ export const PORT_MAX = 65535;
3
+ export const DECIMAL_BASE = 10;
4
+
5
+ // Input delay options for netplay
6
+ export const inputDelayOptions = [
7
+ { value: 0, label: '0 (Lowest latency)' },
8
+ { value: 1, label: '1' },
9
+ { value: 2, label: '2 (Recommended)' },
10
+ { value: 3, label: '3' },
11
+ { value: 4, label: '4' },
12
+ { value: 5, label: '5' },
13
+ { value: 6, label: '6' },
14
+ { value: 8, label: '8 (High latency)' },
15
+ ];
16
+
17
+ /** Delay before sending first discovery query (ms) */
18
+ export const DISCOVERY_INITIAL_DELAY_MS = 100;
19
+
20
+ /** Interval for sending discovery queries (ms) */
21
+ export const DISCOVERY_QUERY_INTERVAL_MS = 2000;
22
+
23
+ /** How long hosts are considered "alive" after last seen (ms) */
24
+ export const DISCOVERY_HOST_MAX_AGE_MS = 10000;