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,779 @@
1
+ /**
2
+ * Core Manager Component
3
+ *
4
+ * Allows users to view, install, and delete libretro cores.
5
+ * Two tabs: Installed Cores and Download Cores.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, useMemo } from 'react';
9
+ import { Box, Text, useInput, useApp } from 'ink';
10
+ import { Spinner, ProgressBar } from '@inkjs/ui';
11
+ import { unlink } from 'fs/promises';
12
+ import { listCores, unregisterCore } from '../../frontend/coreRegistry';
13
+ import {
14
+ registerLibretroCore,
15
+ unloadLibretroCore,
16
+ isInUserCoresDirectory,
17
+ } from '../../cores/libretro';
18
+ import { getSystemName } from '../../frontend/playlist';
19
+ import {
20
+ fetchAvailableCores,
21
+ downloadCore,
22
+ RECOMMENDED_CORE_NAMES,
23
+ RECOMMENDED_CORES,
24
+ type AvailableCoreInfo,
25
+ type DownloadProgress,
26
+ } from '../../frontend/coreDownloader';
27
+ import { notify } from '../../frontend/notifications';
28
+ import { logger } from '../../utils/logger';
29
+ import { getErrorMessage } from '../../utils/getErrorMessage';
30
+ import { useGamepadContext } from '../GamepadContext';
31
+ import { useClearTerminal } from '../hooks/useClearTerminal';
32
+ import {
33
+ TAB_INSTALLED,
34
+ TAB_DOWNLOAD,
35
+ MAX_VISIBLE_ITEMS,
36
+ BYTES_PER_KB,
37
+ } from './consts';
38
+
39
+ export * from './consts';
40
+
41
+ interface CoreManagerProps {
42
+ onClose: () => void;
43
+ }
44
+
45
+ /** Information about an installed core */
46
+ interface InstalledCore {
47
+ id: string;
48
+ name: string;
49
+ extensions: string[];
50
+ path: string;
51
+ canDelete: boolean;
52
+ systemDescription: string;
53
+ }
54
+
55
+ /** Progress bar percentage constants */
56
+ const PROGRESS_FULL = 100;
57
+
58
+ export const CoreManager = ({ onClose }: CoreManagerProps) => {
59
+ const ready = useClearTerminal();
60
+ const { exit } = useApp();
61
+
62
+ // Tab state
63
+ const [activeTab, setActiveTab] = useState(TAB_INSTALLED);
64
+
65
+ // Installed cores state
66
+ const [installedCores, setInstalledCores] = useState<InstalledCore[]>([]);
67
+ const [installedSelectedIndex, setInstalledSelectedIndex] = useState(0);
68
+
69
+ // Download cores state
70
+ const [availableCores, setAvailableCores] = useState<AvailableCoreInfo[]>([]);
71
+ const [downloadSelectedIndex, setDownloadSelectedIndex] = useState(0);
72
+ const [isLoadingCores, setIsLoadingCores] = useState(false);
73
+ const [hasAttemptedLoad, setHasAttemptedLoad] = useState(false);
74
+ const [loadError, setLoadError] = useState<string | null>(null);
75
+ const [showAllCores, setShowAllCores] = useState(false);
76
+
77
+ // Download progress state
78
+ const [downloadingCore, setDownloadingCore] = useState<string | null>(null);
79
+ const [downloadProgress, setDownloadProgress] = useState<DownloadProgress | null>(null);
80
+
81
+ // Delete confirmation state
82
+ const [confirmDelete, setConfirmDelete] = useState<InstalledCore | null>(null);
83
+
84
+ // Core info display state (for non-deletable cores)
85
+ const [showCoreInfo, setShowCoreInfo] = useState<InstalledCore | null>(null);
86
+
87
+ // Install error state (for showing error screen with retry option)
88
+ const [installError, setInstallError] = useState<{
89
+ core: AvailableCoreInfo;
90
+ message: string;
91
+ } | null>(null);
92
+
93
+ // Get system description for a core based on its extensions
94
+ const getSystemDescription = useCallback((coreId: string, extensions: string[]): string => {
95
+ // Check if it's a known recommended core
96
+ // Core IDs now match buildbot names directly (e.g., "mgba", "mupen64plus-next")
97
+ const recommended = RECOMMENDED_CORES.find(c => c.name === coreId);
98
+ if (recommended) {
99
+ return recommended.description;
100
+ }
101
+
102
+ // Fall back to system name from first extension
103
+ if (extensions.length > 0) {
104
+ const systemName = getSystemName(extensions[0]);
105
+ // Remove "Nintendo - " or similar prefixes for brevity
106
+ return systemName.replace(/^[^-]+ - /, '');
107
+ }
108
+
109
+ return 'Unknown system';
110
+ }, []);
111
+
112
+ // Load installed cores
113
+ const loadInstalledCores = useCallback(() => {
114
+ const cores = listCores();
115
+ const processed: InstalledCore[] = cores.map(core => ({
116
+ id: core.id,
117
+ name: core.name,
118
+ extensions: core.extensions,
119
+ path: core.path,
120
+ canDelete: isInUserCoresDirectory(core.path),
121
+ systemDescription: getSystemDescription(core.id, core.extensions),
122
+ }));
123
+ setInstalledCores(processed);
124
+ }, [getSystemDescription]);
125
+
126
+ // Load available cores from buildbot
127
+ const loadAvailableCores = useCallback(async () => {
128
+ setIsLoadingCores(true);
129
+ setLoadError(null);
130
+ setHasAttemptedLoad(true);
131
+ try {
132
+ const cores = await fetchAvailableCores();
133
+ setAvailableCores(cores);
134
+ } catch (error) {
135
+ setLoadError(getErrorMessage(error));
136
+ } finally {
137
+ setIsLoadingCores(false);
138
+ }
139
+ }, []);
140
+
141
+ // Initial load
142
+ useEffect(() => {
143
+ loadInstalledCores();
144
+ }, [loadInstalledCores]);
145
+
146
+ // Load available cores when switching to download tab (only once)
147
+ useEffect(() => {
148
+ if (activeTab === TAB_DOWNLOAD && !hasAttemptedLoad && !isLoadingCores) {
149
+ void loadAvailableCores();
150
+ }
151
+ }, [activeTab, hasAttemptedLoad, isLoadingCores, loadAvailableCores]);
152
+
153
+ // Get installed core names for filtering available cores
154
+ // Core IDs now match buildbot names directly (e.g., "mgba", "mupen64plus-next")
155
+ const installedCoreNames = useMemo(() => {
156
+ return new Set(installedCores.map(c => c.id));
157
+ }, [installedCores]);
158
+
159
+ // Filter available cores (exclude installed, optionally show only recommended)
160
+ const filteredAvailableCores = useMemo(() => {
161
+ let coresToShow = availableCores.filter(c => !installedCoreNames.has(c.name));
162
+ if (!showAllCores) {
163
+ coresToShow = coresToShow.filter(c => c.isRecommended);
164
+ }
165
+
166
+ // Sort: recommended first, then alphabetically
167
+ return coresToShow.sort((a, b) => {
168
+ if (a.isRecommended && !b.isRecommended) { return -1; }
169
+ if (!a.isRecommended && b.isRecommended) { return 1; }
170
+ return a.name.localeCompare(b.name);
171
+ });
172
+ }, [availableCores, installedCoreNames, showAllCores]);
173
+
174
+ // Count of available but not shown cores
175
+ const hiddenCoresCount = useMemo(() => {
176
+ if (showAllCores) { return 0; }
177
+ const allNotInstalled = availableCores.filter(c => !installedCoreNames.has(c.name));
178
+ return allNotInstalled.length - filteredAvailableCores.length;
179
+ }, [availableCores, installedCoreNames, filteredAvailableCores.length, showAllCores]);
180
+
181
+ // Handle core deletion
182
+ const handleDeleteCore = useCallback(async (core: InstalledCore) => {
183
+ try {
184
+ await unlink(core.path);
185
+ unregisterCore(core.id);
186
+ unloadLibretroCore(core.path, core.id);
187
+ notify({ message: `Deleted ${core.name}`, severity: 'info' });
188
+ loadInstalledCores();
189
+ setConfirmDelete(null);
190
+ // Reset selection if needed
191
+ setInstalledSelectedIndex(prev => Math.min(prev, installedCores.length - 2));
192
+ } catch (error) {
193
+ notify({
194
+ message: `Failed to delete ${core.name}: ${getErrorMessage(error)}`,
195
+ severity: 'error',
196
+ });
197
+ setConfirmDelete(null);
198
+ }
199
+ }, [loadInstalledCores, installedCores.length]);
200
+
201
+ // Handle core download
202
+ const handleDownloadCore = useCallback(async (core: AvailableCoreInfo) => {
203
+ setDownloadingCore(core.name);
204
+ setDownloadProgress(null);
205
+
206
+ try {
207
+ const corePath = await downloadCore(core.name, (progress) => {
208
+ setDownloadProgress(progress);
209
+ });
210
+
211
+ // Dynamically register the newly downloaded core
212
+ const coreId = registerLibretroCore(corePath);
213
+ if (coreId) {
214
+ notify({ message: `Installed ${core.name}`, severity: 'info' });
215
+ // Refresh installed cores list to show the new core
216
+ loadInstalledCores();
217
+ } else {
218
+ notify({ message: `Downloaded ${core.name} (already registered)`, severity: 'info' });
219
+ }
220
+
221
+ // Remove from available cores list
222
+ setAvailableCores(prev => prev.filter(c => c.name !== core.name));
223
+
224
+ // Reset selection if needed
225
+ setDownloadSelectedIndex(prev => Math.min(prev, filteredAvailableCores.length - 2));
226
+ } catch (error) {
227
+ const errorMessage = getErrorMessage(error);
228
+ logger.error(`Failed to install core ${core.name}: ${errorMessage}`, 'CoreManager');
229
+ console.error(`Failed to install ${core.name}: ${errorMessage}`);
230
+ setInstallError({ core, message: errorMessage });
231
+ } finally {
232
+ setDownloadingCore(null);
233
+ setDownloadProgress(null);
234
+ }
235
+ }, [loadInstalledCores]);
236
+
237
+ // Calculate scroll offset for lists
238
+ const getScrollOffset = (selectedIndex: number, totalItems: number): number => {
239
+ if (totalItems <= MAX_VISIBLE_ITEMS) { return 0; }
240
+ const halfVisible = Math.floor(MAX_VISIBLE_ITEMS / 2);
241
+ if (selectedIndex <= halfVisible) { return 0; }
242
+ if (selectedIndex >= totalItems - halfVisible) { return totalItems - MAX_VISIBLE_ITEMS; }
243
+ return selectedIndex - halfVisible;
244
+ };
245
+
246
+ // Keyboard input handling
247
+ useInput((input, key) => {
248
+ // CTRL-C exits the app
249
+ if (input === '\x03' || (key.ctrl && input === 'c')) {
250
+ exit();
251
+ return;
252
+ }
253
+
254
+ // Handle delete confirmation dialog
255
+ if (confirmDelete) {
256
+ if (key.escape || input.toLowerCase() === 'n') {
257
+ setConfirmDelete(null);
258
+ return;
259
+ }
260
+ if (key.return || input.toLowerCase() === 'y') {
261
+ void handleDeleteCore(confirmDelete);
262
+ return;
263
+ }
264
+ return;
265
+ }
266
+
267
+ // Handle core info dialog (for non-deletable cores)
268
+ if (showCoreInfo) {
269
+ if (key.escape || key.return) {
270
+ setShowCoreInfo(null);
271
+ return;
272
+ }
273
+ return;
274
+ }
275
+
276
+ // Handle install error screen
277
+ if (installError) {
278
+ if (key.escape || input.toLowerCase() === 'c') {
279
+ setInstallError(null);
280
+ return;
281
+ }
282
+ if (key.return || input.toLowerCase() === 'r') {
283
+ const coreToRetry = installError.core;
284
+ setInstallError(null);
285
+ void handleDownloadCore(coreToRetry);
286
+ return;
287
+ }
288
+ return;
289
+ }
290
+
291
+ // Downloading - ignore most input
292
+ if (downloadingCore) {
293
+ if (key.escape) {
294
+ // Could implement cancel here if needed
295
+ }
296
+ return;
297
+ }
298
+
299
+ // Close panel
300
+ if (key.escape) {
301
+ onClose();
302
+ return;
303
+ }
304
+
305
+ // Tab switching
306
+ if (key.tab || key.leftArrow || key.rightArrow) {
307
+ setActiveTab(prev => prev === TAB_INSTALLED ? TAB_DOWNLOAD : TAB_INSTALLED);
308
+ return;
309
+ }
310
+
311
+ // Navigation
312
+ if (activeTab === TAB_INSTALLED) {
313
+ if (key.upArrow) {
314
+ setInstalledSelectedIndex(prev => Math.max(0, prev - 1));
315
+ return;
316
+ }
317
+ if (key.downArrow) {
318
+ setInstalledSelectedIndex(prev => Math.min(installedCores.length - 1, prev + 1));
319
+ return;
320
+ }
321
+ // Select core with Enter to show delete confirmation or core info
322
+ if (key.return && installedCores[installedSelectedIndex]) {
323
+ const core = installedCores[installedSelectedIndex];
324
+ if (core.canDelete) {
325
+ setConfirmDelete(core);
326
+ } else {
327
+ setShowCoreInfo(core);
328
+ }
329
+ return;
330
+ }
331
+ } else {
332
+ // Download tab
333
+ if (key.upArrow) {
334
+ setDownloadSelectedIndex(prev => Math.max(0, prev - 1));
335
+ return;
336
+ }
337
+ if (key.downArrow) {
338
+ setDownloadSelectedIndex(prev => Math.min(filteredAvailableCores.length - 1, prev + 1));
339
+ return;
340
+ }
341
+ // Download with Enter
342
+ if (key.return && filteredAvailableCores[downloadSelectedIndex]) {
343
+ void handleDownloadCore(filteredAvailableCores[downloadSelectedIndex]);
344
+ return;
345
+ }
346
+ // Toggle show all cores
347
+ if (input === 'a' || input === 'A') {
348
+ setShowAllCores(prev => !prev);
349
+ setDownloadSelectedIndex(0);
350
+ return;
351
+ }
352
+ // Refresh available cores
353
+ if (input === 'r' || input === 'R') {
354
+ setHasAttemptedLoad(false);
355
+ setLoadError(null);
356
+ void loadAvailableCores();
357
+ return;
358
+ }
359
+ }
360
+ });
361
+
362
+ // Gamepad support
363
+ useGamepadContext({
364
+ onUp: () => {
365
+ if (confirmDelete || downloadingCore || installError) { return; }
366
+ if (activeTab === TAB_INSTALLED) {
367
+ setInstalledSelectedIndex(prev => Math.max(0, prev - 1));
368
+ } else {
369
+ setDownloadSelectedIndex(prev => Math.max(0, prev - 1));
370
+ }
371
+ },
372
+ onDown: () => {
373
+ if (confirmDelete || downloadingCore || installError) { return; }
374
+ if (activeTab === TAB_INSTALLED) {
375
+ setInstalledSelectedIndex(prev => Math.min(installedCores.length - 1, prev + 1));
376
+ } else {
377
+ setDownloadSelectedIndex(prev => Math.min(filteredAvailableCores.length - 1, prev + 1));
378
+ }
379
+ },
380
+ onLeft: () => {
381
+ if (confirmDelete || downloadingCore || installError) { return; }
382
+ setActiveTab(TAB_INSTALLED);
383
+ },
384
+ onRight: () => {
385
+ if (confirmDelete || downloadingCore || installError) { return; }
386
+ setActiveTab(TAB_DOWNLOAD);
387
+ },
388
+ onConfirm: () => {
389
+ if (confirmDelete) {
390
+ void handleDeleteCore(confirmDelete);
391
+ return;
392
+ }
393
+ if (showCoreInfo) {
394
+ setShowCoreInfo(null);
395
+ return;
396
+ }
397
+ if (installError) {
398
+ const coreToRetry = installError.core;
399
+ setInstallError(null);
400
+ void handleDownloadCore(coreToRetry);
401
+ return;
402
+ }
403
+ if (downloadingCore) { return; }
404
+ if (activeTab === TAB_INSTALLED && installedCores[installedSelectedIndex]) {
405
+ const core = installedCores[installedSelectedIndex];
406
+ if (core.canDelete) {
407
+ setConfirmDelete(core);
408
+ } else {
409
+ setShowCoreInfo(core);
410
+ }
411
+ } else if (activeTab === TAB_DOWNLOAD && filteredAvailableCores[downloadSelectedIndex]) {
412
+ void handleDownloadCore(filteredAvailableCores[downloadSelectedIndex]);
413
+ }
414
+ },
415
+ onCancel: () => {
416
+ if (confirmDelete) {
417
+ setConfirmDelete(null);
418
+ return;
419
+ }
420
+ if (showCoreInfo) {
421
+ setShowCoreInfo(null);
422
+ return;
423
+ }
424
+ if (installError) {
425
+ setInstallError(null);
426
+ return;
427
+ }
428
+ if (downloadingCore) { return; }
429
+ onClose();
430
+ },
431
+ });
432
+
433
+ // Wait for terminal clear
434
+ if (!ready) {
435
+ return null;
436
+ }
437
+
438
+ // Delete confirmation dialog
439
+ if (confirmDelete) {
440
+ return (
441
+ <Box flexDirection="column" padding={1}>
442
+ <Box marginBottom={1}>
443
+ <Text bold color="yellow">{'\u26A0'} Delete Core</Text>
444
+ </Box>
445
+
446
+ <Box marginBottom={1}>
447
+ <Text color="white">Are you sure you want to delete {confirmDelete.name}?</Text>
448
+ </Box>
449
+
450
+ <Box marginBottom={1}>
451
+ <Text color="gray">{confirmDelete.path}</Text>
452
+ </Box>
453
+
454
+ <Box marginTop={1}>
455
+ <Box marginRight={2}>
456
+ <Text color="red" bold>[Y]</Text>
457
+ <Text color="gray"> Delete</Text>
458
+ </Box>
459
+ <Box>
460
+ <Text color="green" bold>[N]</Text>
461
+ <Text color="gray"> Cancel</Text>
462
+ </Box>
463
+ </Box>
464
+ </Box>
465
+ );
466
+ }
467
+
468
+ // Core info dialog (for non-deletable cores)
469
+ if (showCoreInfo) {
470
+ return (
471
+ <Box flexDirection="column" padding={1}>
472
+ <Box marginBottom={1}>
473
+ <Text bold color="cyan">{'\u2139'} Core Info</Text>
474
+ </Box>
475
+
476
+ <Box marginBottom={1}>
477
+ <Text color="white" bold>{showCoreInfo.name}</Text>
478
+ </Box>
479
+
480
+ <Box marginBottom={1}>
481
+ <Text color="gray">{showCoreInfo.systemDescription}</Text>
482
+ </Box>
483
+
484
+ <Box marginBottom={1}>
485
+ <Text color="gray" dimColor>
486
+ {'This core was not installed by emoemu and cannot be removed from here.'}
487
+ </Text>
488
+ </Box>
489
+
490
+ <Box marginTop={1}>
491
+ <Text color="gray" dimColor>Press Enter or ESC to close</Text>
492
+ </Box>
493
+ </Box>
494
+ );
495
+ }
496
+
497
+ // Download progress overlay
498
+ if (downloadingCore) {
499
+ const progressPercent = downloadProgress?.totalBytes
500
+ ? Math.round((downloadProgress.bytesDownloaded / downloadProgress.totalBytes) * PROGRESS_FULL)
501
+ : 0;
502
+
503
+ const isBuilding = downloadProgress?.phase === 'building';
504
+ const headerText = isBuilding ? 'Building Core' : 'Downloading Core';
505
+ const headerEmoji = isBuilding ? '\u{1F6E0}' : '\u{1F4E5}'; // 🛠 vs 📥
506
+ const statusText = isBuilding
507
+ ? `Building ${downloadingCore} from source...`
508
+ : `Downloading ${downloadingCore}...`;
509
+
510
+ return (
511
+ <Box flexDirection="column" padding={1}>
512
+ <Box marginBottom={1}>
513
+ <Text bold color="cyan">{headerEmoji} {headerText}</Text>
514
+ </Box>
515
+
516
+ <Box marginBottom={1}>
517
+ <Text>{statusText}</Text>
518
+ </Box>
519
+
520
+ {downloadProgress?.phase === 'downloading' && downloadProgress.totalBytes && (
521
+ <Box marginBottom={1}>
522
+ <ProgressBar value={progressPercent} />
523
+ <Text color="gray"> {progressPercent}%</Text>
524
+ </Box>
525
+ )}
526
+
527
+ {downloadProgress?.phase === 'downloading' && !downloadProgress.totalBytes && (
528
+ <Box marginBottom={1}>
529
+ <Spinner label={`${Math.round(downloadProgress.bytesDownloaded / BYTES_PER_KB)} KB downloaded`} />
530
+ </Box>
531
+ )}
532
+
533
+ {downloadProgress?.phase === 'extracting' && (
534
+ <Box marginBottom={1}>
535
+ <Spinner label="Extracting..." />
536
+ </Box>
537
+ )}
538
+
539
+ {downloadProgress?.phase === 'building' && (
540
+ <Box marginBottom={1} flexDirection="column">
541
+ {downloadProgress.buildProgressPercent !== undefined ? (
542
+ <>
543
+ <Box>
544
+ <ProgressBar value={downloadProgress.buildProgressPercent} />
545
+ <Text color="gray"> {downloadProgress.buildProgressPercent}%</Text>
546
+ </Box>
547
+ <Box marginTop={1}>
548
+ <Text color="gray" dimColor>
549
+ {downloadProgress.buildMessage ?? 'Building...'}
550
+ </Text>
551
+ </Box>
552
+ </>
553
+ ) : (
554
+ <Spinner label={downloadProgress.buildMessage ?? 'Building from source...'} />
555
+ )}
556
+ </Box>
557
+ )}
558
+ </Box>
559
+ );
560
+ }
561
+
562
+ // Install error screen
563
+ if (installError) {
564
+ return (
565
+ <Box flexDirection="column" padding={1}>
566
+ <Box marginBottom={1}>
567
+ <Text bold color="red">{'\u{274C}'} Installation Failed</Text>
568
+ </Box>
569
+
570
+ <Box marginBottom={1}>
571
+ <Text>Failed to install <Text bold>{installError.core.name}</Text></Text>
572
+ </Box>
573
+
574
+ <Box marginBottom={1} flexDirection="column">
575
+ <Text color="gray">Error:</Text>
576
+ <Text color="red">{installError.message}</Text>
577
+ </Box>
578
+
579
+ <Box marginTop={1} flexDirection="column">
580
+ <Text color="cyan">[R] Retry</Text>
581
+ <Text color="gray">[C] Cancel</Text>
582
+ </Box>
583
+ </Box>
584
+ );
585
+ }
586
+
587
+ // Calculate scroll offsets
588
+ const installedScrollOffset = getScrollOffset(installedSelectedIndex, installedCores.length);
589
+ const downloadScrollOffset = getScrollOffset(downloadSelectedIndex, filteredAvailableCores.length);
590
+
591
+ // Visible items for installed tab
592
+ const visibleInstalledCores = installedCores.slice(
593
+ installedScrollOffset,
594
+ installedScrollOffset + MAX_VISIBLE_ITEMS
595
+ );
596
+
597
+ // Visible items for download tab
598
+ const visibleDownloadCores = filteredAvailableCores.slice(
599
+ downloadScrollOffset,
600
+ downloadScrollOffset + MAX_VISIBLE_ITEMS
601
+ );
602
+
603
+ return (
604
+ <Box flexDirection="column" padding={1}>
605
+ {/* Header */}
606
+ <Box marginBottom={1}>
607
+ <Text bold color="cyan">{'\u{1F9E9}'} Manage Cores</Text>
608
+ </Box>
609
+
610
+ {/* Tabs */}
611
+ <Box marginBottom={1}>
612
+ <Box marginRight={2}>
613
+ <Text
614
+ backgroundColor={activeTab === TAB_INSTALLED ? 'cyan' : undefined}
615
+ color={activeTab === TAB_INSTALLED ? 'black' : 'gray'}
616
+ bold={activeTab === TAB_INSTALLED}
617
+ >
618
+ {' '}Installed ({installedCores.length}){' '}
619
+ </Text>
620
+ </Box>
621
+ <Box>
622
+ <Text
623
+ backgroundColor={activeTab === TAB_DOWNLOAD ? 'cyan' : undefined}
624
+ color={activeTab === TAB_DOWNLOAD ? 'black' : 'gray'}
625
+ bold={activeTab === TAB_DOWNLOAD}
626
+ >
627
+ {' '}Download{' '}
628
+ </Text>
629
+ </Box>
630
+ </Box>
631
+
632
+ {/* Tab content */}
633
+ {activeTab === TAB_INSTALLED ? (
634
+ <Box flexDirection="column">
635
+ {installedCores.length === 0 ? (
636
+ <Text color="gray">No cores installed</Text>
637
+ ) : (
638
+ <>
639
+ {/* Scroll indicator - top */}
640
+ {installedScrollOffset > 0 && (
641
+ <Box>
642
+ <Text color="gray" dimColor> {'\u25B2'} {installedScrollOffset} more above</Text>
643
+ </Box>
644
+ )}
645
+
646
+ {visibleInstalledCores.map((core, index) => {
647
+ const globalIndex = installedScrollOffset + index;
648
+ const isSelected = globalIndex === installedSelectedIndex;
649
+ return (
650
+ <Box key={core.id} flexDirection="column">
651
+ <Box>
652
+ <Text
653
+ color={isSelected ? 'cyan' : 'white'}
654
+ bold={isSelected}
655
+ >
656
+ {isSelected ? '\u25B6 ' : ' '}
657
+ {core.name}
658
+ </Text>
659
+ </Box>
660
+ <Box marginLeft={4}>
661
+ <Text color="gray" dimColor>{core.systemDescription}</Text>
662
+ </Box>
663
+ </Box>
664
+ );
665
+ })}
666
+
667
+ {/* Scroll indicator - bottom */}
668
+ {installedScrollOffset + MAX_VISIBLE_ITEMS < installedCores.length && (
669
+ <Box>
670
+ <Text color="gray" dimColor>
671
+ {' '}{'\u25BC'} {installedCores.length - installedScrollOffset - MAX_VISIBLE_ITEMS} more below
672
+ </Text>
673
+ </Box>
674
+ )}
675
+ </>
676
+ )}
677
+ </Box>
678
+ ) : (
679
+ <Box flexDirection="column">
680
+ {isLoadingCores ? (
681
+ <Spinner label="Loading available cores..." />
682
+ ) : loadError ? (
683
+ <Box flexDirection="column">
684
+ <Text color="red">Error: {loadError}</Text>
685
+ <Text color="gray" dimColor>Press R to retry</Text>
686
+ </Box>
687
+ ) : filteredAvailableCores.length === 0 ? (
688
+ <Box flexDirection="column">
689
+ <Text color="gray">
690
+ {showAllCores ? 'All cores are already installed' : 'All recommended cores are installed'}
691
+ </Text>
692
+ {!showAllCores && hiddenCoresCount > 0 && (
693
+ <Text color="gray" dimColor>Press A to show {hiddenCoresCount} additional cores</Text>
694
+ )}
695
+ </Box>
696
+ ) : (
697
+ <>
698
+ {/* Show all toggle info */}
699
+ {!showAllCores && hiddenCoresCount > 0 && (
700
+ <Box marginBottom={1}>
701
+ <Text color="gray" dimColor>
702
+ Showing recommended cores. Press A to show {hiddenCoresCount} more.
703
+ </Text>
704
+ </Box>
705
+ )}
706
+ {showAllCores && (
707
+ <Box marginBottom={1}>
708
+ <Text color="gray" dimColor>
709
+ Showing all cores. Press A to show recommended only.
710
+ </Text>
711
+ </Box>
712
+ )}
713
+
714
+ {/* Scroll indicator - top */}
715
+ {downloadScrollOffset > 0 && (
716
+ <Box>
717
+ <Text color="gray" dimColor> {'\u25B2'} {downloadScrollOffset} more above</Text>
718
+ </Box>
719
+ )}
720
+
721
+ {visibleDownloadCores.map((core, index) => {
722
+ const globalIndex = downloadScrollOffset + index;
723
+ const isSelected = globalIndex === downloadSelectedIndex;
724
+ return (
725
+ <Box key={core.name} flexDirection="column">
726
+ <Box>
727
+ <Text
728
+ color={isSelected ? 'cyan' : 'white'}
729
+ bold={isSelected}
730
+ >
731
+ {isSelected ? '\u25B6 ' : ' '}
732
+ {core.name}
733
+ </Text>
734
+ {RECOMMENDED_CORE_NAMES.has(core.name) && (
735
+ <Text color="yellow"> {'\u2605'}</Text>
736
+ )}
737
+ </Box>
738
+ {core.description && (
739
+ <Box marginLeft={4}>
740
+ <Text color="gray" dimColor>{core.description}</Text>
741
+ </Box>
742
+ )}
743
+ </Box>
744
+ );
745
+ })}
746
+
747
+ {/* Scroll indicator - bottom */}
748
+ {downloadScrollOffset + MAX_VISIBLE_ITEMS < filteredAvailableCores.length && (
749
+ <Box>
750
+ <Text color="gray" dimColor>
751
+ {' '}{'\u25BC'} {filteredAvailableCores.length - downloadScrollOffset - MAX_VISIBLE_ITEMS} more below
752
+ </Text>
753
+ </Box>
754
+ )}
755
+ </>
756
+ )}
757
+ </Box>
758
+ )}
759
+
760
+ {/* Help text */}
761
+ <Box marginTop={1} flexDirection="column">
762
+ <Text color="gray" dimColor>
763
+ {'\u2190\u2192'}/Tab: Switch tabs {'\u2191\u2193'}: Navigate ESC: Close
764
+ </Text>
765
+ {activeTab === TAB_INSTALLED && (
766
+ <Text color="gray" dimColor>
767
+ Enter: Remove selected core
768
+ </Text>
769
+ )}
770
+ {activeTab === TAB_DOWNLOAD && (
771
+ <Text color="gray" dimColor>
772
+ Enter: Download A: Toggle all cores R: Refresh
773
+ </Text>
774
+ )}
775
+ </Box>
776
+
777
+ </Box>
778
+ );
779
+ };