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,2 @@
1
+ /** Core selector box minimum width */
2
+ export const CORE_SELECTOR_MIN_WIDTH = 60;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Core Selector UI Component
3
+ *
4
+ * A dialog for selecting which emulator core to use when multiple cores
5
+ * support the same ROM format.
6
+ */
7
+
8
+ import { useState, useEffect } from 'react';
9
+ import { Box, Text, useInput, useApp, useStdout } from 'ink';
10
+ import type { CoreFactory } from '../../frontend/coreRegistry';
11
+ import { useGamepad } from '../hooks/useGamepad';
12
+ import { cleanupInkInstance } from '../../utils/terminal';
13
+ import { renderDialog, type DialogRenderOptions } from '../NativeDialog';
14
+ import {
15
+ DEFAULT_TERM_WIDTH,
16
+ DEFAULT_TERM_HEIGHT,
17
+ DIALOG_BOX_PADDING,
18
+ } from '..';
19
+ import { CORE_SELECTOR_MIN_WIDTH } from './consts';
20
+
21
+ export * from './consts';
22
+
23
+ interface CoreOption {
24
+ id: string;
25
+ factory: CoreFactory;
26
+ }
27
+
28
+ export interface CoreSelection {
29
+ id: string;
30
+ factory: CoreFactory;
31
+ remember: boolean;
32
+ }
33
+
34
+ interface CoreSelectorProps {
35
+ cores: CoreOption[];
36
+ romName: string;
37
+ onSelect: (selection: CoreSelection) => void;
38
+ onCancel: () => void;
39
+ }
40
+
41
+ const CoreSelector = ({ cores, romName, onSelect, onCancel }: CoreSelectorProps) => {
42
+ const { exit } = useApp();
43
+ const { stdout } = useStdout();
44
+ const [selectedIndex, setSelectedIndex] = useState(0);
45
+ const [rememberSelection, setRememberSelection] = useState(false);
46
+
47
+ // Force re-render on mount to trigger Ink's terminal setup before user interaction
48
+ const [, setMounted] = useState(false);
49
+ useEffect(() => {
50
+ setMounted(true);
51
+ }, []);
52
+
53
+ useInput((input, key) => {
54
+ if (key.escape) {
55
+ onCancel();
56
+ exit();
57
+ return;
58
+ }
59
+
60
+ if (key.upArrow) {
61
+ setSelectedIndex(prev => Math.max(0, prev - 1));
62
+ return;
63
+ }
64
+
65
+ if (key.downArrow) {
66
+ setSelectedIndex(prev => Math.min(cores.length - 1, prev + 1));
67
+ return;
68
+ }
69
+
70
+ // Tab or 'r' to toggle remember checkbox
71
+ if (key.tab || input === 'r') {
72
+ setRememberSelection(prev => !prev);
73
+ return;
74
+ }
75
+
76
+ if (key.return) {
77
+ const selected = cores[selectedIndex];
78
+ onSelect({ id: selected.id, factory: selected.factory, remember: rememberSelection });
79
+ exit();
80
+ return;
81
+ }
82
+
83
+ // Number keys for quick selection
84
+ const num = parseInt(input, 10);
85
+ if (num >= 1 && num <= cores.length) {
86
+ const selected = cores[num - 1];
87
+ onSelect({ id: selected.id, factory: selected.factory, remember: rememberSelection });
88
+ exit();
89
+ }
90
+ });
91
+
92
+ // Handle gamepad input
93
+ useGamepad({
94
+ onUp: () => setSelectedIndex(prev => Math.max(0, prev - 1)),
95
+ onDown: () => setSelectedIndex(prev => Math.min(cores.length - 1, prev + 1)),
96
+ onConfirm: () => {
97
+ const selected = cores[selectedIndex];
98
+ onSelect({ id: selected.id, factory: selected.factory, remember: rememberSelection });
99
+ exit();
100
+ },
101
+ onCancel: () => {
102
+ onCancel();
103
+ exit();
104
+ },
105
+ });
106
+
107
+ // Get terminal dimensions for full-screen layout - use Ink's stdout for native mode compatibility
108
+ const termWidth = stdout.columns || DEFAULT_TERM_WIDTH;
109
+ const termHeight = stdout.rows || DEFAULT_TERM_HEIGHT;
110
+ const boxWidth = Math.min(CORE_SELECTOR_MIN_WIDTH, termWidth - DIALOG_BOX_PADDING);
111
+
112
+ return (
113
+ <Box
114
+ flexDirection="column"
115
+ width={termWidth}
116
+ height={termHeight}
117
+ alignItems="center"
118
+ justifyContent="center"
119
+ >
120
+ {/* Header */}
121
+ <Box
122
+ flexDirection="column"
123
+ borderStyle="round"
124
+ borderColor="cyan"
125
+ paddingX={2}
126
+ paddingY={1}
127
+ width={boxWidth}
128
+ >
129
+ <Box justifyContent="center" marginBottom={1}>
130
+ <Text bold color="cyan">{'\u{1F3AE}'} Select Emulator Core</Text>
131
+ </Box>
132
+
133
+ <Box>
134
+ <Text color="gray">Multiple cores can play </Text>
135
+ <Text color="white" bold>{romName}</Text>
136
+ </Box>
137
+ </Box>
138
+
139
+ {/* Core Options */}
140
+ <Box
141
+ flexDirection="column"
142
+ borderStyle="round"
143
+ borderColor="gray"
144
+ paddingX={2}
145
+ paddingY={1}
146
+ marginTop={1}
147
+ width={boxWidth}
148
+ >
149
+ {cores.map((core, index) => {
150
+ const info = core.factory.getSystemInfo();
151
+ const isSelected = index === selectedIndex;
152
+ const corePath = core.factory.path ?? 'native';
153
+ const isNative = corePath === 'native';
154
+
155
+ return (
156
+ <Box key={core.id} marginBottom={index < cores.length - 1 ? 1 : 0}>
157
+ <Text
158
+ color={isSelected ? 'green' : 'gray'}
159
+ bold={isSelected}
160
+ >
161
+ {isSelected ? '\u25B6 ' : ' '}
162
+ {index + 1}. {info.name}
163
+ </Text>
164
+ {isNative && (
165
+ <Text color="green"> [native]</Text>
166
+ )}
167
+ {!isNative && (
168
+ <Text color="gray" dimColor> (libretro)</Text>
169
+ )}
170
+ </Box>
171
+ );
172
+ })}
173
+ </Box>
174
+
175
+ {/* Remember Selection */}
176
+ <Box
177
+ flexDirection="column"
178
+ borderStyle="round"
179
+ borderColor="gray"
180
+ paddingX={2}
181
+ paddingY={1}
182
+ marginTop={1}
183
+ width={boxWidth}
184
+ >
185
+ <Box>
186
+ <Text
187
+ color={rememberSelection ? 'green' : 'gray'}
188
+ >
189
+ {rememberSelection ? '\u2611' : '\u2610'} Remember this selection
190
+ </Text>
191
+ <Text color="gray" dimColor> (Tab to toggle)</Text>
192
+ </Box>
193
+ </Box>
194
+
195
+ {/* Footer */}
196
+ <Box marginTop={1}>
197
+ <Text color="gray" dimColor>
198
+ {'\u2191\u2193'} Navigate {'\u23CE'} Select 1-{cores.length} Quick select ESC Cancel
199
+ </Text>
200
+ </Box>
201
+ </Box>
202
+ );
203
+ };
204
+
205
+ /**
206
+ * Launch the core selector dialog
207
+ *
208
+ * @param cores Array of matching cores
209
+ * @param romName Name of the ROM file for display
210
+ * @param options Optional dialog render options (e.g., nativeMode)
211
+ * @returns Promise that resolves to the CoreSelection, or null if cancelled
212
+ */
213
+ export const selectCore = async (
214
+ cores: CoreOption[],
215
+ romName: string,
216
+ options: DialogRenderOptions = {}
217
+ ): Promise<CoreSelection | null> => new Promise((resolve) => {
218
+ let selection: CoreSelection | null = null;
219
+
220
+ const handleSelect = (sel: CoreSelection) => {
221
+ selection = sel;
222
+ };
223
+
224
+ const handleCancel = () => {
225
+ selection = null;
226
+ };
227
+
228
+ // Enter alternate screen buffer and clear (only in terminal mode)
229
+ if (!options.nativeMode) {
230
+ process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H');
231
+ }
232
+
233
+ void renderDialog(
234
+ <CoreSelector
235
+ cores={cores}
236
+ romName={romName}
237
+ onSelect={handleSelect}
238
+ onCancel={handleCancel}
239
+ />,
240
+ { ...options, title: options.title ?? 'emoemu - Select Core' }
241
+ ).then(({ instance, cleanup }) => {
242
+ void instance.waitUntilExit().then(() => {
243
+ cleanup();
244
+ cleanupInkInstance(instance, resolve, selection, {
245
+ exitAlternateScreen: !options.nativeMode,
246
+ });
247
+ });
248
+ });
249
+ });
250
+
251
+ export default CoreSelector;
@@ -0,0 +1,42 @@
1
+ import { Box, useStdout } from 'ink';
2
+ import { useClearTerminal } from '../hooks/useClearTerminal';
3
+ import {
4
+ DEFAULT_TERM_WIDTH,
5
+ DEFAULT_TERM_HEIGHT,
6
+ DIALOG_BOX_PADDING,
7
+ DIALOG_BOX_MIN_WIDTH,
8
+ } from '..';
9
+
10
+ interface DialogContainerProps {
11
+ minWidth?: number;
12
+ children: (boxWidth: number) => React.ReactNode;
13
+ }
14
+
15
+ /**
16
+ * Shared container for dialogs that handles terminal dimension detection,
17
+ * screen clearing, ready state, and centering layout.
18
+ */
19
+ export const DialogContainer = ({ minWidth = DIALOG_BOX_MIN_WIDTH, children }: DialogContainerProps): React.JSX.Element | null => {
20
+ const { stdout } = useStdout();
21
+ const ready = useClearTerminal();
22
+
23
+ const termWidth = stdout.columns || DEFAULT_TERM_WIDTH;
24
+ const termHeight = stdout.rows || DEFAULT_TERM_HEIGHT;
25
+ const boxWidth = Math.min(minWidth, termWidth - DIALOG_BOX_PADDING);
26
+
27
+ if (!ready) {
28
+ return null;
29
+ }
30
+
31
+ return (
32
+ <Box
33
+ flexDirection="column"
34
+ width={termWidth}
35
+ height={termHeight}
36
+ alignItems="center"
37
+ justifyContent="center"
38
+ >
39
+ {children(boxWidth)}
40
+ </Box>
41
+ );
42
+ };
@@ -0,0 +1,61 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ interface DialogOption {
4
+ label: string;
5
+ choice: string;
6
+ color: string;
7
+ }
8
+
9
+ interface DialogOptionsListProps {
10
+ options: DialogOption[];
11
+ selectedIndex: number;
12
+ boxWidth: number;
13
+ /** Label shown after ESC in the footer (default: "Cancel") */
14
+ escLabel?: string;
15
+ /** Prompt shown above options, or false to hide (default: "What would you like to do?") */
16
+ prompt?: string | false;
17
+ }
18
+
19
+ export const DialogOptionsList = ({
20
+ options,
21
+ selectedIndex,
22
+ boxWidth,
23
+ escLabel = 'Cancel',
24
+ prompt = 'What would you like to do?',
25
+ }: DialogOptionsListProps): React.JSX.Element => (
26
+ <>
27
+ <Box
28
+ flexDirection="column"
29
+ borderStyle="round"
30
+ borderColor="gray"
31
+ paddingX={2}
32
+ paddingY={1}
33
+ marginTop={1}
34
+ width={boxWidth}
35
+ >
36
+ {prompt !== false && (
37
+ <Box marginBottom={1}>
38
+ <Text bold>{prompt}</Text>
39
+ </Box>
40
+ )}
41
+
42
+ {options.map((option, index) => (
43
+ <Box key={option.choice}>
44
+ <Text
45
+ color={selectedIndex === index ? option.color : 'gray'}
46
+ bold={selectedIndex === index}
47
+ >
48
+ {selectedIndex === index ? '\u25B6 ' : ' '}
49
+ {index + 1}. {option.label}
50
+ </Text>
51
+ </Box>
52
+ ))}
53
+ </Box>
54
+
55
+ <Box marginTop={1}>
56
+ <Text color="gray" dimColor>
57
+ {'\u2191\u2193'} Navigate {'\u23CE'} Select ESC {escLabel}
58
+ </Text>
59
+ </Box>
60
+ </>
61
+ );
@@ -0,0 +1,5 @@
1
+ /** Dialog box minimum width (wider than default for path display) */
2
+ export const DIALOG_BOX_MIN_WIDTH = 60;
3
+
4
+ /** Path label width (accounts for "Existing: " or "New: " labels) */
5
+ export const PATH_LABEL_WIDTH = 12;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Duplicate CRC Prompt Component
3
+ *
4
+ * Shows when importing a ROM that has the same CRC32 as an existing entry
5
+ * in the library, where both files exist. Prompts user to either update
6
+ * the path or skip the import.
7
+ */
8
+
9
+ import { Box, Text } from 'ink';
10
+ import { DialogOptionsList } from '../DialogOptionsList';
11
+ import { DialogContainer } from '../DialogContainer';
12
+ import { useDialogNavigation } from '../hooks/useDialogNavigation';
13
+ import { launchDialog, type DialogRenderOptions } from '../NativeDialog';
14
+ import { SEPARATOR_LINE_PADDING } from '..';
15
+ import {
16
+ DIALOG_BOX_MIN_WIDTH,
17
+ PATH_LABEL_WIDTH,
18
+ } from './consts';
19
+
20
+ export type DuplicateCrcChoice = 'update' | 'skip';
21
+
22
+ export interface DuplicateCrcInfo {
23
+ /** The new ROM path being imported */
24
+ newPath: string;
25
+ /** The existing ROM path in the library */
26
+ existingPath: string;
27
+ /** The game label/title */
28
+ label: string;
29
+ /** The shared CRC32 value */
30
+ crc32: string;
31
+ }
32
+
33
+ interface DuplicateCrcDialogProps {
34
+ info: DuplicateCrcInfo;
35
+ onChoice: (choice: DuplicateCrcChoice) => void;
36
+ }
37
+
38
+ /** Truncate a path to fit within max length, showing ... in the middle */
39
+ const truncatePath = (path: string, maxLength: number): string => {
40
+ if (path.length <= maxLength) {
41
+ return path;
42
+ }
43
+
44
+ const ellipsis = '...';
45
+ const availableLength = maxLength - ellipsis.length;
46
+ const startLength = Math.ceil(availableLength / 2);
47
+ const endLength = Math.floor(availableLength / 2);
48
+
49
+ return path.slice(0, startLength) + ellipsis + path.slice(-endLength);
50
+ };
51
+
52
+ const DuplicateCrcDialog = ({ info, onChoice }: DuplicateCrcDialogProps) => {
53
+ const options: { label: string; choice: DuplicateCrcChoice; color: string }[] = [
54
+ { label: 'Update path to new location', choice: 'update', color: 'green' },
55
+ { label: 'Skip (keep existing)', choice: 'skip', color: 'gray' },
56
+ ];
57
+
58
+ const { selectedIndex } = useDialogNavigation({
59
+ itemCount: options.length,
60
+ onSelect: (index) => onChoice(options[index].choice),
61
+ onCancel: () => onChoice('skip'),
62
+ });
63
+
64
+ return (
65
+ <DialogContainer minWidth={DIALOG_BOX_MIN_WIDTH}>
66
+ {(boxWidth) => {
67
+ // Calculate max path length for truncation (accounting for label and padding)
68
+ const pathMaxLength = boxWidth - SEPARATOR_LINE_PADDING - PATH_LABEL_WIDTH;
69
+
70
+ return (
71
+ <>
72
+ {/* Header */}
73
+ <Box
74
+ flexDirection="column"
75
+ borderStyle="round"
76
+ borderColor="yellow"
77
+ paddingX={2}
78
+ paddingY={1}
79
+ width={boxWidth}
80
+ >
81
+ <Box justifyContent="center" marginBottom={1}>
82
+ <Text bold color="yellow">{'\u26A0'} Duplicate ROM Detected</Text>
83
+ </Box>
84
+
85
+ {/* Explanation */}
86
+ <Box marginBottom={1}>
87
+ <Text color="white">
88
+ A ROM with the same checksum already exists in your library.
89
+ </Text>
90
+ </Box>
91
+
92
+ {/* Separator */}
93
+ <Text color="gray" dimColor>{'─'.repeat(boxWidth - SEPARATOR_LINE_PADDING)}</Text>
94
+
95
+ {/* Game info */}
96
+ <Box flexDirection="column" marginY={1}>
97
+ <Box>
98
+ <Text color="gray">{'Game: '}</Text>
99
+ <Text color="white" bold>{info.label}</Text>
100
+ </Box>
101
+ <Box>
102
+ <Text color="gray">{'CRC32: '}</Text>
103
+ <Text color="cyan">{info.crc32}</Text>
104
+ </Box>
105
+ </Box>
106
+
107
+ {/* Separator */}
108
+ <Text color="gray" dimColor>{'─'.repeat(boxWidth - SEPARATOR_LINE_PADDING)}</Text>
109
+
110
+ {/* Paths */}
111
+ <Box flexDirection="column" marginY={1}>
112
+ <Box>
113
+ <Text color="gray">{'Existing: '}</Text>
114
+ <Text color="white">{truncatePath(info.existingPath, pathMaxLength)}</Text>
115
+ </Box>
116
+ <Box>
117
+ <Text color="gray">{'New: '}</Text>
118
+ <Text color="green">{truncatePath(info.newPath, pathMaxLength)}</Text>
119
+ </Box>
120
+ </Box>
121
+ </Box>
122
+
123
+ <DialogOptionsList options={options} selectedIndex={selectedIndex} boxWidth={boxWidth} escLabel="Skip" />
124
+ </>
125
+ );
126
+ }}
127
+ </DialogContainer>
128
+ );
129
+ };
130
+
131
+ /**
132
+ * Show the duplicate CRC dialog and get user's choice
133
+ */
134
+ export const showDuplicateCrcPrompt = (
135
+ info: DuplicateCrcInfo,
136
+ options: DialogRenderOptions = {}
137
+ ): Promise<DuplicateCrcChoice> => launchDialog<DuplicateCrcChoice>(
138
+ (onChoice) => <DuplicateCrcDialog info={info} onChoice={onChoice} />,
139
+ 'skip',
140
+ { ...options, title: options.title ?? 'emoemu - Duplicate ROM' },
141
+ );
142
+
143
+ export default DuplicateCrcDialog;
144
+
145
+ // Re-export constants
146
+ export * from './consts';
@@ -0,0 +1,15 @@
1
+ /** Delay before repeating starts (ms) */
2
+ export const INITIAL_DELAY_MS = 400;
3
+
4
+ /** Starting repeat interval (ms) */
5
+ export const INITIAL_REPEAT_MS = 200;
6
+
7
+ /** Fastest repeat interval (ms) */
8
+ export const MIN_REPEAT_MS = 40;
9
+
10
+ /** Time to reach max speed (ms) */
11
+ export const ACCELERATION_TIME_MS = 1500;
12
+
13
+ /** Ease-in-out cubic smoothing factors */
14
+ export const EASE_CUBIC_FACTOR = 3;
15
+ export const EASE_CUBIC_DIVISOR = 2;