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,478 @@
1
+ import { useState, useEffect, useMemo, useCallback } from 'react';
2
+ import { Box, Text, useInput, useApp } from 'ink';
3
+ import { flatMap } from 'remeda';
4
+ import type { RomInfo } from '@/frontend/romScanner';
5
+ import type { Config } from '@/frontend/config';
6
+ import { resetConfigValue, DEFAULT_CONFIG } from '@/frontend/config';
7
+ import { useGamepadContext } from '../../GamepadContext';
8
+ import { useKittyGraphicsSupported, useNativeSupported } from '../../AppCapabilities';
9
+ import { useConfig } from '../../ConfigContext';
10
+ import { useClearTerminal } from '../../hooks/useClearTerminal';
11
+ import {
12
+ filterSettingsCategories,
13
+ allSettingsOptions,
14
+ getSettingsActions,
15
+ } from '../settingsConfig';
16
+
17
+ // Confirm reset dialog component
18
+ const ConfirmResetDialog = ({
19
+ onConfirm,
20
+ onCancel,
21
+ }: {
22
+ onConfirm: () => void;
23
+ onCancel: () => void;
24
+ }) => {
25
+ const [selectedIndex, setSelectedIndex] = useState(1); // Default to "No" (cancel)
26
+
27
+ useInput((input, key) => {
28
+ if (key.escape) {
29
+ onCancel();
30
+ return;
31
+ }
32
+
33
+ if (key.leftArrow || key.rightArrow) {
34
+ setSelectedIndex(prev => prev === 0 ? 1 : 0);
35
+ return;
36
+ }
37
+
38
+ if (key.return || input === ' ') {
39
+ if (selectedIndex === 0) {
40
+ onConfirm();
41
+ } else {
42
+ onCancel();
43
+ }
44
+ }
45
+ });
46
+
47
+ useGamepadContext({
48
+ onLeft: () => setSelectedIndex(prev => prev === 0 ? 1 : 0),
49
+ onRight: () => setSelectedIndex(prev => prev === 0 ? 1 : 0),
50
+ onConfirm: () => {
51
+ if (selectedIndex === 0) {
52
+ onConfirm();
53
+ } else {
54
+ onCancel();
55
+ }
56
+ },
57
+ onCancel: onCancel,
58
+ });
59
+
60
+ return (
61
+ <Box flexDirection="column" padding={1}>
62
+ <Box marginBottom={1}>
63
+ <Text bold color="yellow">{'\u26A0'} Reset All Settings</Text>
64
+ </Box>
65
+
66
+ <Box marginBottom={1}>
67
+ <Text color="white">Are you sure you want to reset all settings to their default values?</Text>
68
+ </Box>
69
+
70
+ <Box marginBottom={1}>
71
+ <Text color="gray">This action cannot be undone.</Text>
72
+ </Box>
73
+
74
+ <Box marginTop={1}>
75
+ <Box marginRight={2}>
76
+ <Text
77
+ backgroundColor={selectedIndex === 0 ? 'red' : undefined}
78
+ color={selectedIndex === 0 ? 'white' : 'gray'}
79
+ bold={selectedIndex === 0}
80
+ >
81
+ {' '}Yes, Reset{' '}
82
+ </Text>
83
+ </Box>
84
+ <Box>
85
+ <Text
86
+ backgroundColor={selectedIndex === 1 ? 'green' : undefined}
87
+ color={selectedIndex === 1 ? 'white' : 'gray'}
88
+ bold={selectedIndex === 1}
89
+ >
90
+ {' '}No, Cancel{' '}
91
+ </Text>
92
+ </Box>
93
+ </Box>
94
+
95
+ <Box marginTop={1}>
96
+ <Text color="gray" dimColor>
97
+ {'\u2190\u2192'}: Select {'\u23CE'}/A: Confirm ESC/B: Cancel
98
+ </Text>
99
+ </Box>
100
+ </Box>
101
+ );
102
+ };
103
+
104
+ // Settings panel component
105
+ export const SettingsPanel = ({
106
+ onClose,
107
+ lastPlayedRom,
108
+ onResumeGame,
109
+ }: {
110
+ onClose: () => void;
111
+ lastPlayedRom?: RomInfo;
112
+ onResumeGame?: () => void;
113
+ }) => {
114
+ // Get capabilities and config from context
115
+ const kittyGraphicsSupported = useKittyGraphicsSupported();
116
+ const nativeSupported = useNativeSupported();
117
+ const { config, configPath } = useConfig();
118
+ const { exit } = useApp();
119
+ const ready = useClearTerminal();
120
+ const settingsActions = useMemo(() => getSettingsActions(!!lastPlayedRom), [lastPlayedRom]);
121
+ const [localConfig, setLocalConfig] = useState<Config>({ ...config });
122
+ const [showResetConfirm, setShowResetConfirm] = useState(false);
123
+
124
+ // Filter categories and options based on post-processing mode and capabilities
125
+ // Individual effect options are only visible when mode is 'custom'
126
+ // Kitty/native window options are hidden if not supported
127
+ const { visibleCategories, visibleOptions } = useMemo(() => {
128
+ const isCustomMode = localConfig.video_postprocessing_mode === 'custom';
129
+ const isNativeMode = localConfig.video_driver === 'native';
130
+ const filtered = filterSettingsCategories(isCustomMode, isNativeMode, kittyGraphicsSupported, nativeSupported);
131
+ return {
132
+ visibleCategories: filtered,
133
+ visibleOptions: flatMap(filtered, cat => cat.options),
134
+ };
135
+ }, [localConfig.video_postprocessing_mode, localConfig.video_driver, kittyGraphicsSupported, nativeSupported]);
136
+
137
+ // Helper to find index of current value in select options (returns 0 if not found)
138
+ // Uses numeric comparison for float values to handle "1" vs "1.0" mismatch
139
+ const findOptionIndex = (options: Array<{ value: string }>, value: string): number => {
140
+ // First try exact string match
141
+ const exactIndex = options.findIndex(o => o.value === value);
142
+ if (exactIndex >= 0) {return exactIndex;}
143
+ // Fall back to numeric comparison for float values
144
+ const numValue = parseFloat(value);
145
+ if (!isNaN(numValue)) {
146
+ const numIndex = options.findIndex(o => parseFloat(o.value) === numValue);
147
+ if (numIndex >= 0) {return numIndex;}
148
+ }
149
+ return 0;
150
+ };
151
+
152
+ const totalSettingsItems = visibleOptions.length + settingsActions.length;
153
+ // Start with first action item selected (Resume Game if from game, Back to Browser if from ROM browser)
154
+ const [selectedIndex, setSelectedIndex] = useState(() => {
155
+ // Initial index at first action item
156
+ const isCustomMode = config.video_postprocessing_mode === 'custom';
157
+ const isNativeMode = config.video_driver === 'native';
158
+ return flatMap(filterSettingsCategories(isCustomMode, isNativeMode, kittyGraphicsSupported, nativeSupported), cat => cat.options).length;
159
+ });
160
+
161
+ // Clamp selected index when visible options change (e.g., switching from custom mode)
162
+ useEffect(() => {
163
+ if (selectedIndex >= totalSettingsItems) {
164
+ setSelectedIndex(Math.max(0, totalSettingsItems - 1));
165
+ }
166
+ }, [selectedIndex, totalSettingsItems]);
167
+
168
+ // Reset all settings to defaults by commenting them out in the config file
169
+ // This ensures users get updated defaults if they change in future versions
170
+ const handleResetSettings = useCallback(() => {
171
+ // Comment out each setting in the config file so defaults are used
172
+ for (const option of allSettingsOptions) {
173
+ resetConfigValue(option.id as keyof Config, configPath);
174
+ }
175
+ // Update local state with defaults
176
+ setLocalConfig({ ...DEFAULT_CONFIG });
177
+ setShowResetConfirm(false);
178
+ }, [configPath]);
179
+
180
+ useInput((input, key) => {
181
+ // Skip input when confirm dialog is shown
182
+ if (showResetConfirm) {return;}
183
+
184
+ // CTRL-C exits the app entirely
185
+ if (input === '\x03' || (key.ctrl && input === 'c')) {
186
+ exit();
187
+ return;
188
+ }
189
+
190
+ if (key.escape) {
191
+ // If we came from a game, resume it; otherwise go back to browser
192
+ if (onResumeGame) {
193
+ onResumeGame();
194
+ } else {
195
+ onClose();
196
+ }
197
+ return;
198
+ }
199
+
200
+ if (key.upArrow) {
201
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
202
+ return;
203
+ }
204
+
205
+ if (key.downArrow) {
206
+ setSelectedIndex((prev) => Math.min(totalSettingsItems - 1, prev + 1));
207
+ return;
208
+ }
209
+
210
+ // Check if we're on an action item
211
+ if (selectedIndex >= visibleOptions.length) {
212
+ const actionIndex = selectedIndex - visibleOptions.length;
213
+ const action = settingsActions[actionIndex];
214
+
215
+ if (key.return || input === ' ') {
216
+ if (action.id === 'resume' && onResumeGame) {
217
+ onResumeGame();
218
+ } else if (action.id === 'back') {
219
+ onClose();
220
+ } else if (action.id === 'reset') {
221
+ setShowResetConfirm(true);
222
+ } else if (action.id === 'exit') {
223
+ exit();
224
+ }
225
+ return;
226
+ }
227
+ return;
228
+ }
229
+
230
+ const option = visibleOptions[selectedIndex];
231
+
232
+ if (option.type === 'toggle') {
233
+ const currentValue = option.getValue(localConfig);
234
+ // Toggle with Enter/Space
235
+ if (key.return || input === ' ') {
236
+ const newConfig = { ...localConfig };
237
+ option.setValue(newConfig, !currentValue, configPath);
238
+ setLocalConfig(newConfig);
239
+ return;
240
+ }
241
+ // Left arrow = OFF (only update if currently ON)
242
+ if (key.leftArrow && currentValue) {
243
+ const newConfig = { ...localConfig };
244
+ option.setValue(newConfig, false, configPath);
245
+ setLocalConfig(newConfig);
246
+ return;
247
+ }
248
+ // Right arrow = ON (only update if currently OFF)
249
+ if (key.rightArrow && !currentValue) {
250
+ const newConfig = { ...localConfig };
251
+ option.setValue(newConfig, true, configPath);
252
+ setLocalConfig(newConfig);
253
+ return;
254
+ }
255
+ if (key.leftArrow || key.rightArrow) {return;}
256
+ }
257
+
258
+ if (option.type === 'select') {
259
+ const currentValue = option.getValue(localConfig);
260
+ const currentIndex = findOptionIndex(option.options, currentValue);
261
+
262
+ if (key.leftArrow) {
263
+ const newIndex = Math.max(0, currentIndex - 1);
264
+ if (newIndex === currentIndex) {return;}
265
+ const newConfig = { ...localConfig };
266
+ option.setValue(newConfig, option.options[newIndex].value, configPath);
267
+ setLocalConfig(newConfig);
268
+ return;
269
+ }
270
+
271
+ if (key.rightArrow) {
272
+ const newIndex = Math.min(option.options.length - 1, currentIndex + 1);
273
+ if (newIndex === currentIndex) {return;}
274
+ const newConfig = { ...localConfig };
275
+ option.setValue(newConfig, option.options[newIndex].value, configPath);
276
+ setLocalConfig(newConfig);
277
+ return;
278
+ }
279
+ }
280
+ });
281
+
282
+ // Handle gamepad input (disabled when confirm dialog is shown)
283
+ useGamepadContext({
284
+ onUp: () => setSelectedIndex((prev) => Math.max(0, prev - 1)),
285
+ onDown: () => setSelectedIndex((prev) => Math.min(totalSettingsItems - 1, prev + 1)),
286
+ onLeft: () => {
287
+ // Handle left on settings
288
+ if (selectedIndex >= visibleOptions.length) {return;}
289
+ const option = visibleOptions[selectedIndex];
290
+ if (option.type === 'toggle') {
291
+ const currentValue = option.getValue(localConfig);
292
+ if (!currentValue) {return;}
293
+ const newConfig = { ...localConfig };
294
+ option.setValue(newConfig, false, configPath);
295
+ setLocalConfig(newConfig);
296
+ } else {
297
+ // select type
298
+ const currentValue = option.getValue(localConfig);
299
+ const currentIdx = findOptionIndex(option.options, currentValue);
300
+ const newIndex = Math.max(0, currentIdx - 1);
301
+ if (newIndex === currentIdx) {return;}
302
+ const newConfig = { ...localConfig };
303
+ option.setValue(newConfig, option.options[newIndex].value, configPath);
304
+ setLocalConfig(newConfig);
305
+ }
306
+ },
307
+ onRight: () => {
308
+ // Handle right on settings
309
+ if (selectedIndex >= visibleOptions.length) {return;}
310
+ const option = visibleOptions[selectedIndex];
311
+ if (option.type === 'toggle') {
312
+ const currentValue = option.getValue(localConfig);
313
+ if (currentValue) {return;}
314
+ const newConfig = { ...localConfig };
315
+ option.setValue(newConfig, true, configPath);
316
+ setLocalConfig(newConfig);
317
+ } else {
318
+ // select type
319
+ const currentValue = option.getValue(localConfig);
320
+ const currentIdx = findOptionIndex(option.options, currentValue);
321
+ const newIndex = Math.min(option.options.length - 1, currentIdx + 1);
322
+ if (newIndex === currentIdx) {return;}
323
+ const newConfig = { ...localConfig };
324
+ option.setValue(newConfig, option.options[newIndex].value, configPath);
325
+ setLocalConfig(newConfig);
326
+ }
327
+ },
328
+ onConfirm: () => {
329
+ // A button: toggle setting or activate action
330
+ if (selectedIndex >= visibleOptions.length) {
331
+ const actionIndex = selectedIndex - visibleOptions.length;
332
+ const action = settingsActions[actionIndex];
333
+ if (action.id === 'resume' && onResumeGame) {
334
+ onResumeGame();
335
+ } else if (action.id === 'back') {
336
+ onClose();
337
+ } else if (action.id === 'reset') {
338
+ setShowResetConfirm(true);
339
+ } else if (action.id === 'exit') {
340
+ exit();
341
+ }
342
+ return;
343
+ }
344
+ const option = visibleOptions[selectedIndex];
345
+ if (option.type === 'toggle') {
346
+ const currentValue = option.getValue(localConfig);
347
+ const newConfig = { ...localConfig };
348
+ option.setValue(newConfig, !currentValue, configPath);
349
+ setLocalConfig(newConfig);
350
+ }
351
+ },
352
+ onCancel: () => {
353
+ // B button: if we came from a game, resume it; otherwise go back to browser
354
+ if (onResumeGame) {
355
+ onResumeGame();
356
+ } else {
357
+ onClose();
358
+ }
359
+ },
360
+ onGuide: () => {
361
+ // Guide button: if we came from a game, resume it; otherwise go back to browser
362
+ if (onResumeGame) {
363
+ onResumeGame();
364
+ } else {
365
+ onClose();
366
+ }
367
+ },
368
+ }); // Context handles focus automatically via stack
369
+
370
+ // Wait for terminal clear to complete before rendering
371
+ if (!ready) {
372
+ return null;
373
+ }
374
+
375
+ // Show confirm dialog if reset is requested
376
+ if (showResetConfirm) {
377
+ return (
378
+ <ConfirmResetDialog
379
+ onConfirm={handleResetSettings}
380
+ onCancel={() => setShowResetConfirm(false)}
381
+ />
382
+ );
383
+ }
384
+
385
+ // Compute starting index for each category (O(n) linear algorithm)
386
+ const categoryStartIndices: number[] = [];
387
+ let runningIndex = 0;
388
+ for (const cat of visibleCategories) {
389
+ categoryStartIndices.push(runningIndex);
390
+ runningIndex += cat.options.length;
391
+ }
392
+
393
+ return (
394
+ <Box flexDirection="column" padding={1}>
395
+ <Box marginBottom={1}>
396
+ <Text bold color="cyan">{'\u2699'} Settings</Text>
397
+ </Box>
398
+
399
+ <Box flexDirection="column" marginBottom={1}>
400
+ {visibleCategories.map((category, catIndex) => {
401
+ const startIndex = categoryStartIndices[catIndex];
402
+
403
+ return (
404
+ <Box key={category.name} flexDirection="column">
405
+ <Box marginTop={catIndex > 0 ? 1 : 0}>
406
+ <Text color="magenta" bold>{category.name}</Text>
407
+ </Box>
408
+ {category.options.map((option, optIndex) => {
409
+ const globalIndex = startIndex + optIndex;
410
+ const isSelected = globalIndex === selectedIndex;
411
+ const value = option.getValue(localConfig);
412
+
413
+ return (
414
+ <Box key={option.id}>
415
+ <Text
416
+ color={isSelected ? 'cyan' : 'white'}
417
+ bold={isSelected}
418
+ >
419
+ {isSelected ? '\u25B6 ' : ' '}
420
+ {option.label}:
421
+ </Text>
422
+ <Text> </Text>
423
+ {option.type === 'toggle' ? (
424
+ <Text color={value ? 'green' : 'red'} bold={isSelected}>
425
+ {value ? 'ON' : 'OFF'}
426
+ </Text>
427
+ ) : (
428
+ <Box>
429
+ <Text color="gray">{isSelected ? '\u25C0 ' : ' '}</Text>
430
+ <Text color="yellow" bold={isSelected}>
431
+ {option.options.find(o => o.value === value)?.label ?? value}
432
+ </Text>
433
+ <Text color="gray">{isSelected ? ' \u25B6' : ' '}</Text>
434
+ </Box>
435
+ )}
436
+ </Box>
437
+ );
438
+ })}
439
+ </Box>
440
+ );
441
+ })}
442
+
443
+ {/* Action items (Back, Exit) */}
444
+ <Box marginTop={1} flexDirection="column">
445
+ {settingsActions.map((action, actionIndex) => {
446
+ const globalIndex = visibleOptions.length + actionIndex;
447
+ const isSelected = globalIndex === selectedIndex;
448
+ const isExit = action.id === 'exit';
449
+ const color = isSelected ? (isExit ? 'red' : 'cyan') : 'gray';
450
+
451
+ return (
452
+ <Box key={action.id}>
453
+ <Text
454
+ color={color}
455
+ bold={isSelected}
456
+ >
457
+ {isSelected ? '\u25B6 ' : ' '}
458
+ {action.icon} {action.label}
459
+ </Text>
460
+ </Box>
461
+ );
462
+ })}
463
+ </Box>
464
+ </Box>
465
+
466
+ <Box marginTop={1}>
467
+ <Text color="gray" dimColor>
468
+ {'\u2191\u2193'}: Navigate {'\u2190\u2192'}/Space: Change ESC: Close
469
+ </Text>
470
+ </Box>
471
+ <Box>
472
+ <Text color="gray" dimColor>
473
+ Changes are saved automatically
474
+ </Text>
475
+ </Box>
476
+ </Box>
477
+ );
478
+ };
@@ -0,0 +1,61 @@
1
+ // =============================================================================
2
+ // Layout
3
+ // =============================================================================
4
+
5
+ /** Minimum filename display width */
6
+ export const ROM_MIN_DISPLAY_WIDTH = 20;
7
+
8
+ /** Header and footer rows in list calculation */
9
+ export const ROM_LIST_HEADER_ROWS = 4;
10
+ export const ROM_LIST_FOOTER_ROWS = 4;
11
+
12
+ /** Metadata label width for alignment */
13
+ export const ROM_META_LABEL_WIDTH = 12;
14
+
15
+ /** Panel padding calculations */
16
+ export const ROM_PANEL_BORDER_PADDING = 16;
17
+
18
+ /** Maximum separator line width */
19
+ export const ROM_SEPARATOR_MAX_WIDTH = 60;
20
+
21
+ /** Separator padding from panel width */
22
+ export const ROM_SEPARATOR_PADDING = 3;
23
+
24
+ /** Options panel rows for height calculation */
25
+ export const ROM_OPTIONS_PANEL_ROWS = 6;
26
+
27
+ /** Page navigation step multiplier */
28
+ export const ROM_PAGE_STEP_MULTIPLIER = 4;
29
+
30
+ /** Layout proportions */
31
+ export const PERCENT_60 = 0.6;
32
+ export const PERCENT_40 = 0.4;
33
+
34
+ // =============================================================================
35
+ // Timing
36
+ // =============================================================================
37
+
38
+ /** Debounce delay for loading save state details and thumbnails (ms).
39
+ * Must be long enough to prevent file I/O during rapid scrolling, which blocks
40
+ * the event loop and causes input to freeze. */
41
+ export const THUMBNAIL_LOAD_DELAY_MS = 150;
42
+
43
+ /** Debounce delay for search input filtering (ms) */
44
+ export const SEARCH_DEBOUNCE_MS = 200;
45
+
46
+ /** Maximum mouse event buffer size before truncation (bytes) */
47
+ export const MOUSE_BUFFER_MAX_SIZE = 512;
48
+
49
+ // =============================================================================
50
+ // Thumbnail Display
51
+ // =============================================================================
52
+
53
+ /** Kitty image ID for thumbnail rendering (used for cleanup) */
54
+ export const THUMBNAIL_KITTY_IMAGE_ID = 9001;
55
+
56
+ /** Thumbnail display dimensions in terminal cells
57
+ * For 4:3 aspect ratio emulator screenshots with ~2:1 terminal cell proportions,
58
+ * we use 24 cols × 9 rows which gives 24:(9×2) = 24:18 = 4:3 aspect ratio
59
+ */
60
+ export const THUMBNAIL_DISPLAY_COLS = 24;
61
+ export const THUMBNAIL_DISPLAY_ROWS = 9;