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.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ROM Browser UI Component
|
|
3
|
+
*
|
|
4
|
+
* A beautiful terminal UI for browsing and selecting ROMs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useEffect, useMemo, useCallback, useRef, memo } from 'react';
|
|
8
|
+
import { Box, Text, useInput, useApp, useStdin, useStdout } from 'ink';
|
|
9
|
+
import { filter } from 'remeda';
|
|
10
|
+
import type { RomInfo, ThumbnailResult } from '../../frontend/romScanner';
|
|
11
|
+
import { loadAnyThumbnail } from '../../frontend/romScanner';
|
|
12
|
+
import type { SaveStateDetails } from '../../frontend/saveServices';
|
|
13
|
+
import { getSaveStateService } from '../../frontend/serviceProvider';
|
|
14
|
+
import { buildKittyImageSequence, buildKittyDeleteSequence, buildCursorPositionSequence } from '../../utils/kitty';
|
|
15
|
+
import {
|
|
16
|
+
renderThumbnailHalfBlocks,
|
|
17
|
+
buildThumbnailSequence,
|
|
18
|
+
buildThumbnailClearSequence,
|
|
19
|
+
type RenderedThumbnail,
|
|
20
|
+
} from '../../utils/thumbnailRenderer';
|
|
21
|
+
import { updateLastPlayed } from '../../frontend/playlist';
|
|
22
|
+
import { AddRomsPrompt } from '../AddRomsPrompt';
|
|
23
|
+
import { CoreManager } from '../CoreManager';
|
|
24
|
+
import { getSupportedExtensions } from '../../frontend/coreRegistry';
|
|
25
|
+
import { formatRuntimeSeconds } from '../../utils/format';
|
|
26
|
+
import { useGamepadContext } from '../GamepadContext';
|
|
27
|
+
import { useKittyGraphicsSupported } from '../AppCapabilities';
|
|
28
|
+
import { useConfig } from '../ConfigContext';
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_TERM_WIDTH,
|
|
31
|
+
DEFAULT_TERM_HEIGHT,
|
|
32
|
+
} from '..';
|
|
33
|
+
import {
|
|
34
|
+
THUMBNAIL_LOAD_DELAY_MS,
|
|
35
|
+
THUMBNAIL_KITTY_IMAGE_ID,
|
|
36
|
+
THUMBNAIL_DISPLAY_COLS,
|
|
37
|
+
THUMBNAIL_DISPLAY_ROWS,
|
|
38
|
+
ROM_MIN_DISPLAY_WIDTH,
|
|
39
|
+
ROM_LIST_HEADER_ROWS,
|
|
40
|
+
ROM_META_LABEL_WIDTH,
|
|
41
|
+
ROM_PANEL_BORDER_PADDING,
|
|
42
|
+
ROM_SEPARATOR_MAX_WIDTH,
|
|
43
|
+
ROM_SEPARATOR_PADDING,
|
|
44
|
+
ROM_OPTIONS_PANEL_ROWS,
|
|
45
|
+
PERCENT_60,
|
|
46
|
+
PERCENT_40,
|
|
47
|
+
SEARCH_DEBOUNCE_MS,
|
|
48
|
+
MOUSE_BUFFER_MAX_SIZE,
|
|
49
|
+
} from './consts';
|
|
50
|
+
import type { RomBrowserProps, ActionButtonDef, MetadataPanelProps } from './types';
|
|
51
|
+
import { NetplayPanel } from './NetplayPanel';
|
|
52
|
+
import { SettingsPanel } from './SettingsPanel';
|
|
53
|
+
|
|
54
|
+
export * from './types';
|
|
55
|
+
export * from './consts';
|
|
56
|
+
|
|
57
|
+
// Mouse tracking escape sequences (SGR mode 1006 for extended coordinates)
|
|
58
|
+
const ENABLE_MOUSE = '\x1b[?1000h\x1b[?1006h';
|
|
59
|
+
const DISABLE_MOUSE = '\x1b[?1000l\x1b[?1006l';
|
|
60
|
+
|
|
61
|
+
const actionButtons: ActionButtonDef[] = [
|
|
62
|
+
{ id: 'add-roms', label: 'Add ROMs', icon: '\u{1F4C2}' },
|
|
63
|
+
{ id: 'manage-cores', label: 'Manage Cores', icon: '\u{1F9E9}' }, // Puzzle piece
|
|
64
|
+
{ id: 'netplay', label: 'Netplay', icon: '\u{1F310}' }, // Globe icon
|
|
65
|
+
{ id: 'settings', label: 'Settings', icon: '\u2699' },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Action button component
|
|
69
|
+
const ActionButton = ({ button, isSelected, isFocused, highlightBg, highlightFg }: {
|
|
70
|
+
button: ActionButtonDef;
|
|
71
|
+
isSelected: boolean;
|
|
72
|
+
isFocused: boolean;
|
|
73
|
+
highlightBg: string;
|
|
74
|
+
highlightFg: string;
|
|
75
|
+
}) => {
|
|
76
|
+
const showHighlight = isSelected && isFocused;
|
|
77
|
+
return (
|
|
78
|
+
<Box marginRight={1}>
|
|
79
|
+
<Text
|
|
80
|
+
backgroundColor={showHighlight ? highlightBg : undefined}
|
|
81
|
+
color={showHighlight ? highlightFg : isSelected ? 'cyan' : 'gray'}
|
|
82
|
+
bold={showHighlight}
|
|
83
|
+
>
|
|
84
|
+
{' '}{button.icon} {button.label}{' '}
|
|
85
|
+
</Text>
|
|
86
|
+
</Box>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Color schemes for different systems
|
|
91
|
+
const systemColors: Record<string, string> = {
|
|
92
|
+
'Nintendo Entertainment System': 'red',
|
|
93
|
+
'Game Boy': 'green',
|
|
94
|
+
'Game Boy Color': 'magenta',
|
|
95
|
+
'Super Nintendo': 'blue',
|
|
96
|
+
'Sega Genesis': 'cyan',
|
|
97
|
+
'Sega Master System': 'cyan',
|
|
98
|
+
'Sega Game Gear': 'cyan',
|
|
99
|
+
'Game Boy Advance': 'magenta',
|
|
100
|
+
'PC Engine': 'yellow',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const getSystemColor = (system: string): string => systemColors[system] ?? 'white';
|
|
104
|
+
|
|
105
|
+
// Truncate string to fit width
|
|
106
|
+
const truncate = (str: string, maxLength: number): string => {
|
|
107
|
+
if (str.length <= maxLength) {return str;}
|
|
108
|
+
return str.slice(0, maxLength - 1) + '\u2026';
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Filter ROMs by case-insensitive substring match on filename, title, or system
|
|
113
|
+
*/
|
|
114
|
+
const filterRoms = (roms: RomInfo[], query: string): RomInfo[] => {
|
|
115
|
+
const trimmed = query.trim();
|
|
116
|
+
if (!trimmed) {return roms;}
|
|
117
|
+
|
|
118
|
+
const lowerQuery = trimmed.toLowerCase();
|
|
119
|
+
|
|
120
|
+
const matchesQuery = (text: string | undefined): boolean =>
|
|
121
|
+
text !== undefined && text.toLowerCase().includes(lowerQuery);
|
|
122
|
+
|
|
123
|
+
return filter(roms, (rom) =>
|
|
124
|
+
matchesQuery(rom.label) ||
|
|
125
|
+
matchesQuery(rom.filename) ||
|
|
126
|
+
matchesQuery(rom.metadata.title) ||
|
|
127
|
+
matchesQuery(rom.system)
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// ROM list item component
|
|
132
|
+
const RomListItem = ({ rom, isSelected, width, highlightBg, highlightFg }: {
|
|
133
|
+
rom: RomInfo;
|
|
134
|
+
isSelected: boolean;
|
|
135
|
+
width: number;
|
|
136
|
+
highlightBg: string;
|
|
137
|
+
highlightFg: string;
|
|
138
|
+
}) => {
|
|
139
|
+
const color = getSystemColor(rom.system);
|
|
140
|
+
|
|
141
|
+
// Calculate available space for ROM name
|
|
142
|
+
// Format: " [save] name"
|
|
143
|
+
// Show disk emoji for save state, battery emoji for battery save only, nothing for no save
|
|
144
|
+
const saveIndicator = rom.hasSaveState ? '\u{1F4BE} ' : (rom.hasBatterySave ? '\u{1F50B} ' : ' ');
|
|
145
|
+
const SELECTION_PREFIX_WIDTH = 2; // " " or "> "
|
|
146
|
+
const prefixWidth = SELECTION_PREFIX_WIDTH + saveIndicator.length;
|
|
147
|
+
const availableWidth = Math.max(ROM_MIN_DISPLAY_WIDTH, width - prefixWidth);
|
|
148
|
+
|
|
149
|
+
// Prefer playlist label, fall back to filename without extension
|
|
150
|
+
const romName = rom.label ?? rom.filename.replace(/\.[^.]+$/, '');
|
|
151
|
+
const displayName = truncate(romName, availableWidth);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<Box>
|
|
155
|
+
<Text
|
|
156
|
+
backgroundColor={isSelected ? highlightBg : undefined}
|
|
157
|
+
color={isSelected ? highlightFg : color}
|
|
158
|
+
bold={isSelected}
|
|
159
|
+
>
|
|
160
|
+
{isSelected ? '\u25B6 ' : ' '}
|
|
161
|
+
{saveIndicator}{displayName}
|
|
162
|
+
</Text>
|
|
163
|
+
</Box>
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Metadata panel component - memoized to prevent unnecessary re-renders
|
|
168
|
+
const MetadataPanel = memo(({
|
|
169
|
+
rom,
|
|
170
|
+
width,
|
|
171
|
+
height,
|
|
172
|
+
saveStateDetails,
|
|
173
|
+
thumbnail,
|
|
174
|
+
isKittySupported,
|
|
175
|
+
panelStartCol,
|
|
176
|
+
}: MetadataPanelProps) => {
|
|
177
|
+
const color = rom ? getSystemColor(rom.system) : 'gray';
|
|
178
|
+
|
|
179
|
+
// Build metadata lines (computed early so we can use line count for thumbnail positioning)
|
|
180
|
+
const lines: Array<{ label: string; value: string; color?: string }> = [];
|
|
181
|
+
if (rom) {
|
|
182
|
+
const meta = rom.metadata;
|
|
183
|
+
// Title is always shown - prefer playlist label, then ROM header title, then filename as fallback
|
|
184
|
+
const title = rom.label || meta.title || rom.filename.replace(/\.[^.]+$/, '');
|
|
185
|
+
lines.push({ label: 'Title', value: title });
|
|
186
|
+
lines.push({ label: 'System', value: rom.system, color });
|
|
187
|
+
|
|
188
|
+
// Publisher - trim and ignore if starts with "." or ends with ".xxx" or ",xxx" (indicates unavailable)
|
|
189
|
+
const publisher = meta.publisher?.trim();
|
|
190
|
+
const hasInvalidPublisher = !publisher || publisher.startsWith('.') || /[.,][a-zA-Z]{3}$/.test(publisher);
|
|
191
|
+
if (!hasInvalidPublisher) {
|
|
192
|
+
lines.push({ label: 'Publisher', value: publisher });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Playtime (from playlist runtime data)
|
|
196
|
+
if (rom.runtimeSeconds !== undefined && rom.runtimeSeconds > 0) {
|
|
197
|
+
lines.push({ label: 'Playtime', value: `\u{23F1} ${formatRuntimeSeconds(rom.runtimeSeconds)}`, color: 'cyan' });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Last Played - from playlist data
|
|
201
|
+
if (rom.lastPlayed) {
|
|
202
|
+
const formattedDate = rom.lastPlayed.toLocaleDateString();
|
|
203
|
+
const formattedTime = rom.lastPlayed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
204
|
+
lines.push({ label: 'Last Played', value: `\u{1F4C5} ${formattedDate} ${formattedTime}`, color: 'cyan' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Battery save info (.srm file) - only show if no save state exists
|
|
208
|
+
if (rom.hasBatterySave && !rom.hasSaveState) {
|
|
209
|
+
if (rom.batterySaveDate) {
|
|
210
|
+
const batteryDate = rom.batterySaveDate.toLocaleDateString();
|
|
211
|
+
const batteryTime = rom.batterySaveDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
212
|
+
lines.push({ label: 'Battery Save', value: `\u{1F50B} ${batteryDate} ${batteryTime}`, color: 'green' });
|
|
213
|
+
} else {
|
|
214
|
+
lines.push({ label: 'Battery Save', value: '\u{1F50B} Unknown date', color: 'green' });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Save state info (use lazy-loaded savedAt from file contents)
|
|
219
|
+
if (rom.hasSaveState) {
|
|
220
|
+
const savedAt = saveStateDetails?.savedAt;
|
|
221
|
+
if (savedAt) {
|
|
222
|
+
const stateDate = savedAt.toLocaleDateString();
|
|
223
|
+
const stateTime = savedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
224
|
+
lines.push({ label: 'Save State', value: `\u{1F4BE} ${stateDate} ${stateTime}`, color: 'cyan' });
|
|
225
|
+
} else {
|
|
226
|
+
lines.push({ label: 'Save State', value: '\u{1F4BE}', color: 'cyan' });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Render thumbnail using Kitty graphics protocol
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (!thumbnail || !isKittySupported) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Calculate position for thumbnail (after metadata lines)
|
|
239
|
+
// Row calculation:
|
|
240
|
+
// - 4 rows for header: panel border top (1) + "ROM Details" header (1) + marginBottom gap (1) + first line offset (1)
|
|
241
|
+
// - lines.length rows for metadata
|
|
242
|
+
// - 4 rows for gap after metadata
|
|
243
|
+
const HEADER_ROWS = 4;
|
|
244
|
+
const GAP_ROWS = 4;
|
|
245
|
+
const thumbnailRow = HEADER_ROWS + lines.length + GAP_ROWS;
|
|
246
|
+
|
|
247
|
+
// Align thumbnail with the values column (after the label width)
|
|
248
|
+
// Column offset: panelStartCol + paddingX (2) + ROM_META_LABEL_WIDTH (12)
|
|
249
|
+
const thumbnailCol = panelStartCol + 2 + ROM_META_LABEL_WIDTH;
|
|
250
|
+
|
|
251
|
+
// Build and write the image sequence
|
|
252
|
+
const positionSeq = buildCursorPositionSequence(thumbnailRow, thumbnailCol);
|
|
253
|
+
const imageSeq = buildKittyImageSequence(
|
|
254
|
+
thumbnail,
|
|
255
|
+
THUMBNAIL_DISPLAY_COLS,
|
|
256
|
+
THUMBNAIL_DISPLAY_ROWS,
|
|
257
|
+
THUMBNAIL_KITTY_IMAGE_ID
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
process.stdout.write(positionSeq + imageSeq);
|
|
261
|
+
|
|
262
|
+
// Cleanup: delete the image when thumbnail changes or component unmounts
|
|
263
|
+
return () => {
|
|
264
|
+
const deleteSeq = buildKittyDeleteSequence(THUMBNAIL_KITTY_IMAGE_ID);
|
|
265
|
+
process.stdout.write(deleteSeq);
|
|
266
|
+
};
|
|
267
|
+
}, [thumbnail, isKittySupported, panelStartCol, lines.length]);
|
|
268
|
+
|
|
269
|
+
// Render thumbnail using half-block characters (fallback when Kitty not supported)
|
|
270
|
+
const [halfBlockThumbnail, setHalfBlockThumbnail] = useState<RenderedThumbnail | undefined>();
|
|
271
|
+
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (!thumbnail || isKittySupported) {
|
|
274
|
+
setHalfBlockThumbnail(undefined);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Decode and render the thumbnail asynchronously
|
|
279
|
+
let cancelled = false;
|
|
280
|
+
renderThumbnailHalfBlocks(thumbnail, THUMBNAIL_DISPLAY_COLS, THUMBNAIL_DISPLAY_ROWS)
|
|
281
|
+
.then((rendered) => {
|
|
282
|
+
if (!cancelled && rendered) {
|
|
283
|
+
setHalfBlockThumbnail(rendered);
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
.catch(() => {
|
|
287
|
+
// Silently ignore errors
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return () => {
|
|
291
|
+
cancelled = true;
|
|
292
|
+
};
|
|
293
|
+
}, [thumbnail, isKittySupported]);
|
|
294
|
+
|
|
295
|
+
// Write half-block thumbnail to terminal
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (!halfBlockThumbnail || isKittySupported) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Calculate position (same as Kitty thumbnail)
|
|
302
|
+
const HEADER_ROWS = 4;
|
|
303
|
+
const GAP_ROWS = 4;
|
|
304
|
+
const thumbnailRow = HEADER_ROWS + lines.length + GAP_ROWS;
|
|
305
|
+
const thumbnailCol = panelStartCol + 2 + ROM_META_LABEL_WIDTH;
|
|
306
|
+
|
|
307
|
+
// Render the half-block thumbnail
|
|
308
|
+
const seq = buildThumbnailSequence(halfBlockThumbnail, thumbnailRow, thumbnailCol);
|
|
309
|
+
process.stdout.write(seq);
|
|
310
|
+
|
|
311
|
+
// Cleanup: clear the thumbnail area
|
|
312
|
+
return () => {
|
|
313
|
+
const clearSeq = buildThumbnailClearSequence(
|
|
314
|
+
halfBlockThumbnail.width,
|
|
315
|
+
halfBlockThumbnail.height,
|
|
316
|
+
thumbnailRow,
|
|
317
|
+
thumbnailCol
|
|
318
|
+
);
|
|
319
|
+
process.stdout.write(clearSeq);
|
|
320
|
+
};
|
|
321
|
+
}, [halfBlockThumbnail, isKittySupported, panelStartCol, lines.length]);
|
|
322
|
+
|
|
323
|
+
if (!rom) {
|
|
324
|
+
return (
|
|
325
|
+
<Box
|
|
326
|
+
flexDirection="column"
|
|
327
|
+
width={width}
|
|
328
|
+
height={height}
|
|
329
|
+
borderStyle="round"
|
|
330
|
+
borderColor="gray"
|
|
331
|
+
paddingX={1}
|
|
332
|
+
>
|
|
333
|
+
<Text color="gray" italic>No ROM selected</Text>
|
|
334
|
+
</Box>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Calculate available lines for metadata (account for header, footer, borders)
|
|
339
|
+
// Reserve space for thumbnail (Kitty or half-block)
|
|
340
|
+
const hasThumbnailToShow = thumbnail && (isKittySupported || halfBlockThumbnail);
|
|
341
|
+
const thumbnailRowsReserved = hasThumbnailToShow ? THUMBNAIL_DISPLAY_ROWS + 2 : 0;
|
|
342
|
+
const availableLines = height - ROM_LIST_HEADER_ROWS - thumbnailRowsReserved;
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<Box
|
|
346
|
+
flexDirection="column"
|
|
347
|
+
width={width}
|
|
348
|
+
height={height}
|
|
349
|
+
borderStyle="round"
|
|
350
|
+
borderColor={color}
|
|
351
|
+
paddingX={1}
|
|
352
|
+
>
|
|
353
|
+
<Box marginBottom={1}>
|
|
354
|
+
<Text bold color={color}>ROM Details</Text>
|
|
355
|
+
</Box>
|
|
356
|
+
|
|
357
|
+
{lines.slice(0, availableLines).map((line, i) => (
|
|
358
|
+
<Box key={i}>
|
|
359
|
+
<Text color="gray">{line.label.padEnd(ROM_META_LABEL_WIDTH)}</Text>
|
|
360
|
+
<Text color={line.color ?? 'white'}>{truncate(line.value, width - ROM_PANEL_BORDER_PADDING)}</Text>
|
|
361
|
+
</Box>
|
|
362
|
+
))}
|
|
363
|
+
|
|
364
|
+
{/* Spacer for thumbnail area (Kitty or half-block) */}
|
|
365
|
+
{hasThumbnailToShow && (
|
|
366
|
+
<Box height={THUMBNAIL_DISPLAY_ROWS + 1} />
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
<Box marginTop={1} flexGrow={1} />
|
|
370
|
+
|
|
371
|
+
<Box borderStyle="single" borderColor="gray" borderTop borderBottom={false} borderLeft={false} borderRight={false}>
|
|
372
|
+
<Text color="gray" dimColor>
|
|
373
|
+
{'\u23CE'} Play {'\u2191\u2193'} Navigate
|
|
374
|
+
</Text>
|
|
375
|
+
</Box>
|
|
376
|
+
</Box>
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Header component
|
|
381
|
+
const Header = ({ romCount, totalCount, searchQuery }: { romCount: number; totalCount: number; searchQuery: string }) => {
|
|
382
|
+
const { stdout } = useStdout();
|
|
383
|
+
const separatorWidth = Math.min(ROM_SEPARATOR_MAX_WIDTH, (stdout.columns || DEFAULT_TERM_WIDTH) - ROM_SEPARATOR_PADDING + 1);
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
387
|
+
<Box>
|
|
388
|
+
<Text bold color="cyan">
|
|
389
|
+
{'\u{1F3AE}'} emoemu
|
|
390
|
+
</Text>
|
|
391
|
+
<Text color="gray"> - </Text>
|
|
392
|
+
{searchQuery ? (
|
|
393
|
+
<>
|
|
394
|
+
<Text color="yellow">{romCount.toLocaleString()}</Text>
|
|
395
|
+
<Text color="gray">/{totalCount.toLocaleString()} matching "</Text>
|
|
396
|
+
<Text color="yellow">{searchQuery}</Text>
|
|
397
|
+
<Text color="gray">"</Text>
|
|
398
|
+
</>
|
|
399
|
+
) : (
|
|
400
|
+
<Text color="white">{romCount.toLocaleString()} {romCount === 1 ? 'ROM' : 'ROMs'}</Text>
|
|
401
|
+
)}
|
|
402
|
+
</Box>
|
|
403
|
+
{searchQuery ? (
|
|
404
|
+
<Box>
|
|
405
|
+
<Text color="gray" dimColor>
|
|
406
|
+
{'\u{1F50D}'} Filter: </Text>
|
|
407
|
+
<Text color="yellow" bold>{searchQuery}</Text>
|
|
408
|
+
<Text color="gray" dimColor> (ESC to clear)</Text>
|
|
409
|
+
</Box>
|
|
410
|
+
) : (
|
|
411
|
+
<Box>
|
|
412
|
+
<Text color="gray" dimColor>
|
|
413
|
+
{'━'.repeat(separatorWidth)}
|
|
414
|
+
</Text>
|
|
415
|
+
</Box>
|
|
416
|
+
)}
|
|
417
|
+
</Box>
|
|
418
|
+
);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Empty state component
|
|
422
|
+
const EmptyState = () => {
|
|
423
|
+
const extensions = getSupportedExtensions();
|
|
424
|
+
const extensionList = extensions.length > 0
|
|
425
|
+
? extensions.join(', ')
|
|
426
|
+
: 'No cores loaded';
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
<Box flexDirection="column" padding={2}>
|
|
430
|
+
<Text color="yellow">{'\u26A0'} No ROMs found in this directory</Text>
|
|
431
|
+
<Box marginTop={1}>
|
|
432
|
+
<Text color="gray">Supported formats ({extensions.length}):</Text>
|
|
433
|
+
</Box>
|
|
434
|
+
<Text color="white">{extensionList}</Text>
|
|
435
|
+
</Box>
|
|
436
|
+
);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// Main browser component
|
|
440
|
+
export const RomBrowser = ({ roms, playlistDirectory, scanDepth, onSelect, onExit: _onExit, onRefresh, initialSelection, initialFilter, showSettingsOnMount, lastPlayedRom, showNetplayOnMount, onScaleFactorChange }: RomBrowserProps) => {
|
|
441
|
+
const { exit } = useApp();
|
|
442
|
+
const { stdout } = useStdout();
|
|
443
|
+
const { config: localConfig, reloadConfig } = useConfig();
|
|
444
|
+
|
|
445
|
+
// Track previous scale factor to detect changes
|
|
446
|
+
const prevScaleFactorRef = useRef(localConfig.menu_scale_factor);
|
|
447
|
+
|
|
448
|
+
// Call onScaleFactorChange when menu_scale_factor changes
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
const currentScale = localConfig.menu_scale_factor;
|
|
451
|
+
const prevScale = prevScaleFactorRef.current;
|
|
452
|
+
|
|
453
|
+
// Check if scale factor actually changed (handle null comparison)
|
|
454
|
+
const changed = currentScale !== prevScale;
|
|
455
|
+
if (changed && onScaleFactorChange) {
|
|
456
|
+
onScaleFactorChange(currentScale);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
prevScaleFactorRef.current = currentScale;
|
|
460
|
+
}, [localConfig.menu_scale_factor, onScaleFactorChange]);
|
|
461
|
+
|
|
462
|
+
// Initialize search query with initial filter
|
|
463
|
+
// searchQuery is for immediate display, debouncedSearchQuery is for filtering
|
|
464
|
+
const [searchQuery, setSearchQuery] = useState(initialFilter ?? '');
|
|
465
|
+
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(initialFilter ?? '');
|
|
466
|
+
|
|
467
|
+
// Debounce search query updates to avoid filtering on every keystroke
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
const timer = setTimeout(() => {
|
|
470
|
+
setDebouncedSearchQuery(searchQuery);
|
|
471
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
472
|
+
return () => clearTimeout(timer);
|
|
473
|
+
}, [searchQuery]);
|
|
474
|
+
|
|
475
|
+
// Compute initial filtered list for finding initial selection
|
|
476
|
+
const initialFilteredRoms = initialFilter ? filterRoms(roms, initialFilter) : roms;
|
|
477
|
+
|
|
478
|
+
const [selectedIndex, setSelectedIndex] = useState(() => {
|
|
479
|
+
// Find initial selection index in the FILTERED list
|
|
480
|
+
if (initialSelection) {
|
|
481
|
+
const index = initialFilteredRoms.findIndex(rom => rom.path === initialSelection);
|
|
482
|
+
if (index !== -1) {return index;}
|
|
483
|
+
}
|
|
484
|
+
return 0;
|
|
485
|
+
});
|
|
486
|
+
const [scrollOffset, setScrollOffset] = useState(() => {
|
|
487
|
+
// Calculate initial scroll offset to show selected item
|
|
488
|
+
if (initialSelection) {
|
|
489
|
+
const index = initialFilteredRoms.findIndex(rom => rom.path === initialSelection);
|
|
490
|
+
if (index !== -1) {
|
|
491
|
+
const termHeight = process.stdout.rows || DEFAULT_TERM_HEIGHT;
|
|
492
|
+
const listHeight = termHeight - ROM_OPTIONS_PANEL_ROWS;
|
|
493
|
+
const MIN_VISIBLE = 1;
|
|
494
|
+
const VISIBLE_PADDING = 2;
|
|
495
|
+
const visibleItems = Math.max(MIN_VISIBLE, listHeight - VISIBLE_PADDING);
|
|
496
|
+
// Center the selection if possible
|
|
497
|
+
return Math.max(0, index - Math.floor(visibleItems / 2));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return 0;
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// Force re-render on mount to trigger Ink's terminal setup before user interaction
|
|
504
|
+
const [, setMountRender] = useState(0);
|
|
505
|
+
useEffect(() => {
|
|
506
|
+
// Trigger a re-render after mount to ensure proper layout
|
|
507
|
+
setMountRender(1);
|
|
508
|
+
}, []);
|
|
509
|
+
|
|
510
|
+
// Action buttons state
|
|
511
|
+
const [actionButtonsFocused, setActionButtonsFocused] = useState(false);
|
|
512
|
+
const [actionButtonIndex, setActionButtonIndex] = useState(0);
|
|
513
|
+
|
|
514
|
+
// Auto-focus action buttons when no ROMs are found (so user can press Enter to add ROMs)
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
if (roms.length === 0) {
|
|
517
|
+
setActionButtonsFocused(true);
|
|
518
|
+
setActionButtonIndex(0); // Focus "Add ROMs" button
|
|
519
|
+
}
|
|
520
|
+
}, [roms.length]);
|
|
521
|
+
|
|
522
|
+
// Add ROMs prompt state
|
|
523
|
+
const [showAddRomsPrompt, setShowAddRomsPrompt] = useState(false);
|
|
524
|
+
|
|
525
|
+
// Settings panel state - open on mount if coming from a game
|
|
526
|
+
const [showSettings, setShowSettings] = useState(showSettingsOnMount ?? false);
|
|
527
|
+
// Track resumable ROM locally - cleared when settings is closed (Resume Game only shows once after exiting a game)
|
|
528
|
+
const [resumableRom, setResumableRom] = useState<RomInfo | undefined>(lastPlayedRom);
|
|
529
|
+
|
|
530
|
+
// Netplay panel state
|
|
531
|
+
const [showNetplay, setShowNetplay] = useState(showNetplayOnMount ?? false);
|
|
532
|
+
// Track if we're returning from a netplay disconnect (to default to Join mode)
|
|
533
|
+
const [netplayReturnFromDisconnect, setNetplayReturnFromDisconnect] = useState(showNetplayOnMount ?? false);
|
|
534
|
+
|
|
535
|
+
// Core manager panel state
|
|
536
|
+
const [showCoreManager, setShowCoreManager] = useState(false);
|
|
537
|
+
|
|
538
|
+
// Filter ROMs based on search query (memoized to avoid filtering on every render)
|
|
539
|
+
const filteredRoms = useMemo(() => filterRoms(roms, debouncedSearchQuery), [roms, debouncedSearchQuery]);
|
|
540
|
+
|
|
541
|
+
// Calculate dimensions - use Ink's stdout for native mode compatibility
|
|
542
|
+
const termWidth = stdout.columns || DEFAULT_TERM_WIDTH;
|
|
543
|
+
const termHeight = stdout.rows || DEFAULT_TERM_HEIGHT;
|
|
544
|
+
|
|
545
|
+
// Layout: left panel (ROM list) takes 60%, right panel (metadata) takes 40%
|
|
546
|
+
const LAYOUT_BORDER_PADDING = 4;
|
|
547
|
+
const listWidth = Math.floor((termWidth - LAYOUT_BORDER_PADDING) * PERCENT_60);
|
|
548
|
+
const metaWidth = Math.floor((termWidth - LAYOUT_BORDER_PADDING) * PERCENT_40);
|
|
549
|
+
const listHeight = termHeight - ROM_OPTIONS_PANEL_ROWS; // Account for header and margins
|
|
550
|
+
|
|
551
|
+
// Visible items in the list (each item takes 2 lines: content + separator)
|
|
552
|
+
const MIN_ITEMS = 1;
|
|
553
|
+
const ITEMS_PER_ROW = 2;
|
|
554
|
+
const visibleItems = Math.max(MIN_ITEMS, Math.floor((listHeight - 1) / ITEMS_PER_ROW));
|
|
555
|
+
|
|
556
|
+
// Calculate scrollbar dimensions (needed for mouse handling)
|
|
557
|
+
const scrollbarTrackHeight = listHeight - 2; // Subtract 2 for top/bottom border
|
|
558
|
+
|
|
559
|
+
// Refs for values that need to be current in callbacks (avoids stale closures)
|
|
560
|
+
const scrollOffsetRef = useRef(scrollOffset);
|
|
561
|
+
const visibleItemsRef = useRef(visibleItems);
|
|
562
|
+
const filteredRomsRef = useRef(filteredRoms);
|
|
563
|
+
scrollOffsetRef.current = scrollOffset;
|
|
564
|
+
visibleItemsRef.current = visibleItems;
|
|
565
|
+
filteredRomsRef.current = filteredRoms;
|
|
566
|
+
|
|
567
|
+
// Track if this is the first render (to skip resetting on mount)
|
|
568
|
+
const isFirstRenderRef = useRef(true);
|
|
569
|
+
|
|
570
|
+
// Reset selection when filter changes (but not on initial mount)
|
|
571
|
+
useEffect(() => {
|
|
572
|
+
if (isFirstRenderRef.current) {
|
|
573
|
+
isFirstRenderRef.current = false;
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
setSelectedIndex(0);
|
|
577
|
+
setScrollOffset(0);
|
|
578
|
+
}, [debouncedSearchQuery]);
|
|
579
|
+
|
|
580
|
+
// Mouse support for scrollbar and ROM list
|
|
581
|
+
const { stdin, setRawMode } = useStdin();
|
|
582
|
+
|
|
583
|
+
// Calculate positions for mouse hit detection
|
|
584
|
+
// Header takes ~3 rows, list box border takes 1 row, so content starts at row 5
|
|
585
|
+
const listStartRow = 4; // Row where list content begins (after header + border)
|
|
586
|
+
const listStartCol = 1; // Column where list starts
|
|
587
|
+
const listEndCol = listWidth - 2; // Column where list content ends (before scrollbar)
|
|
588
|
+
const scrollbarCol = listWidth - 1; // 1-indexed column position for scrollbar
|
|
589
|
+
|
|
590
|
+
// Enable mouse tracking and handle mouse events
|
|
591
|
+
// Mouse handlers are defined inside the effect to avoid stale closure issues.
|
|
592
|
+
// They use refs to access current state values.
|
|
593
|
+
useEffect(() => {
|
|
594
|
+
// Handle mouse click on scrollbar
|
|
595
|
+
const handleScrollbarClick = (clickRow: number) => {
|
|
596
|
+
const trackPosition = clickRow - listStartRow;
|
|
597
|
+
if (trackPosition < 0 || trackPosition >= scrollbarTrackHeight) {return;}
|
|
598
|
+
|
|
599
|
+
const scrollRatio = trackPosition / (scrollbarTrackHeight - 1);
|
|
600
|
+
const maxScroll = Math.max(0, filteredRomsRef.current.length - visibleItemsRef.current);
|
|
601
|
+
const newOffset = Math.round(scrollRatio * maxScroll);
|
|
602
|
+
|
|
603
|
+
setScrollOffset(newOffset);
|
|
604
|
+
setSelectedIndex(Math.min(newOffset, filteredRomsRef.current.length - 1));
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// Handle mouse click on ROM list item
|
|
608
|
+
const handleListClick = (clickRow: number) => {
|
|
609
|
+
const rowInList = clickRow - listStartRow;
|
|
610
|
+
if (rowInList < 0 || rowInList >= scrollbarTrackHeight) {return;}
|
|
611
|
+
|
|
612
|
+
const itemIndex = Math.floor(rowInList / 2);
|
|
613
|
+
const absoluteIndex = scrollOffsetRef.current + itemIndex;
|
|
614
|
+
|
|
615
|
+
if (absoluteIndex >= 0 && absoluteIndex < filteredRomsRef.current.length) {
|
|
616
|
+
setSelectedIndex(absoluteIndex);
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Enable mouse tracking
|
|
621
|
+
process.stdout.write(ENABLE_MOUSE);
|
|
622
|
+
setRawMode(true);
|
|
623
|
+
|
|
624
|
+
let buffer = '';
|
|
625
|
+
|
|
626
|
+
// Pre-compiled regex for SGR mouse events (reused across calls)
|
|
627
|
+
const sgrRegex = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
628
|
+
|
|
629
|
+
const handleData = (data: Buffer) => {
|
|
630
|
+
buffer += data.toString();
|
|
631
|
+
|
|
632
|
+
// Guard against unbounded buffer growth
|
|
633
|
+
if (buffer.length > MOUSE_BUFFER_MAX_SIZE) {
|
|
634
|
+
const lastEsc = buffer.lastIndexOf('\x1b');
|
|
635
|
+
buffer = lastEsc >= 0 ? buffer.slice(lastEsc) : '';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Parse SGR mouse events: \x1b[<button;x;yM (press) or \x1b[<button;x;ym (release)
|
|
639
|
+
sgrRegex.lastIndex = 0; // Reset regex state
|
|
640
|
+
let match;
|
|
641
|
+
let lastMatchEnd = 0;
|
|
642
|
+
|
|
643
|
+
while ((match = sgrRegex.exec(buffer)) !== null) {
|
|
644
|
+
lastMatchEnd = sgrRegex.lastIndex;
|
|
645
|
+
const button = parseInt(match[1], 10);
|
|
646
|
+
const x = parseInt(match[2], 10);
|
|
647
|
+
const y = parseInt(match[3], 10);
|
|
648
|
+
const isPress = match[4] === 'M';
|
|
649
|
+
|
|
650
|
+
// Only handle left click press (button 0)
|
|
651
|
+
if (isPress && button === 0) {
|
|
652
|
+
// Check if click is on scrollbar column
|
|
653
|
+
if (x === scrollbarCol || x === scrollbarCol + 1) {
|
|
654
|
+
handleScrollbarClick(y);
|
|
655
|
+
} else if (x >= listStartCol && x <= listEndCol) {
|
|
656
|
+
// Click is in the ROM list area
|
|
657
|
+
handleListClick(y);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Clear processed data from buffer, keep only unprocessed tail
|
|
663
|
+
if (lastMatchEnd > 0) {
|
|
664
|
+
buffer = buffer.slice(lastMatchEnd);
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
stdin.on('data', handleData);
|
|
669
|
+
|
|
670
|
+
return () => {
|
|
671
|
+
stdin.off('data', handleData);
|
|
672
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
673
|
+
};
|
|
674
|
+
}, [stdin, setRawMode, scrollbarCol, listStartCol, listEndCol, listStartRow, scrollbarTrackHeight]);
|
|
675
|
+
|
|
676
|
+
// Get selected ROM for display (may be undefined if filtered list is empty)
|
|
677
|
+
// Using .at() which properly returns T | undefined instead of bracket notation
|
|
678
|
+
const selectedRom = filteredRoms.at(selectedIndex);
|
|
679
|
+
|
|
680
|
+
// Cache for loaded save state details (keyed by ROM path)
|
|
681
|
+
const saveStateDetailsCacheRef = useRef<Map<string, SaveStateDetails>>(new Map());
|
|
682
|
+
// Cache for loaded thumbnails (keyed by ROM path)
|
|
683
|
+
const thumbnailCacheRef = useRef<Map<string, ThumbnailResult | null>>(new Map());
|
|
684
|
+
// Track which path's details are ready to render (after load + delay)
|
|
685
|
+
const [detailsReadyPath, setDetailsReadyPath] = useState<string | null>(null);
|
|
686
|
+
|
|
687
|
+
// Use detected Kitty graphics support for thumbnails (independent of video_driver setting)
|
|
688
|
+
const isKittySupported = useKittyGraphicsSupported();
|
|
689
|
+
|
|
690
|
+
// Load save state details and thumbnail lazily when selected ROM changes.
|
|
691
|
+
// IMPORTANT: File I/O is debounced to prevent blocking the event loop during
|
|
692
|
+
// rapid scrolling. Without debouncing, synchronous file reads (existsSync,
|
|
693
|
+
// readFileSync, gunzipSync) block input processing and cause the UI to freeze.
|
|
694
|
+
useEffect(() => {
|
|
695
|
+
// Handle empty filtered list (no ROM selected)
|
|
696
|
+
if (!selectedRom) {
|
|
697
|
+
setDetailsReadyPath(null);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const romPath = selectedRom.path;
|
|
701
|
+
const hasSaveState = selectedRom.hasSaveState;
|
|
702
|
+
const romForThumbnail = selectedRom;
|
|
703
|
+
|
|
704
|
+
// Clear ready state while we wait for debounce
|
|
705
|
+
setDetailsReadyPath(null);
|
|
706
|
+
|
|
707
|
+
// Debounce the file I/O operations to avoid blocking during rapid scrolling
|
|
708
|
+
const timer = setTimeout(() => {
|
|
709
|
+
const saveStateCache = saveStateDetailsCacheRef.current;
|
|
710
|
+
const thumbCache = thumbnailCacheRef.current;
|
|
711
|
+
|
|
712
|
+
// Load save state details if not cached and ROM has a save state
|
|
713
|
+
if (hasSaveState && !saveStateCache.has(romPath)) {
|
|
714
|
+
const details = getSaveStateService().loadDetails(romPath);
|
|
715
|
+
saveStateCache.set(romPath, details);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Load thumbnail if not cached (works with both Kitty and half-block renderers)
|
|
719
|
+
if (!thumbCache.has(romPath)) {
|
|
720
|
+
const thumbnail = loadAnyThumbnail(romForThumbnail);
|
|
721
|
+
thumbCache.set(romPath, thumbnail ?? null);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
setDetailsReadyPath(romPath);
|
|
725
|
+
}, THUMBNAIL_LOAD_DELAY_MS);
|
|
726
|
+
|
|
727
|
+
return () => clearTimeout(timer);
|
|
728
|
+
}, [selectedRom?.path, selectedRom?.hasSaveState]);
|
|
729
|
+
|
|
730
|
+
// Get the details for the current selection (from ref cache)
|
|
731
|
+
const selectedRomDetails = selectedRom?.hasSaveState && detailsReadyPath === selectedRom.path
|
|
732
|
+
? saveStateDetailsCacheRef.current.get(selectedRom.path)
|
|
733
|
+
: undefined;
|
|
734
|
+
|
|
735
|
+
// Get the thumbnail for the current selection (from ref cache)
|
|
736
|
+
const selectedRomThumbnail = selectedRom && detailsReadyPath === selectedRom.path
|
|
737
|
+
? (thumbnailCacheRef.current.get(selectedRom.path) ?? undefined)
|
|
738
|
+
: undefined;
|
|
739
|
+
|
|
740
|
+
// Migrate save state timestamp to playlist last_played if not already set
|
|
741
|
+
// This is a one-time migration for ROMs that were played before last_played tracking was added
|
|
742
|
+
const migratedDatesRef = useRef<Map<string, Date>>(new Map());
|
|
743
|
+
useEffect(() => {
|
|
744
|
+
if (!selectedRom || selectedRom.lastPlayed) {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Only migrate from save state savedAt
|
|
749
|
+
const dateToMigrate = selectedRomDetails?.savedAt;
|
|
750
|
+
if (!dateToMigrate) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Skip if already migrated this session
|
|
755
|
+
if (migratedDatesRef.current.has(selectedRom.path)) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Store migrated date and update playlist
|
|
760
|
+
migratedDatesRef.current.set(selectedRom.path, dateToMigrate);
|
|
761
|
+
updateLastPlayed(selectedRom.path, playlistDirectory, dateToMigrate);
|
|
762
|
+
}, [selectedRom, selectedRomDetails?.savedAt, playlistDirectory]);
|
|
763
|
+
|
|
764
|
+
// Create ROM with lastPlayed populated (from playlist or migration)
|
|
765
|
+
const selectedRomWithLastPlayed = useMemo(() => {
|
|
766
|
+
if (!selectedRom) {
|
|
767
|
+
return undefined;
|
|
768
|
+
}
|
|
769
|
+
if (selectedRom.lastPlayed) {
|
|
770
|
+
return selectedRom;
|
|
771
|
+
}
|
|
772
|
+
const migratedDate = migratedDatesRef.current.get(selectedRom.path);
|
|
773
|
+
if (migratedDate) {
|
|
774
|
+
return { ...selectedRom, lastPlayed: migratedDate };
|
|
775
|
+
}
|
|
776
|
+
return selectedRom;
|
|
777
|
+
}, [selectedRom, selectedRomDetails?.savedAt]); // Re-compute when savedAt loads (triggers migration)
|
|
778
|
+
|
|
779
|
+
// Handle keyboard input
|
|
780
|
+
useInput((input, key) => {
|
|
781
|
+
// Skip main input handling when overlays are shown
|
|
782
|
+
if (showAddRomsPrompt || showSettings || showNetplay || showCoreManager) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Tab: toggle focus between ROM list and action buttons
|
|
787
|
+
if (key.tab) {
|
|
788
|
+
setActionButtonsFocused(prev => !prev);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ESC: if action buttons focused, return to list; otherwise clear filter or show settings
|
|
793
|
+
if (key.escape) {
|
|
794
|
+
if (actionButtonsFocused) {
|
|
795
|
+
setActionButtonsFocused(false);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (searchQuery) {
|
|
799
|
+
setSearchQuery('');
|
|
800
|
+
} else {
|
|
801
|
+
setShowSettings(true);
|
|
802
|
+
}
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Backspace: remove last character from search (only when list is focused)
|
|
807
|
+
if ((key.backspace || key.delete) && !actionButtonsFocused) {
|
|
808
|
+
setSearchQuery(prev => prev.slice(0, -1));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// When action buttons are focused, handle horizontal navigation
|
|
813
|
+
if (actionButtonsFocused) {
|
|
814
|
+
if (key.leftArrow) {
|
|
815
|
+
setActionButtonIndex(prev => Math.max(0, prev - 1));
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (key.rightArrow) {
|
|
819
|
+
setActionButtonIndex(prev => Math.min(actionButtons.length - 1, prev + 1));
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (key.return) {
|
|
823
|
+
// Handle action button activation
|
|
824
|
+
const button = actionButtons[actionButtonIndex];
|
|
825
|
+
if (button.id === 'add-roms') {
|
|
826
|
+
setShowAddRomsPrompt(true);
|
|
827
|
+
setActionButtonsFocused(false);
|
|
828
|
+
} else if (button.id === 'manage-cores') {
|
|
829
|
+
setShowCoreManager(true);
|
|
830
|
+
setActionButtonsFocused(false);
|
|
831
|
+
} else if (button.id === 'netplay') {
|
|
832
|
+
// Can only start netplay if a ROM is selected
|
|
833
|
+
if (filteredRoms.length > 0) {
|
|
834
|
+
setShowNetplay(true);
|
|
835
|
+
setActionButtonsFocused(false);
|
|
836
|
+
}
|
|
837
|
+
} else if (button.id === 'settings') {
|
|
838
|
+
setShowSettings(true);
|
|
839
|
+
setActionButtonsFocused(false);
|
|
840
|
+
}
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
// Ignore other keys when action buttons focused
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Navigation with arrow keys and page up/down only (when ROM list is focused)
|
|
848
|
+
// Note: We only update selectedIndex here. The useEffect keeps scrollOffset in sync.
|
|
849
|
+
// We use refs for filteredRoms and visibleItems to avoid stale closure issues
|
|
850
|
+
// during rapid key presses.
|
|
851
|
+
if (key.upArrow) {
|
|
852
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (key.downArrow) {
|
|
857
|
+
setSelectedIndex((prev) => Math.min(filteredRomsRef.current.length - 1, prev + 1));
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (key.pageUp) {
|
|
862
|
+
setSelectedIndex((prev) => Math.max(0, prev - visibleItemsRef.current));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (key.pageDown) {
|
|
867
|
+
setSelectedIndex((prev) => Math.min(filteredRomsRef.current.length - 1, prev + visibleItemsRef.current));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Home/End for jumping to top/bottom
|
|
872
|
+
if (key.home) {
|
|
873
|
+
setSelectedIndex(0);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (key.end) {
|
|
878
|
+
setSelectedIndex(filteredRomsRef.current.length - 1);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (key.return) {
|
|
883
|
+
const rom = filteredRoms.at(selectedIndex);
|
|
884
|
+
if (!rom) {return;} // No ROM selected (empty filtered list)
|
|
885
|
+
onSelect(rom, searchQuery);
|
|
886
|
+
exit();
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Any printable character adds to search
|
|
891
|
+
if (input && input.length === 1 && !key.ctrl && !key.meta) {
|
|
892
|
+
setSearchQuery(prev => prev + input);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// Handle gamepad input
|
|
897
|
+
// Note: Like keyboard input, we only update selectedIndex here and let the
|
|
898
|
+
// useEffect sync scrollOffset. We use refs to avoid stale closure issues.
|
|
899
|
+
useGamepadContext({
|
|
900
|
+
onUp: () => {
|
|
901
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
902
|
+
},
|
|
903
|
+
onDown: () => {
|
|
904
|
+
setSelectedIndex((prev) => Math.min(filteredRomsRef.current.length - 1, prev + 1));
|
|
905
|
+
},
|
|
906
|
+
onConfirm: () => {
|
|
907
|
+
const rom = filteredRoms.at(selectedIndex);
|
|
908
|
+
if (!rom) {return;} // No ROM selected (empty filtered list)
|
|
909
|
+
onSelect(rom, searchQuery);
|
|
910
|
+
exit();
|
|
911
|
+
},
|
|
912
|
+
onCancel: () => {
|
|
913
|
+
// B button clears filter but doesn't exit app (use keyboard Esc to exit)
|
|
914
|
+
if (searchQuery) {
|
|
915
|
+
setSearchQuery('');
|
|
916
|
+
}
|
|
917
|
+
},
|
|
918
|
+
onGuide: () => {
|
|
919
|
+
// Guide/Xbox button opens settings
|
|
920
|
+
setShowSettings(true);
|
|
921
|
+
},
|
|
922
|
+
}, !showSettings && !showAddRomsPrompt && !showNetplay && !showCoreManager); // Disable when overlays are shown
|
|
923
|
+
|
|
924
|
+
// Keep scroll in sync with selection
|
|
925
|
+
// Uses refs to read current values without triggering effect re-runs when scrollOffset changes
|
|
926
|
+
useEffect(() => {
|
|
927
|
+
const currentScrollOffset = scrollOffsetRef.current;
|
|
928
|
+
const currentVisibleItems = visibleItemsRef.current;
|
|
929
|
+
if (selectedIndex < currentScrollOffset) {
|
|
930
|
+
setScrollOffset(selectedIndex);
|
|
931
|
+
} else if (selectedIndex >= currentScrollOffset + currentVisibleItems) {
|
|
932
|
+
setScrollOffset(selectedIndex - currentVisibleItems + 1);
|
|
933
|
+
}
|
|
934
|
+
}, [selectedIndex]);
|
|
935
|
+
|
|
936
|
+
// Clamp selection to valid range when filtered list changes
|
|
937
|
+
useEffect(() => {
|
|
938
|
+
if (selectedIndex >= filteredRoms.length) {
|
|
939
|
+
setSelectedIndex(Math.max(0, filteredRoms.length - 1));
|
|
940
|
+
}
|
|
941
|
+
}, [filteredRoms.length, selectedIndex]);
|
|
942
|
+
|
|
943
|
+
// Get visible ROMs for current scroll position
|
|
944
|
+
const visibleRoms = filteredRoms.slice(scrollOffset, scrollOffset + visibleItems);
|
|
945
|
+
|
|
946
|
+
// Calculate scrollbar position and size
|
|
947
|
+
const scrollbarHeight = Math.max(1, Math.floor(scrollbarTrackHeight * visibleItems / filteredRoms.length));
|
|
948
|
+
const scrollbarPosition = Math.floor((scrollOffset / Math.max(1, filteredRoms.length - visibleItems)) * (scrollbarTrackHeight - scrollbarHeight));
|
|
949
|
+
|
|
950
|
+
// Empty space elements to fill unused list rows
|
|
951
|
+
const emptySpaceCount = Math.max(0, visibleItems - visibleRoms.length);
|
|
952
|
+
const emptySpaceElements = Array.from({ length: emptySpaceCount }, (_, i) => (
|
|
953
|
+
<Box key={`empty-${i}`}>
|
|
954
|
+
<Text> </Text>
|
|
955
|
+
</Box>
|
|
956
|
+
));
|
|
957
|
+
|
|
958
|
+
// Scrollbar track elements
|
|
959
|
+
const scrollbarElements = Array.from({ length: scrollbarTrackHeight }, (_, i) => {
|
|
960
|
+
const isThumb = i >= scrollbarPosition && i < scrollbarPosition + scrollbarHeight;
|
|
961
|
+
return (
|
|
962
|
+
<Text key={i} color={isThumb ? 'blue' : 'gray'}>
|
|
963
|
+
{isThumb ? '\u2588' : '\u2591'}
|
|
964
|
+
</Text>
|
|
965
|
+
);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// Handle Add ROMs prompt completion
|
|
969
|
+
const handleAddRomsComplete = useCallback(() => {
|
|
970
|
+
// Trigger a refresh to reload the ROM browser with the updated ROM list
|
|
971
|
+
onRefresh(searchQuery);
|
|
972
|
+
exit();
|
|
973
|
+
}, [searchQuery, onRefresh, exit]);
|
|
974
|
+
|
|
975
|
+
const handleAddRomsCancel = useCallback(() => {
|
|
976
|
+
setShowAddRomsPrompt(false);
|
|
977
|
+
}, []);
|
|
978
|
+
|
|
979
|
+
// Show Add ROMs prompt overlay when active
|
|
980
|
+
if (showAddRomsPrompt) {
|
|
981
|
+
return (
|
|
982
|
+
<AddRomsPrompt
|
|
983
|
+
directory={process.cwd()}
|
|
984
|
+
playlistDirectory={playlistDirectory}
|
|
985
|
+
scanDepth={scanDepth}
|
|
986
|
+
onPlaylistGenerated={handleAddRomsComplete}
|
|
987
|
+
onExit={handleAddRomsCancel}
|
|
988
|
+
/>
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Show core manager panel when active
|
|
993
|
+
if (showCoreManager) {
|
|
994
|
+
return (
|
|
995
|
+
<CoreManager
|
|
996
|
+
onClose={() => setShowCoreManager(false)}
|
|
997
|
+
/>
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Show netplay panel when active
|
|
1002
|
+
if (showNetplay) {
|
|
1003
|
+
// Use the ROM at selected index, or fall back to first ROM in list
|
|
1004
|
+
const selectedRomForNetplay = filteredRoms.at(selectedIndex) ?? filteredRoms.at(0);
|
|
1005
|
+
if (selectedRomForNetplay) {
|
|
1006
|
+
return (
|
|
1007
|
+
<NetplayPanel
|
|
1008
|
+
rom={selectedRomForNetplay}
|
|
1009
|
+
onStart={(options) => {
|
|
1010
|
+
setNetplayReturnFromDisconnect(false);
|
|
1011
|
+
onSelect(selectedRomForNetplay, searchQuery, false, options);
|
|
1012
|
+
exit();
|
|
1013
|
+
}}
|
|
1014
|
+
onCancel={() => {
|
|
1015
|
+
setNetplayReturnFromDisconnect(false);
|
|
1016
|
+
setShowNetplay(false);
|
|
1017
|
+
}}
|
|
1018
|
+
initialMode={netplayReturnFromDisconnect ? 'join' : 'host'}
|
|
1019
|
+
/>
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
// No ROMs available - fall through to show main browser
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Show settings panel when active
|
|
1026
|
+
if (showSettings) {
|
|
1027
|
+
return (
|
|
1028
|
+
<SettingsPanel
|
|
1029
|
+
onClose={() => {
|
|
1030
|
+
// Reload config in case settings changed
|
|
1031
|
+
reloadConfig();
|
|
1032
|
+
setShowSettings(false);
|
|
1033
|
+
// Clear resumable ROM so Resume Game doesn't appear next time settings is opened
|
|
1034
|
+
setResumableRom(undefined);
|
|
1035
|
+
}}
|
|
1036
|
+
lastPlayedRom={resumableRom}
|
|
1037
|
+
onResumeGame={resumableRom ? () => {
|
|
1038
|
+
onSelect(resumableRom, searchQuery, true); // true = resumeGame
|
|
1039
|
+
exit();
|
|
1040
|
+
} : undefined}
|
|
1041
|
+
/>
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Empty state - no ROMs found
|
|
1046
|
+
if (roms.length === 0) {
|
|
1047
|
+
return (
|
|
1048
|
+
<Box flexDirection="column" height={termHeight}>
|
|
1049
|
+
<Header romCount={0} totalCount={0} searchQuery="" />
|
|
1050
|
+
|
|
1051
|
+
{/* Empty state message - fills available space */}
|
|
1052
|
+
<Box flexGrow={1} alignItems="center" justifyContent="center">
|
|
1053
|
+
<EmptyState />
|
|
1054
|
+
</Box>
|
|
1055
|
+
|
|
1056
|
+
{/* Footer with action buttons */}
|
|
1057
|
+
<Box marginTop={1}>
|
|
1058
|
+
{actionButtons.map((btn, i) => (
|
|
1059
|
+
<ActionButton
|
|
1060
|
+
key={btn.id}
|
|
1061
|
+
button={btn}
|
|
1062
|
+
isSelected={i === actionButtonIndex}
|
|
1063
|
+
isFocused={actionButtonsFocused}
|
|
1064
|
+
highlightBg={localConfig.menu_highlight_bg}
|
|
1065
|
+
highlightFg={localConfig.menu_highlight_fg}
|
|
1066
|
+
/>
|
|
1067
|
+
))}
|
|
1068
|
+
<Text color="gray" dimColor>
|
|
1069
|
+
{actionButtonsFocused ? ' (Tab: list, \u2190\u2192: select, \u23CE: activate)' : ' (Tab: actions)'}
|
|
1070
|
+
</Text>
|
|
1071
|
+
</Box>
|
|
1072
|
+
</Box>
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
return (
|
|
1077
|
+
<Box flexDirection="column">
|
|
1078
|
+
<Header romCount={filteredRoms.length} totalCount={roms.length} searchQuery={searchQuery} />
|
|
1079
|
+
|
|
1080
|
+
<Box>
|
|
1081
|
+
{/* ROM List */}
|
|
1082
|
+
<Box
|
|
1083
|
+
flexDirection="column"
|
|
1084
|
+
width={listWidth}
|
|
1085
|
+
height={listHeight}
|
|
1086
|
+
borderStyle="round"
|
|
1087
|
+
borderColor="gray"
|
|
1088
|
+
>
|
|
1089
|
+
{visibleRoms.map((rom, i) => (
|
|
1090
|
+
<Box key={rom.path} flexDirection="column">
|
|
1091
|
+
<RomListItem
|
|
1092
|
+
rom={rom}
|
|
1093
|
+
isSelected={scrollOffset + i === selectedIndex}
|
|
1094
|
+
width={listWidth - LAYOUT_BORDER_PADDING}
|
|
1095
|
+
highlightBg={localConfig.menu_highlight_bg}
|
|
1096
|
+
highlightFg={localConfig.menu_highlight_fg}
|
|
1097
|
+
/>
|
|
1098
|
+
{i < visibleRoms.length - 1 && (
|
|
1099
|
+
<Text color="gray" dimColor>{'─'.repeat(listWidth - LAYOUT_BORDER_PADDING)}</Text>
|
|
1100
|
+
)}
|
|
1101
|
+
</Box>
|
|
1102
|
+
))}
|
|
1103
|
+
|
|
1104
|
+
{/* Fill empty space (memoized) */}
|
|
1105
|
+
{emptySpaceElements}
|
|
1106
|
+
|
|
1107
|
+
{/* Scrollbar indicator (memoized) */}
|
|
1108
|
+
{filteredRoms.length > visibleItems && (
|
|
1109
|
+
<Box position="absolute" flexDirection="column" marginLeft={listWidth - ROM_SEPARATOR_PADDING}>
|
|
1110
|
+
{scrollbarElements}
|
|
1111
|
+
</Box>
|
|
1112
|
+
)}
|
|
1113
|
+
</Box>
|
|
1114
|
+
|
|
1115
|
+
{/* Spacer */}
|
|
1116
|
+
<Box width={ITEMS_PER_ROW} />
|
|
1117
|
+
|
|
1118
|
+
{/* Metadata Panel */}
|
|
1119
|
+
<MetadataPanel
|
|
1120
|
+
rom={selectedRomWithLastPlayed}
|
|
1121
|
+
width={metaWidth}
|
|
1122
|
+
height={listHeight}
|
|
1123
|
+
saveStateDetails={selectedRomDetails}
|
|
1124
|
+
thumbnail={selectedRomThumbnail?.data}
|
|
1125
|
+
isKittySupported={isKittySupported}
|
|
1126
|
+
panelStartCol={listWidth + ITEMS_PER_ROW + 1}
|
|
1127
|
+
/>
|
|
1128
|
+
</Box>
|
|
1129
|
+
|
|
1130
|
+
{/* Footer with action buttons and position indicator */}
|
|
1131
|
+
<Box marginTop={1} justifyContent="space-between">
|
|
1132
|
+
{/* Action buttons */}
|
|
1133
|
+
<Box>
|
|
1134
|
+
{actionButtons.map((btn, i) => (
|
|
1135
|
+
<ActionButton
|
|
1136
|
+
key={btn.id}
|
|
1137
|
+
button={btn}
|
|
1138
|
+
isSelected={i === actionButtonIndex}
|
|
1139
|
+
isFocused={actionButtonsFocused}
|
|
1140
|
+
highlightBg={localConfig.menu_highlight_bg}
|
|
1141
|
+
highlightFg={localConfig.menu_highlight_fg}
|
|
1142
|
+
/>
|
|
1143
|
+
))}
|
|
1144
|
+
<Text color="gray" dimColor>
|
|
1145
|
+
{actionButtonsFocused ? ' (Tab: list, \u2190\u2192: select, \u23CE: activate)' : ' (Tab: actions)'}
|
|
1146
|
+
</Text>
|
|
1147
|
+
</Box>
|
|
1148
|
+
|
|
1149
|
+
{/* Position indicator */}
|
|
1150
|
+
<Box>
|
|
1151
|
+
<Text color="gray">
|
|
1152
|
+
{filteredRoms.length > 0 ? `${selectedIndex + 1}/${filteredRoms.length}` : 'No matches'}
|
|
1153
|
+
{!searchQuery && !actionButtonsFocused && (
|
|
1154
|
+
<Text color="gray" dimColor> (type to search)</Text>
|
|
1155
|
+
)}
|
|
1156
|
+
{searchQuery && !actionButtonsFocused && (
|
|
1157
|
+
<Text color="gray" dimColor> (ESC to clear)</Text>
|
|
1158
|
+
)}
|
|
1159
|
+
</Text>
|
|
1160
|
+
</Box>
|
|
1161
|
+
</Box>
|
|
1162
|
+
</Box>
|
|
1163
|
+
);
|
|
1164
|
+
};
|