1use anyhow::Result;
14use crossterm::event::{
15 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
16 KeyModifiers,
17};
18use crossterm::execute;
19use crossterm::terminal::{
20 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
21};
22use ratatui::backend::CrosstermBackend;
23use ratatui::style::Color;
24use ratatui::Terminal;
25use std::collections::{HashSet, VecDeque};
26use std::path::{Path, PathBuf};
27use std::time::{Duration, Instant};
28
29use crate::client::RommClient;
30use crate::commands::library_scan::ScanCacheInvalidate;
31use crate::config::{
32 auth_for_persist_merge, normalize_romm_origin, resolve_game_save_dir, Config, ExtrasDefaults,
33};
34use crate::core::cache::{RomCache, RomCacheKey};
35use crate::core::download::DownloadManager;
36use crate::core::extras::has_update_or_dlc_extras;
37use crate::core::startup_library_snapshot;
38use crate::endpoints::device::{DeviceSchema, ListDevices};
39use crate::endpoints::platforms::ListPlatforms;
40use crate::endpoints::roms::GetRoms;
41use crate::endpoints::sync::{SyncSessionSchema, TriggerPushPull};
42use crate::types::{Collection, Platform, RomList, SaveMetadata};
43use crate::update::UpdateStatus;
44
45use super::keyboard_help;
46use super::screens::connected_splash::{self, StartupSplash};
47use super::screens::settings::{ConsolePathKind, SettingsRow};
48use super::screens::setup_wizard::SetupWizard;
49use super::screens::{
50 BrowseScreen, DownloadScreen, ExecuteScreen, ExtrasPickerScreen, GameDetailPrevious,
51 GameDetailScreen, LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen,
52 SearchScreen, SettingsScreen,
53};
54use crate::feature_compat::{save_sync_compatibility, SaveSyncCompatibility};
55use crate::openapi::{resolve_path_template, EndpointRegistry};
56
57struct LibraryMetadataRefreshDone {
59 gen: u64,
60 platforms: Vec<Platform>,
61 collections: Vec<Collection>,
62 collection_digest: Vec<startup_library_snapshot::CollectionDigestEntry>,
63 warnings: Vec<String>,
64}
65
66struct CollectionPrefetchDone {
67 key: RomCacheKey,
68 expected: u64,
69 roms: Option<RomList>,
70 warning: Option<String>,
71}
72
73enum RomLoadEvent {
74 Batch(RomList),
75 Failed(String),
76 Complete,
77}
78
79struct RomLoadDone {
81 gen: u64,
82 key: Option<RomCacheKey>,
83 expected: u64,
84 event: RomLoadEvent,
85 context: &'static str,
86 started: Instant,
87}
88
89enum SearchLoadEvent {
90 Batch(RomList),
91 Failed(String),
92 Complete,
93}
94
95struct SearchLoadDone {
96 query: String,
97 event: SearchLoadEvent,
98}
99
100struct CoverLoadDone {
101 rom_id: u64,
102 result: Result<image::DynamicImage, String>,
103}
104
105struct SaveListDone {
106 rom_id: u64,
107 result: Result<Vec<SaveMetadata>, String>,
108}
109
110struct SaveUploadDone {
111 rom_id: u64,
112 result: Result<(), String>,
113}
114
115struct SaveDownloadDone {
116 rom_id: u64,
117 result: Result<PathBuf, String>,
118}
119
120struct DeviceListDone {
121 result: Result<Vec<DeviceSchema>, String>,
122}
123
124struct PlatformListDone {
125 result: Result<Vec<crate::types::Platform>, String>,
126}
127
128struct SyncPushPullDone {
129 result: Result<SyncSessionSchema, String>,
130}
131
132struct StartupUpdatePrompt {
133 status: UpdateStatus,
134 updating: bool,
135}
136
137type DeferredLoadRoms = (
139 Option<RomCacheKey>,
140 Option<GetRoms>,
141 u64,
142 &'static str,
143 Instant,
144);
145
146#[inline]
147fn primary_rom_load_result_is_current(done_gen: u64, current_gen: u64) -> bool {
148 done_gen == current_gen
149}
150
151fn safe_path_segment(input: &str) -> String {
152 let cleaned: String = input
153 .chars()
154 .map(|c| {
155 if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') {
156 c
157 } else {
158 '_'
159 }
160 })
161 .collect();
162 let trimmed = cleaned.trim().trim_matches('.').trim();
163 if trimmed.is_empty() {
164 "game".to_string()
165 } else {
166 trimmed.to_string()
167 }
168}
169
170fn unique_save_path(dir: &Path, file_name: &str) -> PathBuf {
171 let safe_name = safe_path_segment(file_name);
172 let base = Path::new(&safe_name)
173 .file_stem()
174 .and_then(|s| s.to_str())
175 .unwrap_or("save");
176 let ext = Path::new(&safe_name).extension().and_then(|s| s.to_str());
177 let mut candidate = dir.join(&safe_name);
178 let mut n = 1u32;
179 while candidate.exists() {
180 let name = match ext {
181 Some(ext) if !ext.is_empty() => format!("{base}-{n}.{ext}"),
182 _ => format!("{base}-{n}"),
183 };
184 candidate = dir.join(name);
185 n += 1;
186 }
187 candidate
188}
189
190pub enum AppScreen {
199 MainMenu(MainMenuScreen),
200 LibraryBrowse(LibraryBrowseScreen),
201 Search(SearchScreen),
202 Settings(Box<SettingsScreen>),
203 Browse(BrowseScreen),
204 Execute(ExecuteScreen),
205 Result(ResultScreen),
206 ResultDetail(ResultDetailScreen),
207 GameDetail(Box<GameDetailScreen>),
208 ExtrasPicker(Box<ExtrasPickerScreen>),
209 Download(DownloadScreen),
210 SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
211}
212
213struct LibraryUploadComplete {
215 platform_id: u64,
216 scan_after: bool,
217}
218
219pub struct App {
228 pub screen: AppScreen,
229 client: RommClient,
230 config: Config,
231 registry: EndpointRegistry,
232 server_version: Option<String>,
234 save_sync_compat: SaveSyncCompatibility,
235 rom_cache: RomCache,
236 downloads: DownloadManager,
237 screen_before_download: Option<AppScreen>,
239 deferred_load_roms: Option<DeferredLoadRoms>,
241 startup_splash: Option<StartupSplash>,
243 pub global_error: Option<String>,
244 pub global_notice: Option<String>,
245 show_keyboard_help: bool,
246 startup_update_prompt: Option<StartupUpdatePrompt>,
247 library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
249 library_metadata_refresh_gen: u64,
251 collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
252 collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
253 collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
254 collection_prefetch_queued_keys: HashSet<RomCacheKey>,
255 collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
256 rom_load_gen: u64,
258 rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
259 rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
260 rom_load_task: Option<tokio::task::JoinHandle<()>>,
261 search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
262 search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
263 search_load_task: Option<tokio::task::JoinHandle<()>>,
264 cover_load_rx: tokio::sync::mpsc::UnboundedReceiver<CoverLoadDone>,
265 cover_load_tx: tokio::sync::mpsc::UnboundedSender<CoverLoadDone>,
266 cover_load_task: Option<tokio::task::JoinHandle<()>>,
267 library_scan_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Result<(), String>>>,
269 library_scan_inflight: bool,
270 library_scan_pending_invalidate: Option<ScanCacheInvalidate>,
272 force_rom_reload_after_metadata: bool,
274 library_upload_inflight: bool,
276 library_upload_progress_rx: Option<tokio::sync::mpsc::UnboundedReceiver<(u64, u64)>>,
277 library_upload_done_rx:
278 Option<tokio::sync::mpsc::UnboundedReceiver<Result<LibraryUploadComplete, String>>>,
279 save_list_rx: tokio::sync::mpsc::UnboundedReceiver<SaveListDone>,
280 save_list_tx: tokio::sync::mpsc::UnboundedSender<SaveListDone>,
281 save_upload_rx: tokio::sync::mpsc::UnboundedReceiver<SaveUploadDone>,
282 save_upload_tx: tokio::sync::mpsc::UnboundedSender<SaveUploadDone>,
283 save_download_rx: tokio::sync::mpsc::UnboundedReceiver<SaveDownloadDone>,
284 save_download_tx: tokio::sync::mpsc::UnboundedSender<SaveDownloadDone>,
285 device_list_rx: tokio::sync::mpsc::UnboundedReceiver<DeviceListDone>,
286 device_list_tx: tokio::sync::mpsc::UnboundedSender<DeviceListDone>,
287 platform_list_rx: tokio::sync::mpsc::UnboundedReceiver<PlatformListDone>,
288 platform_list_tx: tokio::sync::mpsc::UnboundedSender<PlatformListDone>,
289 sync_push_pull_rx: tokio::sync::mpsc::UnboundedReceiver<SyncPushPullDone>,
290 sync_push_pull_tx: tokio::sync::mpsc::UnboundedSender<SyncPushPullDone>,
291}
292
293impl App {
294 fn blocks_global_chord_shortcuts(&self) -> bool {
295 self.startup_splash.is_some()
296 || self.startup_update_prompt.is_some()
297 || self.global_error.is_some()
298 || self.global_notice.is_some()
299 }
300
301 fn blocks_global_d_shortcut(&self) -> bool {
302 let base = match &self.screen {
303 AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
304 AppScreen::LibraryBrowse(lib) => {
305 lib.any_search_bar_open() || lib.any_upload_prompt_open()
306 }
307 _ => false,
308 };
309 base || self.library_upload_inflight || self.blocks_global_chord_shortcuts()
310 }
311
312 fn allows_global_question_help(&self) -> bool {
313 match &self.screen {
314 AppScreen::Search(_) | AppScreen::SetupWizard(_) | AppScreen::Execute(_) => false,
315 AppScreen::LibraryBrowse(lib)
316 if lib.any_search_bar_open() || lib.any_upload_prompt_open() =>
317 {
318 false
319 }
320 AppScreen::Settings(s) if s.editing || s.path_picker.is_some() => false,
321 _ => true,
322 }
323 }
324
325 fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
326 key.kind == KeyEventKind::Press
327 && key.modifiers.contains(KeyModifiers::CONTROL)
328 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
329 }
330
331 fn selected_rom_request_for_library(
332 lib: &super::screens::library_browse::LibraryBrowseScreen,
333 ) -> Option<GetRoms> {
334 match lib.subsection {
335 super::screens::library_browse::LibrarySubsection::ByConsole => {
336 lib.get_roms_request_platform()
337 }
338 super::screens::library_browse::LibrarySubsection::ByCollection => {
339 lib.get_roms_request_collection()
340 }
341 }
342 }
343
344 pub fn new(
346 client: RommClient,
347 config: Config,
348 registry: EndpointRegistry,
349 server_version: Option<String>,
350 startup_splash: Option<StartupSplash>,
351 startup_update: Option<UpdateStatus>,
352 ) -> Self {
353 let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
354 let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
355 let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
356 let (cover_load_tx, cover_load_rx) = tokio::sync::mpsc::unbounded_channel();
357 let (save_list_tx, save_list_rx) = tokio::sync::mpsc::unbounded_channel();
358 let (save_upload_tx, save_upload_rx) = tokio::sync::mpsc::unbounded_channel();
359 let (save_download_tx, save_download_rx) = tokio::sync::mpsc::unbounded_channel();
360 let (device_list_tx, device_list_rx) = tokio::sync::mpsc::unbounded_channel();
361 let (platform_list_tx, platform_list_rx) = tokio::sync::mpsc::unbounded_channel();
362 let (sync_push_pull_tx, sync_push_pull_rx) = tokio::sync::mpsc::unbounded_channel();
363 let save_sync_compat = save_sync_compatibility(®istry);
364 Self {
365 screen: AppScreen::MainMenu(MainMenuScreen::new()),
366 client,
367 config,
368 registry,
369 server_version,
370 save_sync_compat,
371 rom_cache: RomCache::load(),
372 downloads: DownloadManager::new(),
373 screen_before_download: None,
374 deferred_load_roms: None,
375 startup_splash,
376 global_error: None,
377 global_notice: None,
378 show_keyboard_help: false,
379 startup_update_prompt: startup_update.map(|status| StartupUpdatePrompt {
380 status,
381 updating: false,
382 }),
383 library_metadata_rx: None,
384 library_metadata_refresh_gen: 0,
385 collection_prefetch_rx: prefetch_rx,
386 collection_prefetch_tx: prefetch_tx,
387 collection_prefetch_queue: VecDeque::new(),
388 collection_prefetch_queued_keys: HashSet::new(),
389 collection_prefetch_inflight_keys: HashSet::new(),
390 rom_load_gen: 0,
391 rom_load_rx,
392 rom_load_tx,
393 rom_load_task: None,
394 search_load_rx,
395 search_load_tx,
396 search_load_task: None,
397 cover_load_rx,
398 cover_load_tx,
399 cover_load_task: None,
400 library_scan_rx: None,
401 library_scan_inflight: false,
402 library_scan_pending_invalidate: None,
403 force_rom_reload_after_metadata: false,
404 library_upload_inflight: false,
405 library_upload_progress_rx: None,
406 library_upload_done_rx: None,
407 save_list_rx,
408 save_list_tx,
409 save_upload_rx,
410 save_upload_tx,
411 save_download_rx,
412 save_download_tx,
413 device_list_rx,
414 device_list_tx,
415 platform_list_rx,
416 platform_list_tx,
417 sync_push_pull_rx,
418 sync_push_pull_tx,
419 }
420 }
421
422 fn spawn_library_metadata_refresh(&mut self) {
423 self.library_metadata_refresh_gen = self.library_metadata_refresh_gen.saturating_add(1);
424 let gen = self.library_metadata_refresh_gen;
425 let client = self.client.clone();
426 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
427 self.library_metadata_rx = Some(rx);
428 tokio::spawn(async move {
429 let fetch = startup_library_snapshot::fetch_merged_library_metadata(&client).await;
430 let _ = tx.send(LibraryMetadataRefreshDone {
431 gen,
432 platforms: fetch.platforms,
433 collections: fetch.collections,
434 collection_digest: fetch.collection_digest,
435 warnings: fetch.warnings,
436 });
437 });
438 }
439
440 pub fn poll_background_tasks(&mut self) {
442 self.poll_library_metadata_refresh();
443 self.poll_rom_load_results();
444 self.poll_collection_prefetch_results();
445 self.poll_search_load_results();
446 self.poll_cover_load_results();
447 self.poll_save_results();
448 self.poll_settings_results();
449 self.poll_library_upload();
450 self.poll_library_scan();
451 self.drive_collection_prefetch_scheduler();
452 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
453 lib.poll_footer_clear();
454 }
455 }
456
457 fn spawn_library_rescan_worker(&mut self, cache_on_success: ScanCacheInvalidate) {
458 if self.library_scan_inflight {
459 return;
460 }
461 self.library_scan_inflight = true;
462 self.library_scan_pending_invalidate = Some(cache_on_success);
463 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
464 lib.set_metadata_footer(Some("Server library scan running…".into()));
465 }
466 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
467 self.library_scan_rx = Some(rx);
468 let client = self.client.clone();
469 tokio::spawn(async move {
470 let result = async {
471 let start =
472 crate::commands::library_scan::start_scan_library(&client, None).await?;
473 crate::commands::library_scan::wait_for_task_terminal(
474 &client,
475 &start.task_id,
476 Duration::from_secs(3600),
477 None,
478 |_| {},
479 )
480 .await?;
481 Ok::<(), anyhow::Error>(())
482 }
483 .await
484 .map_err(|e| e.to_string());
485 let _ = tx.send(result);
486 });
487 }
488
489 fn poll_library_scan(&mut self) {
490 let Some(rx) = &mut self.library_scan_rx else {
491 return;
492 };
493 match rx.try_recv() {
494 Ok(result) => {
495 self.library_scan_rx = None;
496 self.library_scan_inflight = false;
497 match result {
498 Ok(()) => self.on_library_scan_completed_success(),
499 Err(e) => {
500 self.library_scan_pending_invalidate = None;
501 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
502 lib.set_metadata_footer(Some(format!("Library scan failed: {e}")));
503 } else {
504 self.global_error = Some(format!("Library scan failed: {e}"));
505 }
506 }
507 }
508 }
509 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
510 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
511 self.library_scan_rx = None;
512 self.library_scan_inflight = false;
513 self.library_scan_pending_invalidate = None;
514 }
515 }
516 }
517
518 fn apply_library_scan_cache_invalidate(&mut self, inv: &ScanCacheInvalidate) {
519 match inv {
520 ScanCacheInvalidate::None => {}
521 ScanCacheInvalidate::Platform(pid) => {
522 self.rom_cache.remove(&RomCacheKey::Platform(*pid));
523 }
524 ScanCacheInvalidate::AllPlatforms => {
525 self.rom_cache.remove_all_platform_entries();
526 if let AppScreen::LibraryBrowse(lib) = &self.screen {
527 if let Some(ref k) = lib.cache_key() {
528 if !matches!(k, RomCacheKey::Platform(_)) {
529 self.rom_cache.remove(k);
530 }
531 }
532 }
533 }
534 }
535 }
536
537 fn on_library_scan_completed_success(&mut self) {
538 let inv = self
539 .library_scan_pending_invalidate
540 .take()
541 .unwrap_or(ScanCacheInvalidate::AllPlatforms);
542 self.apply_library_scan_cache_invalidate(&inv);
543 if matches!(self.screen, AppScreen::LibraryBrowse(_)) {
544 self.force_rom_reload_after_metadata = true;
545 self.spawn_library_metadata_refresh();
546 }
547 }
548
549 fn format_upload_bytes(n: u64) -> String {
550 const KB: u64 = 1024;
551 const MB: u64 = KB * 1024;
552 const GB: u64 = MB * 1024;
553 if n >= GB {
554 format!("{:.2} GiB", n as f64 / GB as f64)
555 } else if n >= MB {
556 format!("{:.2} MiB", n as f64 / MB as f64)
557 } else if n >= KB {
558 format!("{:.1} KiB", n as f64 / KB as f64)
559 } else {
560 format!("{n} B")
561 }
562 }
563
564 fn spawn_library_upload_worker(&mut self, platform_id: u64, path: PathBuf, scan_after: bool) {
565 if self.library_upload_inflight || self.library_scan_inflight {
566 return;
567 }
568 self.library_upload_inflight = true;
569 self.library_upload_progress_rx = None;
570 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
571 lib.set_metadata_footer(Some("Preparing upload…".into()));
572 }
573 let (prog_tx, prog_rx) = tokio::sync::mpsc::unbounded_channel();
574 let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
575 self.library_upload_progress_rx = Some(prog_rx);
576 self.library_upload_done_rx = Some(done_rx);
577 let client = self.client.clone();
578 tokio::spawn(async move {
579 let result: Result<LibraryUploadComplete, String> = async {
580 client
581 .upload_rom(platform_id, &path, move |uploaded, total| {
582 let _ = prog_tx.send((uploaded, total));
583 })
584 .await
585 .map_err(|e| e.to_string())?;
586 Ok(LibraryUploadComplete {
587 platform_id,
588 scan_after,
589 })
590 }
591 .await;
592 let _ = done_tx.send(result);
593 });
594 }
595
596 fn poll_library_upload(&mut self) {
597 if let Some(rx) = &mut self.library_upload_progress_rx {
598 while let Ok((up, tot)) = rx.try_recv() {
599 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
600 lib.set_metadata_footer(Some(format!(
601 "Uploading {} / {}…",
602 Self::format_upload_bytes(up),
603 Self::format_upload_bytes(tot)
604 )));
605 }
606 }
607 }
608
609 let Some(rx) = &mut self.library_upload_done_rx else {
610 return;
611 };
612 match rx.try_recv() {
613 Ok(result) => {
614 self.library_upload_done_rx = None;
615 self.library_upload_progress_rx = None;
616 self.library_upload_inflight = false;
617 match result {
618 Ok(done) => {
619 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
620 if done.scan_after {
621 lib.set_metadata_footer(Some(
622 "Upload complete. Starting library scan…".into(),
623 ));
624 self.spawn_library_rescan_worker(ScanCacheInvalidate::Platform(
625 done.platform_id,
626 ));
627 } else {
628 lib.set_metadata_footer(Some("Upload complete.".into()));
629 }
630 }
631 }
632 Err(e) => {
633 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
634 lib.set_metadata_footer(Some(format!("Upload failed: {e}")));
635 } else {
636 self.global_error = Some(format!("Upload failed: {e}"));
637 }
638 }
639 }
640 }
641 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
642 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
643 self.library_upload_done_rx = None;
644 self.library_upload_progress_rx = None;
645 self.library_upload_inflight = false;
646 }
647 }
648 }
649
650 fn poll_search_load_results(&mut self) {
651 loop {
652 match self.search_load_rx.try_recv() {
653 Ok(done) => {
654 if let AppScreen::Search(ref mut search) = self.screen {
655 match done.event {
656 SearchLoadEvent::Batch(roms) => {
657 search.set_results_for_query(done.query, roms);
658 }
659 SearchLoadEvent::Failed(err) => {
660 search.loading = false;
661 self.global_error = Some(err);
662 }
663 SearchLoadEvent::Complete => {
664 search.loading = false;
665 }
666 }
667 }
668 }
669 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
670 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
671 }
672 }
673 }
674
675 fn spawn_cover_load_worker(&mut self, rom_id: u64, url: String) {
676 if let Some(task) = self.cover_load_task.take() {
677 task.abort();
678 }
679 let tx = self.cover_load_tx.clone();
680 self.cover_load_task = Some(tokio::spawn(async move {
681 let result = async {
682 let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
683 let status = response.status();
684 if !status.is_success() {
685 return Err(format!("HTTP {}", status.as_u16()));
686 }
687 let bytes = response.bytes().await.map_err(|e| e.to_string())?;
688 image::load_from_memory(&bytes).map_err(|e| e.to_string())
689 }
690 .await;
691 let _ = tx.send(CoverLoadDone { rom_id, result });
692 }));
693 }
694
695 fn poll_cover_load_results(&mut self) {
696 loop {
697 match self.cover_load_rx.try_recv() {
698 Ok(done) => {
699 if let AppScreen::GameDetail(detail) = &mut self.screen {
700 if detail.rom.id != done.rom_id {
701 continue;
702 }
703 match done.result {
704 Ok(image) => detail.apply_cover_image(image),
705 Err(err) => detail.apply_cover_error(format!(
706 "Cover failed: {}",
707 crate::tui::utils::truncate(&err, 120)
708 )),
709 }
710 }
711 }
712 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
713 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
714 }
715 }
716 }
717
718 fn maybe_start_game_detail_cover_load(&mut self) {
719 let (rom_id, url) = match &mut self.screen {
720 AppScreen::GameDetail(detail) => {
721 if !detail.should_request_cover_load() {
722 return;
723 }
724 detail.set_cover_loading();
725 let Some(url) = detail.cover_last_url.clone() else {
726 return;
727 };
728 (detail.rom.id, url)
729 }
730 _ => return,
731 };
732 self.spawn_cover_load_worker(rom_id, url);
733 }
734
735 fn spawn_save_list_worker(&mut self, rom_id: u64) {
736 if let AppScreen::GameDetail(detail) = &mut self.screen {
737 detail.set_saves_loading();
738 }
739 let client = self.client.clone();
740 let tx = self.save_list_tx.clone();
741 tokio::spawn(async move {
742 let result = async {
743 let value = client
744 .request_json(
745 "GET",
746 "/api/saves",
747 &[("rom_id".to_string(), rom_id.to_string())],
748 None,
749 )
750 .await?;
751 SaveMetadata::from_api_value(value)
752 }
753 .await
754 .map_err(|e| format!("{e:#}"));
755 let _ = tx.send(SaveListDone { rom_id, result });
756 });
757 }
758
759 fn refresh_current_game_saves(&mut self) {
760 if let AppScreen::GameDetail(detail) = &self.screen {
761 self.spawn_save_list_worker(detail.rom.id);
762 }
763 }
764
765 fn poll_save_results(&mut self) {
766 while let Ok(done) = self.save_list_rx.try_recv() {
767 if let AppScreen::GameDetail(detail) = &mut self.screen {
768 if detail.rom.id == done.rom_id {
769 match done.result {
770 Ok(rows) => detail.apply_saves(rows),
771 Err(e) => detail.apply_saves_error(e),
772 }
773 }
774 }
775 }
776 while let Ok(done) = self.save_upload_rx.try_recv() {
777 if let AppScreen::GameDetail(detail) = &mut self.screen {
778 if detail.rom.id == done.rom_id {
779 match done.result {
780 Ok(()) => {
781 detail.message = Some("Save uploaded. Refreshing saves...".into());
782 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
783 self.spawn_save_list_worker(done.rom_id);
784 }
785 Err(e) => {
786 detail.message = Some(format!("Save upload failed: {e}"));
787 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
788 }
789 }
790 }
791 }
792 }
793 while let Ok(done) = self.save_download_rx.try_recv() {
794 if let AppScreen::GameDetail(detail) = &mut self.screen {
795 if detail.rom.id == done.rom_id {
796 match done.result {
797 Ok(path) => {
798 detail.message = Some(format!("Save downloaded: {}", path.display()));
799 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
800 self.spawn_save_list_worker(done.rom_id);
801 }
802 Err(e) => {
803 detail.message = Some(format!("Save download failed: {e}"));
804 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
805 }
806 }
807 }
808 }
809 }
810 }
811
812 fn poll_settings_results(&mut self) {
813 while let Ok(done) = self.device_list_rx.try_recv() {
814 if let AppScreen::Settings(settings) = &mut self.screen {
815 match done.result {
816 Ok(devices) => {
817 settings.set_devices(devices);
818 settings.message = None;
819 }
820 Err(e) => {
821 settings.set_device_error(e.clone());
822 settings.message = Some((format!("Device load failed: {e}"), Color::Red));
823 }
824 }
825 }
826 }
827 while let Ok(done) = self.platform_list_rx.try_recv() {
828 if let AppScreen::Settings(settings) = &mut self.screen {
829 match done.result {
830 Ok(platforms) => {
831 settings.set_console_platforms(platforms);
832 settings.message = None;
833 }
834 Err(e) => {
835 settings.set_console_platform_error(e.clone());
836 settings.message = Some((format!("Platform load failed: {e}"), Color::Red));
837 }
838 }
839 }
840 }
841 while let Ok(done) = self.sync_push_pull_rx.try_recv() {
842 if let AppScreen::Settings(settings) = &mut self.screen {
843 settings.sync_inflight = false;
844 match done.result {
845 Ok(session) => {
846 settings.message = Some((
847 format!("Sync session #{}: {}", session.id, session.status),
848 Color::Green,
849 ));
850 }
851 Err(e) => {
852 settings.message = Some((format!("Sync failed: {e}"), Color::Red));
853 }
854 }
855 }
856 }
857 }
858
859 fn poll_rom_load_results(&mut self) {
860 loop {
861 match self.rom_load_rx.try_recv() {
862 Ok(done) => {
863 if !primary_rom_load_result_is_current(done.gen, self.rom_load_gen) {
864 continue;
865 }
866 let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
867 continue;
868 };
869 match done.event {
870 RomLoadEvent::Batch(roms) => {
871 if let Some(ref k) = done.key {
872 self.rom_cache
873 .insert(k.clone(), roms.clone(), done.expected);
874 }
875 lib.set_roms(roms);
876 tracing::debug!(
877 "rom-list-render batch context={} latency_ms={}",
878 done.context,
879 done.started.elapsed().as_millis()
880 );
881 }
882 RomLoadEvent::Failed(e) => {
883 lib.set_metadata_footer(Some(format!("Could not load games: {e}")));
884 lib.set_rom_loading(false);
885 }
886 RomLoadEvent::Complete => {
887 lib.set_rom_loading(false);
888 }
889 }
890 }
891 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
892 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
893 }
894 }
895 }
896
897 fn poll_library_metadata_refresh(&mut self) {
898 let mut batch = Vec::new();
899 let mut disconnected = false;
900 if let Some(rx) = &mut self.library_metadata_rx {
901 loop {
902 match rx.try_recv() {
903 Ok(msg) => batch.push(msg),
904 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
905 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
906 disconnected = true;
907 break;
908 }
909 }
910 }
911 }
912 if disconnected {
913 self.library_metadata_rx = None;
914 }
915 for msg in batch {
916 self.apply_library_metadata_refresh(msg);
917 }
918 }
919
920 fn apply_library_metadata_refresh(&mut self, msg: LibraryMetadataRefreshDone) {
921 if msg.gen != self.library_metadata_refresh_gen {
922 return;
923 }
924 let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
925 return;
926 };
927
928 let had_cached_lists = !lib.platforms.is_empty() || !lib.collections.is_empty();
929 let live_empty = msg.collections.is_empty();
930 if live_empty && had_cached_lists && !msg.warnings.is_empty() {
931 lib.set_temporary_metadata_footer(
932 "Could not refresh library metadata (keeping cached list).".into(),
933 std::time::Duration::from_secs(3),
934 );
935 self.force_rom_reload_after_metadata = false;
936 return;
937 }
938
939 let old_digest =
940 startup_library_snapshot::build_collection_digest_from_collections(&lib.collections);
941 let digest_changed = old_digest != msg.collection_digest;
942 let update_platforms = !msg.platforms.is_empty();
943 let selection_changed = lib.replace_metadata_preserving_selection(
944 msg.platforms,
945 msg.collections,
946 update_platforms,
947 true,
948 );
949 startup_library_snapshot::save_snapshot(&lib.platforms, &lib.collections);
950
951 let footer = if msg.warnings.is_empty() {
952 if digest_changed {
953 Some("Collection metadata updated.".into())
954 } else {
955 None
956 }
957 } else {
958 let w = msg.warnings.join(" | ");
959 let short: String = if w.chars().count() > 160 {
960 let prefix: String = w.chars().take(157).collect();
961 format!("{prefix}…")
962 } else {
963 w
964 };
965 Some(format!("Partial refresh: {}", short))
966 };
967 lib.set_metadata_footer(footer);
968
969 if selection_changed && lib.list_len() > 0 {
970 lib.clear_roms();
971 let key = lib.cache_key();
972 let expected = lib.expected_rom_count();
973 let req = Self::selected_rom_request_for_library(lib);
974 lib.set_rom_loading(expected > 0);
975 self.deferred_load_roms =
976 Some((key, req, expected, "refresh_selection", Instant::now()));
977 }
978
979 let force_reload = std::mem::take(&mut self.force_rom_reload_after_metadata);
980 if force_reload && lib.list_len() > 0 && !selection_changed {
981 lib.clear_roms();
982 let key = lib.cache_key();
983 let expected = lib.expected_rom_count();
984 let req = Self::selected_rom_request_for_library(lib);
985 lib.set_rom_loading(expected > 0);
986 self.deferred_load_roms =
987 Some((key, req, expected, "post_scan_reload", Instant::now()));
988 }
989
990 self.queue_collection_prefetches_from_screen(1, "refresh_warmup");
991 }
992
993 fn queue_collection_prefetches_from_screen(&mut self, radius: usize, _reason: &'static str) {
994 let AppScreen::LibraryBrowse(ref lib) = self.screen else {
995 return;
996 };
997 for (key, req, expected) in lib.collection_prefetch_candidates(radius) {
998 if self.rom_cache.get_valid(&key, expected).is_some() {
999 continue;
1000 }
1001 if self.collection_prefetch_queued_keys.contains(&key)
1002 || self.collection_prefetch_inflight_keys.contains(&key)
1003 {
1004 continue;
1005 }
1006 self.collection_prefetch_queued_keys.insert(key.clone());
1007 self.collection_prefetch_queue
1008 .push_back((key, req, expected));
1009 }
1010 }
1011
1012 fn drive_collection_prefetch_scheduler(&mut self) {
1013 const PREFETCH_MAX_INFLIGHT: usize = 2;
1014 while self.collection_prefetch_inflight_keys.len() < PREFETCH_MAX_INFLIGHT {
1015 let Some((key, req, expected)) = self.collection_prefetch_queue.pop_back() else {
1016 break;
1017 };
1018 self.collection_prefetch_queued_keys.remove(&key);
1019 self.collection_prefetch_inflight_keys.insert(key.clone());
1020 let tx = self.collection_prefetch_tx.clone();
1021 let client = self.client.clone();
1022 tokio::spawn(async move {
1023 let result = Self::fetch_roms_full(client, req).await;
1024 let (roms, warning) = match result {
1025 Ok(list) => (Some(list), None),
1026 Err(e) => (None, Some(format!("Collection prefetch failed: {e:#}"))),
1027 };
1028 let _ = tx.send(CollectionPrefetchDone {
1029 key,
1030 expected,
1031 roms,
1032 warning,
1033 });
1034 });
1035 }
1036 }
1037
1038 fn poll_collection_prefetch_results(&mut self) {
1039 loop {
1040 match self.collection_prefetch_rx.try_recv() {
1041 Ok(done) => {
1042 self.collection_prefetch_inflight_keys.remove(&done.key);
1043 if let Some(roms) = done.roms {
1044 self.rom_cache.insert(done.key, roms, done.expected);
1045 } else if let Some(warning) = done.warning {
1046 tracing::debug!("{warning}");
1047 }
1048 }
1049 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
1050 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
1051 }
1052 }
1053 }
1054
1055 pub fn set_error(&mut self, err: anyhow::Error) {
1056 self.global_error = Some(format!("{:#}", err));
1057 }
1058
1059 pub async fn run(&mut self) -> Result<()> {
1069 enable_raw_mode()?;
1070 let mut stdout = std::io::stdout();
1071 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
1072 let backend = CrosstermBackend::new(stdout);
1073 let mut terminal = Terminal::new(backend)?;
1074
1075 loop {
1076 self.poll_background_tasks();
1077 if self
1078 .startup_splash
1079 .as_ref()
1080 .is_some_and(|s| s.should_auto_dismiss())
1081 {
1082 self.startup_splash = None;
1083 }
1084 terminal.draw(|f| self.render(f))?;
1087
1088 if let Some(ref mut prompt) = self.startup_update_prompt {
1090 if prompt.updating {
1091 if prompt.status.latest_version == "9.9.9-mock" {
1093 tokio::time::sleep(std::time::Duration::from_secs(2)).await; self.global_notice =
1095 Some("Mock update successful! (No files were changed)".into());
1096 self.startup_update_prompt = None;
1097 } else {
1098 let options = crate::update::ApplyUpdateOptions {
1099 show_progress: false,
1100 show_output: false,
1101 no_confirm: true,
1102 target_version_tag: Some(prompt.status.release_tag.clone()),
1103 };
1104 match crate::update::apply_update(None, options).await {
1105 Ok(crate::update::ApplyUpdateOutcome::Updated(version)) => {
1106 self.global_notice = Some(format!(
1107 "Updated to {version}. Restart romm-cli to use the new version."
1108 ));
1109 }
1110 Ok(crate::update::ApplyUpdateOutcome::UpToDate(version)) => {
1111 self.global_notice =
1112 Some(format!("Already up to date (`{version}`)."));
1113 }
1114 Err(err) => {
1115 self.global_error = Some(format!("Update failed: {err:#}"));
1116 }
1117 }
1118 self.startup_update_prompt = None;
1119 }
1120 continue;
1121 }
1122 }
1123
1124 if event::poll(Duration::from_millis(100))? {
1127 if let Event::Key(key_event) = event::read()? {
1128 if Self::is_force_quit_key(&key_event) {
1129 break;
1130 }
1131 if key_event.kind == KeyEventKind::Press
1132 && key_event.modifiers.contains(KeyModifiers::CONTROL)
1133 && matches!(key_event.code, KeyCode::Char('r') | KeyCode::Char('R'))
1134 && !self.blocks_global_chord_shortcuts()
1135 {
1136 if let AppScreen::LibraryBrowse(ref lib) = self.screen {
1137 if !lib.any_search_bar_open()
1138 && !lib.any_upload_prompt_open()
1139 && !self.library_upload_inflight
1140 && !self.library_scan_inflight
1141 {
1142 self.spawn_library_rescan_worker(ScanCacheInvalidate::AllPlatforms);
1143 }
1144 }
1145 continue;
1146 }
1147 if key_event.kind == KeyEventKind::Press
1148 && key_event.modifiers.contains(KeyModifiers::CONTROL)
1149 && matches!(key_event.code, KeyCode::Char('u') | KeyCode::Char('U'))
1150 && !self.blocks_global_chord_shortcuts()
1151 {
1152 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1153 if lib.any_upload_prompt_open() {
1154 lib.close_upload_prompt();
1155 } else if !lib.any_search_bar_open()
1156 && !self.library_upload_inflight
1157 && !self.library_scan_inflight
1158 {
1159 if lib.subsection
1160 == super::screens::library_browse::LibrarySubsection::ByConsole
1161 {
1162 lib.open_upload_prompt();
1163 } else {
1164 lib.set_metadata_footer(Some(
1165 "Upload requires Consoles view — press t".into(),
1166 ));
1167 }
1168 }
1169 }
1170 continue;
1171 }
1172 if key_event.kind == KeyEventKind::Press
1173 && self.handle_key_event(&key_event).await?
1174 {
1175 break;
1176 }
1177 }
1178 }
1179
1180 if let Some((key, req, expected, context, started)) = self.deferred_load_roms.take() {
1184 if let Some(ref k) = key {
1186 if let Some(cached) = self.rom_cache.get_valid(k, expected) {
1187 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1188 lib.set_roms(cached.clone());
1189 lib.set_rom_loading(false);
1190 tracing::debug!(
1191 "rom-list-render context={} latency_ms={} (cache_hit)",
1192 context,
1193 started.elapsed().as_millis()
1194 );
1195 }
1196 continue;
1197 }
1198 }
1199
1200 if started.elapsed() < std::time::Duration::from_millis(250) {
1202 self.deferred_load_roms = Some((key, req, expected, context, started));
1204 continue;
1205 }
1206
1207 self.rom_load_gen = self.rom_load_gen.saturating_add(1);
1208 let gen = self.rom_load_gen;
1209 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1210 lib.set_rom_loading(expected > 0);
1211 }
1212 if expected == 0 {
1213 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1214 lib.set_rom_loading(false);
1215 }
1216 continue;
1217 }
1218
1219 let Some(r) = req else {
1220 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1221 lib.set_rom_loading(false);
1222 }
1223 continue;
1224 };
1225 let client = self.client.clone();
1226 let tx = self.rom_load_tx.clone();
1227
1228 if let Some(task) = self.rom_load_task.take() {
1229 task.abort();
1230 }
1231
1232 self.rom_load_task = Some(tokio::spawn(async move {
1233 let mut req = r;
1234 let mut aggregated: Option<RomList> = None;
1235
1236 loop {
1237 match client.call(&req).await {
1238 Ok(mut batch) => {
1239 if let Some(ref mut all) = aggregated {
1240 if batch.items.is_empty() {
1241 break;
1242 }
1243 all.items.append(&mut batch.items);
1244 let _ = tx.send(RomLoadDone {
1245 gen,
1246 key: key.clone(),
1247 expected,
1248 event: RomLoadEvent::Batch(all.clone()),
1249 context,
1250 started,
1251 });
1252 if all.items.len() as u64 >= all.total {
1253 break;
1254 }
1255 req.offset = Some(all.items.len() as u32);
1256 } else {
1257 let loaded = batch.items.len() as u64;
1258 let total = batch.total;
1259 let _ = tx.send(RomLoadDone {
1260 gen,
1261 key: key.clone(),
1262 expected,
1263 event: RomLoadEvent::Batch(batch.clone()),
1264 context,
1265 started,
1266 });
1267 req.offset = Some(loaded as u32);
1268 aggregated = Some(batch);
1269 if loaded >= total {
1270 break;
1271 }
1272 }
1273 }
1274 Err(e) => {
1275 let _ = tx.send(RomLoadDone {
1276 gen,
1277 key: key.clone(),
1278 expected,
1279 event: RomLoadEvent::Failed(format!("{e:#}")),
1280 context,
1281 started,
1282 });
1283 return;
1284 }
1285 }
1286 if let Some(ref all) = aggregated {
1288 if all.items.len() >= 20000 {
1289 break;
1290 }
1291 }
1292 }
1293
1294 let _ = tx.send(RomLoadDone {
1295 gen,
1296 key,
1297 expected,
1298 event: RomLoadEvent::Complete,
1299 context,
1300 started,
1301 });
1302 }));
1303 }
1304 }
1305
1306 disable_raw_mode()?;
1307 execute!(
1308 terminal.backend_mut(),
1309 LeaveAlternateScreen,
1310 DisableMouseCapture
1311 )?;
1312 terminal.show_cursor()?;
1313 Ok(())
1314 }
1315
1316 async fn fetch_roms_full(client: RommClient, req: GetRoms) -> Result<RomList> {
1320 let mut roms = client.call(&req).await?;
1321 let total = roms.total;
1322 let ceiling = 20000;
1323 while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
1324 let mut next_req = req.clone();
1325 next_req.offset = Some(roms.items.len() as u32);
1326 let next_batch = client.call(&next_req).await?;
1327 if next_batch.items.is_empty() {
1328 break;
1329 }
1330 roms.items.extend(next_batch.items);
1331 }
1332 Ok(roms)
1333 }
1334
1335 pub async fn handle_key_event(&mut self, key: &KeyEvent) -> Result<bool> {
1340 if key.kind != KeyEventKind::Press {
1341 return Ok(false);
1342 }
1343
1344 if self.global_error.is_some() || self.global_notice.is_some() {
1345 if key.code == KeyCode::Esc || key.code == KeyCode::Enter {
1346 self.global_error = None;
1347 self.global_notice = None;
1348 }
1349 return Ok(false);
1350 }
1351
1352 if self.startup_splash.is_some() {
1355 self.startup_splash = None;
1356 return Ok(false);
1357 }
1358
1359 if self.startup_update_prompt.is_some() {
1360 return self.handle_startup_update_prompt(key).await;
1361 }
1362
1363 if self.show_keyboard_help {
1364 if matches!(
1365 key.code,
1366 KeyCode::Esc | KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('?')
1367 ) {
1368 self.show_keyboard_help = false;
1369 }
1370 return Ok(false);
1371 }
1372
1373 if key.code == KeyCode::F(1) {
1374 self.show_keyboard_help = true;
1375 return Ok(false);
1376 }
1377 if key.code == KeyCode::Char('?') && self.allows_global_question_help() {
1378 self.show_keyboard_help = true;
1379 return Ok(false);
1380 }
1381
1382 if key.code == KeyCode::Char('d') && !self.blocks_global_d_shortcut() {
1384 self.toggle_download_screen();
1385 return Ok(false);
1386 }
1387
1388 match &self.screen {
1389 AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
1390 AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
1391 AppScreen::Search(_) => self.handle_search(key).await,
1392 AppScreen::Settings(_) => self.handle_settings(key).await,
1393 AppScreen::Browse(_) => self.handle_browse(key),
1394 AppScreen::Execute(_) => self.handle_execute(key).await,
1395 AppScreen::Result(_) => self.handle_result(key),
1396 AppScreen::ResultDetail(_) => self.handle_result_detail(key),
1397 AppScreen::GameDetail(_) => self.handle_game_detail(key),
1398 AppScreen::ExtrasPicker(_) => self.handle_extras_picker(key),
1399 AppScreen::Download(_) => self.handle_download(key),
1400 AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
1401 }
1402 }
1403
1404 async fn handle_startup_update_prompt(&mut self, key: &KeyEvent) -> Result<bool> {
1405 let Some(ref mut prompt) = self.startup_update_prompt else {
1406 return Ok(false);
1407 };
1408 if prompt.updating {
1409 return Ok(false); }
1411
1412 match key.code {
1413 KeyCode::Char('u')
1414 | KeyCode::Char('U')
1415 | KeyCode::Char('y')
1416 | KeyCode::Char('Y')
1417 | KeyCode::Enter => {
1418 prompt.updating = true;
1419 Ok(false)
1420 }
1421 KeyCode::Char('c') | KeyCode::Char('C') => {
1422 if let Err(err) = crate::update::open_changelog_in_browser() {
1423 self.global_error = Some(format!("Could not open changelog: {err:#}"));
1424 } else {
1425 self.global_notice =
1426 Some(format!("Opened changelog: {}", prompt.status.changelog_url));
1427 }
1428 Ok(false)
1429 }
1430 KeyCode::Esc
1431 | KeyCode::Char('s')
1432 | KeyCode::Char('S')
1433 | KeyCode::Char('n')
1434 | KeyCode::Char('N')
1435 | KeyCode::Char('q')
1436 | KeyCode::Char('Q') => {
1437 self.startup_update_prompt = None;
1438 Ok(false)
1439 }
1440 _ => Ok(false),
1441 }
1442 }
1443
1444 fn toggle_download_screen(&mut self) {
1447 let current =
1448 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1449 match current {
1450 AppScreen::Download(_) => {
1451 self.screen = self
1452 .screen_before_download
1453 .take()
1454 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
1455 }
1456 other => {
1457 self.screen_before_download = Some(other);
1458 self.screen = AppScreen::Download(DownloadScreen::new(
1459 self.downloads.shared(),
1460 self.downloads.shared_extras(),
1461 ));
1462 }
1463 }
1464 }
1465
1466 fn handle_download(&mut self, key: &KeyEvent) -> Result<bool> {
1467 if key.code == KeyCode::Esc || key.code == KeyCode::Char('d') {
1468 self.screen = self
1469 .screen_before_download
1470 .take()
1471 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
1472 }
1473 Ok(false)
1474 }
1475
1476 async fn handle_main_menu(&mut self, key: &KeyEvent) -> Result<bool> {
1479 let menu = match &mut self.screen {
1480 AppScreen::MainMenu(m) => m,
1481 _ => return Ok(false),
1482 };
1483 match key.code {
1484 KeyCode::Up | KeyCode::Char('k') => menu.previous(),
1485 KeyCode::Down | KeyCode::Char('j') => menu.next(),
1486 KeyCode::Enter => match menu.selected {
1487 0 => {
1488 let start = Instant::now();
1489 let snap = startup_library_snapshot::load_snapshot();
1490 let (platforms, collections, from_disk) = match snap {
1491 Some(s) => (s.platforms, s.collections, true),
1492 None => (Vec::new(), Vec::new(), false),
1493 };
1494 let mut lib = LibraryBrowseScreen::new(platforms, collections);
1495 if from_disk && lib.list_len() > 0 {
1496 lib.set_metadata_footer(Some(
1497 "Refreshing library metadata in background…".into(),
1498 ));
1499 } else if lib.list_len() == 0 {
1500 lib.set_metadata_footer(Some("Loading library metadata…".into()));
1501 }
1502 if lib.list_len() > 0 {
1503 let key = lib.cache_key();
1504 let expected = lib.expected_rom_count();
1505 let req = Self::selected_rom_request_for_library(&lib);
1506 lib.set_rom_loading(expected > 0);
1507 self.deferred_load_roms = Some((
1508 key,
1509 req,
1510 expected,
1511 "startup_first_selection",
1512 Instant::now(),
1513 ));
1514 }
1515 self.screen = AppScreen::LibraryBrowse(lib);
1516 self.spawn_library_metadata_refresh();
1517 tracing::debug!(
1518 "library-open latency_ms={} snapshot_hit={}",
1519 start.elapsed().as_millis(),
1520 from_disk
1521 );
1522 }
1523 1 => self.screen = AppScreen::Search(SearchScreen::new()),
1524 2 => {
1525 self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
1526 self.screen = AppScreen::Download(DownloadScreen::new(
1527 self.downloads.shared(),
1528 self.downloads.shared_extras(),
1529 ));
1530 }
1531 3 => {
1532 self.screen = AppScreen::Settings(Box::new(SettingsScreen::new(
1533 &self.config,
1534 self.server_version.as_deref(),
1535 self.save_sync_compat.clone(),
1536 )))
1537 }
1538 4 => return Ok(true),
1539 _ => {}
1540 },
1541 KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
1542 _ => {}
1543 }
1544 Ok(false)
1545 }
1546
1547 async fn handle_library_browse(&mut self, key: &KeyEvent) -> Result<bool> {
1550 use super::path_picker::PathPickerEvent;
1551 use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
1552
1553 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1554 if lib.upload_prompt.is_some() {
1555 if let Some(up) = lib.upload_prompt.as_mut() {
1556 if key.code == KeyCode::Esc {
1557 lib.close_upload_prompt();
1558 return Ok(false);
1559 }
1560 if key.modifiers.contains(KeyModifiers::CONTROL)
1561 && matches!(key.code, KeyCode::Char('s') | KeyCode::Char('S'))
1562 {
1563 up.scan_after = !up.scan_after;
1564 return Ok(false);
1565 }
1566 match up.picker.handle_key(key) {
1567 PathPickerEvent::Confirmed(path) => {
1568 let scan_after = up.scan_after;
1569 if !Path::new(&path).is_file() {
1570 lib.set_metadata_footer(Some(format!(
1571 "Not a file: {}",
1572 path.display()
1573 )));
1574 return Ok(false);
1575 }
1576 let Some(pid) = lib.selected_platform_id() else {
1577 lib.set_metadata_footer(Some(
1578 "Select a console before uploading.".into(),
1579 ));
1580 return Ok(false);
1581 };
1582 lib.close_upload_prompt();
1583 self.spawn_library_upload_worker(pid, path, scan_after);
1584 }
1585 PathPickerEvent::None => {}
1586 }
1587 }
1588 return Ok(false);
1589 }
1590 }
1591
1592 if self.library_upload_inflight {
1593 return Ok(false);
1594 }
1595
1596 let lib = match &mut self.screen {
1597 AppScreen::LibraryBrowse(l) => l,
1598 _ => return Ok(false),
1599 };
1600
1601 if lib.view_mode == LibraryViewMode::List {
1603 if let Some(mode) = lib.list_search.mode {
1604 let old_key = lib.cache_key();
1605 match key.code {
1606 KeyCode::Esc => lib.clear_list_search(),
1607 KeyCode::Backspace => lib.delete_list_search_char(),
1608 KeyCode::Char(c) => lib.add_list_search_char(c),
1609 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.list_jump_match(true),
1610 KeyCode::Enter => lib.commit_list_filter_bar(),
1611 _ => {}
1612 }
1613 let new_key = lib.cache_key();
1614 if old_key != new_key && lib.list_len() > 0 {
1615 lib.clear_roms();
1616 let expected = lib.expected_rom_count();
1617 if expected > 0 {
1618 let req = Self::selected_rom_request_for_library(lib);
1619 lib.set_rom_loading(true);
1620 self.deferred_load_roms =
1621 Some((new_key, req, expected, "search_filter", Instant::now()));
1622 } else {
1623 lib.set_rom_loading(false);
1624 self.deferred_load_roms = None;
1625 }
1626 }
1627 return Ok(false);
1628 }
1629 }
1630
1631 if lib.view_mode == LibraryViewMode::Roms {
1633 if let Some(mode) = lib.rom_search.mode {
1634 match key.code {
1635 KeyCode::Esc => lib.clear_rom_search(),
1636 KeyCode::Backspace => lib.delete_rom_search_char(),
1637 KeyCode::Char(c) => lib.add_rom_search_char(c),
1638 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_rom_match(true),
1639 KeyCode::Enter => lib.commit_rom_filter_bar(),
1640 _ => {}
1641 }
1642 return Ok(false);
1643 }
1644 }
1645
1646 match key.code {
1647 KeyCode::Up | KeyCode::Char('k') => {
1648 if lib.view_mode == LibraryViewMode::List {
1649 lib.list_previous();
1650 if lib.list_len() > 0 {
1651 lib.clear_roms(); let key = lib.cache_key();
1653 let expected = lib.expected_rom_count();
1654 if expected > 0 {
1655 let req = Self::selected_rom_request_for_library(lib);
1656 lib.set_rom_loading(true);
1657 self.deferred_load_roms =
1658 Some((key, req, expected, "list_move_up", Instant::now()));
1659 } else {
1660 lib.set_rom_loading(false);
1661 self.deferred_load_roms = None;
1662 }
1663 if lib.subsection
1664 == super::screens::library_browse::LibrarySubsection::ByCollection
1665 {
1666 tracing::debug!("collections-selection move=up expected={expected}");
1667 self.queue_collection_prefetches_from_screen(1, "move_up");
1668 }
1669 }
1670 } else {
1671 lib.rom_previous();
1672 }
1673 }
1674 KeyCode::Down | KeyCode::Char('j') => {
1675 if lib.view_mode == LibraryViewMode::List {
1676 lib.list_next();
1677 if lib.list_len() > 0 {
1678 lib.clear_roms(); let key = lib.cache_key();
1680 let expected = lib.expected_rom_count();
1681 if expected > 0 {
1682 let req = Self::selected_rom_request_for_library(lib);
1683 lib.set_rom_loading(true);
1684 self.deferred_load_roms =
1685 Some((key, req, expected, "list_move_down", Instant::now()));
1686 } else {
1687 lib.set_rom_loading(false);
1688 self.deferred_load_roms = None;
1689 }
1690 if lib.subsection
1691 == super::screens::library_browse::LibrarySubsection::ByCollection
1692 {
1693 tracing::debug!("collections-selection move=down expected={expected}");
1694 self.queue_collection_prefetches_from_screen(1, "move_down");
1695 }
1696 }
1697 } else {
1698 lib.rom_next();
1699 }
1700 }
1701 KeyCode::Left | KeyCode::Char('h') if lib.view_mode == LibraryViewMode::Roms => {
1702 lib.back_to_list();
1703 }
1704 KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
1705 KeyCode::Tab => {
1706 if lib.view_mode == LibraryViewMode::List {
1707 lib.switch_view();
1708 } else {
1709 lib.switch_view(); }
1711 }
1712 KeyCode::Char('/') => match lib.view_mode {
1713 LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Filter),
1714 LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Filter),
1715 },
1716 KeyCode::Char('f') => match lib.view_mode {
1717 LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Jump),
1718 LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Jump),
1719 },
1720 KeyCode::Enter => {
1721 if lib.view_mode == LibraryViewMode::List {
1722 lib.switch_view();
1723 } else if let Some((primary, others)) = lib.get_selected_group() {
1724 let lib_screen = std::mem::replace(
1725 &mut self.screen,
1726 AppScreen::MainMenu(MainMenuScreen::new()),
1727 );
1728 if let AppScreen::LibraryBrowse(l) = lib_screen {
1729 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
1730 primary,
1731 others,
1732 GameDetailPrevious::Library(Box::new(l)),
1733 self.downloads.shared(),
1734 )));
1735 self.maybe_start_game_detail_cover_load();
1736 self.refresh_current_game_saves();
1737 }
1738 }
1739 }
1740 KeyCode::Char('t') => {
1741 lib.switch_subsection();
1742 if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 {
1745 let key = lib.cache_key();
1746 let expected = lib.expected_rom_count();
1747 if expected > 0 {
1748 let req = Self::selected_rom_request_for_library(lib);
1749 lib.set_rom_loading(true);
1750 self.deferred_load_roms =
1751 Some((key, req, expected, "switch_subsection", Instant::now()));
1752 } else {
1753 lib.set_rom_loading(false);
1754 self.deferred_load_roms = None;
1755 }
1756 }
1757 if lib.subsection == super::screens::library_browse::LibrarySubsection::ByCollection
1758 {
1759 tracing::debug!("collections-subsection entered");
1760 self.queue_collection_prefetches_from_screen(1, "enter_collections");
1761 }
1762 }
1763 KeyCode::Esc => {
1764 if lib.view_mode == LibraryViewMode::Roms {
1765 if lib.rom_search.filter_browsing {
1766 lib.clear_rom_search();
1767 } else {
1768 lib.back_to_list();
1769 }
1770 } else if lib.list_search.filter_browsing {
1771 lib.clear_list_search();
1772 } else {
1773 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
1774 }
1775 }
1776 KeyCode::Char('q') => return Ok(true),
1777 _ => {}
1778 }
1779 Ok(false)
1780 }
1781
1782 async fn handle_search(&mut self, key: &KeyEvent) -> Result<bool> {
1785 let search = match &mut self.screen {
1786 AppScreen::Search(s) => s,
1787 _ => return Ok(false),
1788 };
1789 match key.code {
1790 KeyCode::Backspace => search.delete_char(),
1791 KeyCode::Left => search.cursor_left(),
1792 KeyCode::Right => search.cursor_right(),
1793 KeyCode::Up => search.previous(),
1794 KeyCode::Down => search.next(),
1795 KeyCode::Char(c) => search.add_char(c),
1796 KeyCode::Enter => {
1797 if search.query.is_empty() {
1798 } else if search.result_groups.is_some() && search.results_match_current_query() {
1800 if let Some((primary, others)) = search.get_selected_group() {
1801 let prev = std::mem::replace(
1802 &mut self.screen,
1803 AppScreen::MainMenu(MainMenuScreen::new()),
1804 );
1805 if let AppScreen::Search(s) = prev {
1806 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
1807 primary,
1808 others,
1809 GameDetailPrevious::Search(s),
1810 self.downloads.shared(),
1811 )));
1812 self.maybe_start_game_detail_cover_load();
1813 self.refresh_current_game_saves();
1814 }
1815 }
1816 } else {
1817 let query = search.query.clone();
1818 let req = GetRoms {
1819 search_term: Some(query.clone()),
1820 limit: Some(50),
1821 ..Default::default()
1822 };
1823 search.loading = true;
1824 if let Some(task) = self.search_load_task.take() {
1825 task.abort();
1826 }
1827 let client = self.client.clone();
1828 let tx = self.search_load_tx.clone();
1829 self.search_load_task = Some(tokio::spawn(async move {
1830 let mut req = req;
1831 let mut aggregated: Option<RomList> = None;
1832
1833 loop {
1834 match client.call(&req).await {
1835 Ok(mut batch) => {
1836 if let Some(ref mut all) = aggregated {
1837 if batch.items.is_empty() {
1838 break;
1839 }
1840 all.items.append(&mut batch.items);
1841 let _ = tx.send(SearchLoadDone {
1842 query: query.clone(),
1843 event: SearchLoadEvent::Batch(all.clone()),
1844 });
1845 if all.items.len() as u64 >= all.total {
1846 break;
1847 }
1848 req.offset = Some(all.items.len() as u32);
1849 } else {
1850 let loaded = batch.items.len() as u64;
1851 let total = batch.total;
1852 let _ = tx.send(SearchLoadDone {
1853 query: query.clone(),
1854 event: SearchLoadEvent::Batch(batch.clone()),
1855 });
1856 req.offset = Some(loaded as u32);
1857 aggregated = Some(batch);
1858 if loaded >= total {
1859 break;
1860 }
1861 }
1862 }
1863 Err(e) => {
1864 let _ = tx.send(SearchLoadDone {
1865 query: query.clone(),
1866 event: SearchLoadEvent::Failed(format!("{e:#}")),
1867 });
1868 return;
1869 }
1870 }
1871 }
1872
1873 let _ = tx.send(SearchLoadDone {
1874 query,
1875 event: SearchLoadEvent::Complete,
1876 });
1877 }));
1878 }
1879 }
1880 KeyCode::Esc => {
1881 if search.results.is_some() {
1882 search.clear_results();
1883 } else {
1884 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
1885 }
1886 }
1887 _ => {}
1888 }
1889 Ok(false)
1890 }
1891
1892 async fn refresh_settings_server_version(&mut self) -> Result<()> {
1895 let (base_url, download_dir, use_https, verbose, auth) = {
1896 let settings = match &self.screen {
1897 AppScreen::Settings(s) => s,
1898 _ => return Ok(()),
1899 };
1900 let mut base_url = normalize_romm_origin(settings.base_url.trim());
1901 if settings.use_https && base_url.starts_with("http://") {
1902 base_url = base_url.replace("http://", "https://");
1903 }
1904 if !settings.use_https && base_url.starts_with("https://") {
1905 base_url = base_url.replace("https://", "http://");
1906 }
1907 (
1908 base_url,
1909 settings.download_dir.clone(),
1910 settings.use_https,
1911 self.client.verbose(),
1912 self.config.auth.clone(),
1913 )
1914 };
1915 let cfg = Config {
1916 base_url,
1917 download_dir,
1918 use_https,
1919 auth,
1920 extras_defaults: self.config.extras_defaults.clone(),
1921 save_sync: self.config.save_sync.clone(),
1922 roms_layout: self.config.roms_layout.clone(),
1923 };
1924 let client = match RommClient::new(&cfg, verbose) {
1925 Ok(c) => c,
1926 Err(_) => {
1927 if let AppScreen::Settings(s) = &mut self.screen {
1928 s.server_version = "unavailable (invalid URL or client error)".to_string();
1929 self.server_version = None;
1930 }
1931 return Ok(());
1932 }
1933 };
1934 let ver = client.rom_server_version_from_heartbeat().await;
1935 if let AppScreen::Settings(s) = &mut self.screen {
1936 match ver {
1937 Some(v) => {
1938 s.server_version = v.clone();
1939 self.server_version = Some(v);
1940 }
1941 None => {
1942 s.server_version = "unavailable (heartbeat failed)".to_string();
1943 self.server_version = None;
1944 }
1945 }
1946 }
1947 Ok(())
1948 }
1949
1950 async fn handle_settings(&mut self, key: &KeyEvent) -> Result<bool> {
1951 use super::path_picker::PathPickerEvent;
1952 use crate::core::download::validate_configured_download_directory;
1953
1954 let settings = match &mut self.screen {
1955 AppScreen::Settings(s) => s,
1956 _ => return Ok(false),
1957 };
1958
1959 if let Some((kind, ref mut picker)) = settings.path_picker {
1960 if key.code == KeyCode::Esc {
1961 settings.path_picker = None;
1962 return Ok(false);
1963 }
1964 match picker.handle_key(key) {
1965 PathPickerEvent::Confirmed(p) => {
1966 match validate_configured_download_directory(p.to_string_lossy().as_ref()) {
1967 Ok(canonical) => {
1968 if kind == super::screens::settings::SettingsPickerKind::RomsDir {
1969 settings.download_dir = canonical.display().to_string();
1970 settings.message = Some((
1971 "ROMs directory updated (press S to save)".to_string(),
1972 Color::Green,
1973 ));
1974 } else {
1975 settings.save_dir = canonical.display().to_string();
1976 settings.message = Some((
1977 "Save directory updated (press S to save)".to_string(),
1978 Color::Green,
1979 ));
1980 }
1981 settings.path_picker = None;
1982 }
1983 Err(e) => {
1984 settings.message =
1985 Some((format!("Invalid ROMs directory: {e:#}"), Color::Red));
1986 }
1987 }
1988 }
1989 PathPickerEvent::None => {}
1990 }
1991 return Ok(false);
1992 }
1993
1994 if let Some((platform_id, ref mut picker)) = settings.console_path_picker {
1995 if key.code == KeyCode::Esc {
1996 settings.console_path_picker = None;
1997 return Ok(false);
1998 }
1999 match picker.handle_key(key) {
2000 PathPickerEvent::Confirmed(p) => {
2001 match validate_configured_download_directory(p.to_string_lossy().as_ref()) {
2002 Ok(canonical) => {
2003 settings
2004 .confirm_console_path(platform_id, canonical.display().to_string());
2005 }
2006 Err(e) => {
2007 settings.message =
2008 Some((format!("Invalid console directory: {e:#}"), Color::Red));
2009 }
2010 }
2011 }
2012 PathPickerEvent::None => {}
2013 }
2014 return Ok(false);
2015 }
2016
2017 if settings.console_picker_open {
2018 match key.code {
2019 KeyCode::Esc => {
2020 settings.console_picker_open = false;
2021 settings.active_console_kind = None;
2022 }
2023 KeyCode::Up | KeyCode::Char('k') => settings.console_previous(),
2024 KeyCode::Down | KeyCode::Char('j') => settings.console_next(),
2025 KeyCode::Enter => settings.open_console_path_picker(),
2026 KeyCode::Delete | KeyCode::Backspace => {
2027 if let Some(platform) = settings
2028 .console_platforms
2029 .get(settings.console_selected_index)
2030 {
2031 settings.clear_console_path(platform.id);
2032 }
2033 }
2034 _ => {}
2035 }
2036 return Ok(false);
2037 }
2038
2039 if settings.device_picker_open {
2040 match key.code {
2041 KeyCode::Esc => {
2042 settings.device_picker_open = false;
2043 settings.device_picker_loading = false;
2044 }
2045 KeyCode::Up | KeyCode::Char('k') => settings.device_previous(),
2046 KeyCode::Down | KeyCode::Char('j') => settings.device_next(),
2047 KeyCode::Enter => settings.confirm_device(),
2048 _ => {}
2049 }
2050 return Ok(false);
2051 }
2052
2053 if settings.confirm.is_some() {
2054 match key.code {
2055 KeyCode::Enter => match settings.confirm.take().unwrap() {
2056 super::screens::settings::SettingsConfirm::Reset => {
2057 let _ = crate::config::reset_all_settings();
2058 settings.message = Some((
2059 "Settings deleted. Please restart romm-cli.".to_string(),
2060 Color::Yellow,
2061 ));
2062 }
2063 super::screens::settings::SettingsConfirm::ClearCache => {
2064 match crate::core::cache::RomCache::clear_file() {
2065 Ok(true) => {
2066 self.rom_cache = crate::core::cache::RomCache::load();
2067 settings.message =
2068 Some(("ROM cache cleared.".to_string(), Color::Green));
2069 }
2070 Ok(false) => {
2071 settings.message = Some((
2072 "ROM cache file does not exist.".to_string(),
2073 Color::Yellow,
2074 ));
2075 }
2076 Err(e) => {
2077 settings.message =
2078 Some((format!("Failed to clear cache: {e}"), Color::Red));
2079 }
2080 }
2081 }
2082 },
2083 KeyCode::Esc => {
2084 settings.confirm = None;
2085 }
2086 _ => {}
2087 }
2088 return Ok(false);
2089 }
2090
2091 if settings.editing {
2092 match key.code {
2093 KeyCode::Enter => {
2094 let row = settings.selected_row();
2095 settings.save_edit();
2096 if row == SettingsRow::BaseUrl {
2097 self.refresh_settings_server_version().await?;
2098 }
2099 }
2100 KeyCode::Esc => settings.cancel_edit(),
2101 KeyCode::Backspace => settings.delete_char(),
2102 KeyCode::Left => settings.move_cursor_left(),
2103 KeyCode::Right => settings.move_cursor_right(),
2104 KeyCode::Char(c) => settings.add_char(c),
2105 _ => {}
2106 }
2107 return Ok(false);
2108 }
2109
2110 match key.code {
2111 KeyCode::Up | KeyCode::Char('k') => settings.previous(),
2112 KeyCode::Down | KeyCode::Char('j') => settings.next(),
2113 KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => settings.next_tab(),
2114 KeyCode::Left | KeyCode::Char('h') | KeyCode::BackTab => settings.previous_tab(),
2115 KeyCode::Enter => {
2116 let row = settings.selected_row();
2117 if row == SettingsRow::Auth {
2118 self.screen =
2119 AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
2120 } else if row == SettingsRow::ConsolePaths {
2121 settings.open_console_picker(ConsolePathKind::Roms);
2122 let client = self.client.clone();
2123 let tx = self.platform_list_tx.clone();
2124 tokio::spawn(async move {
2125 let result = client
2126 .call(&ListPlatforms)
2127 .await
2128 .map_err(|e| format!("{e:#}"));
2129 let _ = tx.send(PlatformListDone { result });
2130 });
2131 } else if row == SettingsRow::SaveConsolePaths {
2132 settings.open_console_picker(ConsolePathKind::Saves);
2133 let client = self.client.clone();
2134 let tx = self.platform_list_tx.clone();
2135 tokio::spawn(async move {
2136 let result = client
2137 .call(&ListPlatforms)
2138 .await
2139 .map_err(|e| format!("{e:#}"));
2140 let _ = tx.send(PlatformListDone { result });
2141 });
2142 } else if row == SettingsRow::SyncDevice {
2143 if !settings.save_sync_supported() {
2144 settings.set_save_sync_unsupported_message();
2145 return Ok(false);
2146 }
2147 settings.enter_edit();
2148 let client = self.client.clone();
2149 let tx = self.device_list_tx.clone();
2150 tokio::spawn(async move {
2151 let result = client
2152 .call(&ListDevices)
2153 .await
2154 .map_err(|e| format!("{e:#}"));
2155 let _ = tx.send(DeviceListDone { result });
2156 });
2157 } else if row == SettingsRow::SyncNow {
2158 if !settings.save_sync_supported() {
2159 settings.set_save_sync_unsupported_message();
2160 return Ok(false);
2161 }
2162 if settings.sync_inflight {
2163 return Ok(false);
2164 }
2165 let Some(device_id) = settings.sync_device_id.clone() else {
2166 settings.message =
2167 Some(("Choose a Sync Device first".to_string(), Color::Yellow));
2168 return Ok(false);
2169 };
2170 settings.sync_inflight = true;
2171 settings.message =
2172 Some(("Sync Saves Now running...".to_string(), Color::Yellow));
2173 let client = self.client.clone();
2174 let tx = self.sync_push_pull_tx.clone();
2175 tokio::spawn(async move {
2176 let result = client
2177 .call(&TriggerPushPull { device_id })
2178 .await
2179 .map_err(|e| format!("{e:#}"));
2180 let _ = tx.send(SyncPushPullDone { result });
2181 });
2182 } else {
2183 let toggle_https = row == SettingsRow::UseHttps;
2184 settings.enter_edit();
2185 if toggle_https {
2186 self.refresh_settings_server_version().await?;
2187 }
2188 }
2189 }
2190 KeyCode::Char('s' | 'S') => {
2191 use crate::config::persist_user_config;
2193 let auth = auth_for_persist_merge(self.config.auth.clone());
2194 let cfg = Config {
2195 base_url: settings.base_url.clone(),
2196 download_dir: settings.download_dir.clone(),
2197 use_https: settings.use_https,
2198 auth,
2199 extras_defaults: ExtrasDefaults {
2200 include_related_roms: settings.extras_include_related_roms,
2201 include_cover: settings.extras_include_cover,
2202 include_manual: settings.extras_include_manual,
2203 },
2204 save_sync: settings.save_sync_config(),
2205 roms_layout: settings.roms_layout_config(),
2206 };
2207 if let Err(e) = persist_user_config(&cfg) {
2208 settings.message = Some((format!("Error saving: {e}"), Color::Red));
2209 } else {
2210 settings.message = Some(("Saved to config.json".to_string(), Color::Green));
2211 self.config.base_url = cfg.base_url.clone();
2213 self.config.download_dir = cfg.download_dir.clone();
2214 self.config.use_https = cfg.use_https;
2215 self.config.extras_defaults = cfg.extras_defaults.clone();
2216 self.config.save_sync = cfg.save_sync.clone();
2217 self.config.roms_layout = cfg.roms_layout.clone();
2218 if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
2220 self.client = new_client;
2221 }
2222 }
2223 }
2224 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
2225 KeyCode::Char('q') => return Ok(true),
2226 _ => {}
2227 }
2228 Ok(false)
2229 }
2230
2231 fn handle_browse(&mut self, key: &KeyEvent) -> Result<bool> {
2234 use super::screens::browse::ViewMode;
2235
2236 let browse = match &mut self.screen {
2237 AppScreen::Browse(b) => b,
2238 _ => return Ok(false),
2239 };
2240 match key.code {
2241 KeyCode::Up | KeyCode::Char('k') => browse.previous(),
2242 KeyCode::Down | KeyCode::Char('j') => browse.next(),
2243 KeyCode::Left | KeyCode::Char('h') if browse.view_mode == ViewMode::Endpoints => {
2244 browse.switch_view();
2245 }
2246 KeyCode::Right | KeyCode::Char('l') if browse.view_mode == ViewMode::Sections => {
2247 browse.switch_view();
2248 }
2249 KeyCode::Tab => browse.switch_view(),
2250 KeyCode::Enter => {
2251 if browse.view_mode == ViewMode::Endpoints {
2252 if let Some(ep) = browse.get_selected_endpoint() {
2253 self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
2254 }
2255 } else {
2256 browse.switch_view();
2257 }
2258 }
2259 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
2260 _ => {}
2261 }
2262 Ok(false)
2263 }
2264
2265 async fn handle_execute(&mut self, key: &KeyEvent) -> Result<bool> {
2268 let execute = match &mut self.screen {
2269 AppScreen::Execute(e) => e,
2270 _ => return Ok(false),
2271 };
2272 match key.code {
2273 KeyCode::Tab => execute.next_field(),
2274 KeyCode::BackTab => execute.previous_field(),
2275 KeyCode::Char(c) => execute.add_char_to_focused(c),
2276 KeyCode::Backspace => execute.delete_char_from_focused(),
2277 KeyCode::Enter => {
2278 let endpoint = execute.endpoint.clone();
2279 let query = execute.get_query_params();
2280 let body = if endpoint.has_body && !execute.body_text.is_empty() {
2281 Some(serde_json::from_str(&execute.body_text)?)
2282 } else {
2283 None
2284 };
2285 let resolved_path =
2286 match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
2287 Ok(p) => p,
2288 Err(e) => {
2289 self.screen = AppScreen::Result(ResultScreen::new(
2290 serde_json::json!({ "error": format!("{e}") }),
2291 None,
2292 None,
2293 ));
2294 return Ok(false);
2295 }
2296 };
2297 match self
2298 .client
2299 .request_json(&endpoint.method, &resolved_path, &query, body)
2300 .await
2301 {
2302 Ok(result) => {
2303 self.screen = AppScreen::Result(ResultScreen::new(
2304 result,
2305 Some(&endpoint.method),
2306 Some(resolved_path.as_str()),
2307 ));
2308 }
2309 Err(e) => {
2310 self.screen = AppScreen::Result(ResultScreen::new(
2311 serde_json::json!({ "error": format!("{e}") }),
2312 None,
2313 None,
2314 ));
2315 }
2316 }
2317 }
2318 KeyCode::Esc => {
2319 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
2320 }
2321 _ => {}
2322 }
2323 Ok(false)
2324 }
2325
2326 fn handle_result(&mut self, key: &KeyEvent) -> Result<bool> {
2329 use super::screens::result::ResultViewMode;
2330
2331 let result = match &mut self.screen {
2332 AppScreen::Result(r) => r,
2333 _ => return Ok(false),
2334 };
2335 match key.code {
2336 KeyCode::Up | KeyCode::Char('k') => {
2337 if result.view_mode == ResultViewMode::Json {
2338 result.scroll_up(1);
2339 } else {
2340 result.table_previous();
2341 }
2342 }
2343 KeyCode::Down => {
2344 if result.view_mode == ResultViewMode::Json {
2345 result.scroll_down(1);
2346 } else {
2347 result.table_next();
2348 }
2349 }
2350 KeyCode::Char('j') if result.view_mode == ResultViewMode::Json => {
2351 result.scroll_down(1);
2352 }
2353 KeyCode::PageUp => {
2354 if result.view_mode == ResultViewMode::Table {
2355 result.table_page_up();
2356 } else {
2357 result.scroll_up(10);
2358 }
2359 }
2360 KeyCode::PageDown => {
2361 if result.view_mode == ResultViewMode::Table {
2362 result.table_page_down();
2363 } else {
2364 result.scroll_down(10);
2365 }
2366 }
2367 KeyCode::Char('t') if result.table_row_count > 0 => {
2368 result.switch_view_mode();
2369 }
2370 KeyCode::Enter
2371 if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 =>
2372 {
2373 if let Some(item) = result.get_selected_item_value() {
2374 let prev = std::mem::replace(
2375 &mut self.screen,
2376 AppScreen::MainMenu(MainMenuScreen::new()),
2377 );
2378 if let AppScreen::Result(rs) = prev {
2379 self.screen = AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
2380 }
2381 }
2382 }
2383 KeyCode::Esc => {
2384 result.clear_message();
2385 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
2386 }
2387 KeyCode::Char('q') => return Ok(true),
2388 _ => {}
2389 }
2390 Ok(false)
2391 }
2392
2393 fn handle_result_detail(&mut self, key: &KeyEvent) -> Result<bool> {
2396 let detail = match &mut self.screen {
2397 AppScreen::ResultDetail(d) => d,
2398 _ => return Ok(false),
2399 };
2400 match key.code {
2401 KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
2402 KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
2403 KeyCode::PageUp => detail.scroll_up(10),
2404 KeyCode::PageDown => detail.scroll_down(10),
2405 KeyCode::Char('o') => detail.open_image_url(),
2406 KeyCode::Esc => {
2407 detail.clear_message();
2408 let prev =
2409 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2410 if let AppScreen::ResultDetail(d) = prev {
2411 self.screen = AppScreen::Result(d.parent);
2412 }
2413 }
2414 KeyCode::Char('q') => return Ok(true),
2415 _ => {}
2416 }
2417 Ok(false)
2418 }
2419
2420 fn handle_game_detail(&mut self, key: &KeyEvent) -> Result<bool> {
2423 use super::path_picker::PathPickerEvent;
2424 let detail = match &mut self.screen {
2425 AppScreen::GameDetail(d) => d,
2426 _ => return Ok(false),
2427 };
2428
2429 if let Some(picker) = detail.save_upload_picker.as_mut() {
2430 if key.code == KeyCode::Esc {
2431 detail.save_upload_picker = None;
2432 detail.clear_message();
2433 return Ok(false);
2434 }
2435 match picker.handle_key(key) {
2436 PathPickerEvent::Confirmed(path) => {
2437 let rom_id = detail.rom.id;
2438 detail.save_upload_picker = None;
2439 detail.message = Some("Uploading save...".into());
2440 detail.message_clear_at = None;
2441 let client = self.client.clone();
2442 let tx = self.save_upload_tx.clone();
2443 tokio::spawn(async move {
2444 let result = client
2445 .upload_save_file(rom_id, None, &path)
2446 .await
2447 .map(|_| ())
2448 .map_err(|e| format!("{e:#}"));
2449 let _ = tx.send(SaveUploadDone { rom_id, result });
2450 });
2451 }
2452 PathPickerEvent::None => {}
2453 }
2454 return Ok(false);
2455 }
2456
2457 if !detail.download_completion_acknowledged {
2460 if let Ok(list) = detail.downloads.lock() {
2461 let has_completed = list.iter().any(|j| {
2462 j.rom_id == detail.rom.id
2463 && matches!(
2464 j.status,
2465 crate::core::download::DownloadStatus::Done
2466 | crate::core::download::DownloadStatus::SkippedAlreadyExists
2467 | crate::core::download::DownloadStatus::Cancelled
2468 | crate::core::download::DownloadStatus::FinalizeFailed(_)
2469 | crate::core::download::DownloadStatus::Error(_)
2470 )
2471 });
2472 let is_still_downloading = list.iter().any(|j| {
2473 j.rom_id == detail.rom.id
2474 && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
2475 });
2476 if has_completed && !is_still_downloading {
2478 detail.download_completion_acknowledged = true;
2479 }
2480 }
2481 }
2482
2483 let wants_extras = matches!(key.code, KeyCode::Char('e') | KeyCode::Char('E'))
2484 || (key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::SHIFT));
2485 if wants_extras {
2486 if !detail.has_any_extras() {
2487 detail.message = Some("No extras available for this ROM".to_string());
2488 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
2489 return Ok(false);
2490 }
2491 let prev =
2492 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2493 if let AppScreen::GameDetail(g) = prev {
2494 self.screen = AppScreen::ExtrasPicker(Box::new(ExtrasPickerScreen::new(
2495 g,
2496 &self.config.extras_defaults,
2497 )));
2498 }
2499 return Ok(false);
2500 }
2501
2502 match key.code {
2503 KeyCode::Up | KeyCode::Char('k') => detail.save_selection_previous(),
2504 KeyCode::Down | KeyCode::Char('j') => detail.save_selection_next(),
2505 KeyCode::Char('u') => detail.open_save_upload_picker(),
2506 KeyCode::Char('D') => {
2507 let Some(save) = detail.selected_save().cloned() else {
2508 detail.message = Some("No save selected".into());
2509 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
2510 return Ok(false);
2511 };
2512 let rom_id = detail.rom.id;
2513 let rom = detail.rom.clone();
2514 let target_dir = match resolve_game_save_dir(&self.config, &rom) {
2515 Ok(path) => path,
2516 Err(err) => {
2517 detail.message = Some(format!(
2518 "Save download blocked: {err:#}. Fix save paths in Settings."
2519 ));
2520 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
2521 return Ok(false);
2522 }
2523 };
2524 detail.message = Some("Downloading save...".into());
2525 detail.message_clear_at = None;
2526 let client = self.client.clone();
2527 let tx = self.save_download_tx.clone();
2528 tokio::spawn(async move {
2529 let result = async {
2530 let bytes = client.download_save_content(save.id, None, None).await?;
2531 tokio::fs::create_dir_all(&target_dir).await?;
2532 let filename = if save.file_name.trim().is_empty() {
2533 format!("save-{}.sav", save.id)
2534 } else {
2535 save.file_name.clone()
2536 };
2537 let target = unique_save_path(&target_dir, &filename);
2538 tokio::fs::write(&target, bytes).await?;
2539 Ok::<PathBuf, anyhow::Error>(target)
2540 }
2541 .await
2542 .map_err(|e| format!("{e:#}"));
2543 let _ = tx.send(SaveDownloadDone { rom_id, result });
2544 });
2545 }
2546 KeyCode::Enter if !detail.has_started_download => {
2549 match self.downloads.start_download(
2550 &detail.rom,
2551 self.client.clone(),
2552 &self.config.roms_layout,
2553 Some(self.config.download_dir.as_str()),
2554 ) {
2555 Ok(()) => {
2556 detail.has_started_download = true;
2557 if has_update_or_dlc_extras(&detail.rom, &detail.other_files) {
2558 detail.message = Some(
2559 "Updates/DLC available. Press e to download extras.".to_string(),
2560 );
2561 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
2562 }
2563 }
2564 Err(err) => {
2565 detail.has_started_download = false;
2566 detail.message = Some(format!(
2567 "Download blocked: {err}. Fix ROMs directory in settings/setup."
2568 ));
2569 }
2570 }
2571 }
2572 KeyCode::Char('o') => detail.open_cover(),
2573 KeyCode::Char('m') => detail.toggle_technical(),
2574 KeyCode::Esc => {
2575 detail.clear_message();
2576 let prev =
2577 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2578 if let AppScreen::GameDetail(g) = prev {
2579 self.screen = match g.previous {
2580 GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(*l),
2581 GameDetailPrevious::Search(s) => AppScreen::Search(s),
2582 };
2583 }
2584 }
2585 KeyCode::Char('q') => return Ok(true),
2586 _ => {}
2587 }
2588 Ok(false)
2589 }
2590
2591 fn handle_extras_picker(&mut self, key: &KeyEvent) -> Result<bool> {
2592 let picker = match &mut self.screen {
2593 AppScreen::ExtrasPicker(p) => p,
2594 _ => return Ok(false),
2595 };
2596 picker.tick_message();
2597
2598 match key.code {
2599 KeyCode::Esc => {
2600 let prev =
2601 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2602 if let AppScreen::ExtrasPicker(p) = prev {
2603 self.screen = AppScreen::GameDetail(p.previous);
2604 }
2605 }
2606 KeyCode::Up | KeyCode::Char('k') => picker.move_up(),
2607 KeyCode::Down | KeyCode::Char('j') => picker.move_down(),
2608 KeyCode::Char(' ') => picker.toggle_current(),
2609 KeyCode::Char('a') | KeyCode::Char('A') => picker.toggle_all(),
2610 KeyCode::Enter => {
2611 if picker.selected_count() == 0 {
2612 picker.show_message(
2613 "Select at least one item (Space to toggle)",
2614 Duration::from_secs(2),
2615 );
2616 return Ok(false);
2617 }
2618 let targets = match picker.build_selected_targets(
2619 &self.config.roms_layout,
2620 Some(self.config.download_dir.as_str()),
2621 ) {
2622 Ok(t) => t,
2623 Err(e) => {
2624 picker.show_message(format!("{e:#}"), Duration::from_secs(4));
2625 return Ok(false);
2626 }
2627 };
2628 let rom = picker.rom.clone();
2629 let prev =
2630 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2631 if let AppScreen::ExtrasPicker(p) = prev {
2632 match self.downloads.start_extras_download(
2633 &rom,
2634 targets,
2635 self.client.clone(),
2636 Some(self.config.download_dir.as_str()),
2637 ) {
2638 Ok(()) => {
2639 self.screen = AppScreen::GameDetail(p.previous);
2640 }
2641 Err(e) => {
2642 let mut detail = *p.previous;
2643 detail.message = Some(format!("Extras: {e:#}"));
2644 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
2645 self.screen = AppScreen::GameDetail(Box::new(detail));
2646 }
2647 }
2648 }
2649 }
2650 KeyCode::Char('q') => return Ok(true),
2651 _ => {}
2652 }
2653 Ok(false)
2654 }
2655
2656 async fn handle_setup_wizard(&mut self, key: &KeyEvent) -> Result<bool> {
2659 let wizard = match &mut self.screen {
2660 AppScreen::SetupWizard(w) => w,
2661 _ => return Ok(false),
2662 };
2663
2664 if wizard.handle_key(key)? {
2665 self.screen = AppScreen::Settings(Box::new(SettingsScreen::new(
2667 &self.config,
2668 self.server_version.as_deref(),
2669 self.save_sync_compat.clone(),
2670 )));
2671 return Ok(false);
2672 }
2673
2674 if wizard.testing {
2675 let result = wizard.try_connect_and_persist(self.client.verbose()).await;
2676 wizard.testing = false;
2677 match result {
2678 Ok(cfg) => {
2679 let auth_ok = cfg.auth.is_some();
2680 self.config = cfg;
2681 if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
2682 self.client = new_client;
2683 }
2684 let mut settings = SettingsScreen::new(
2685 &self.config,
2686 self.server_version.as_deref(),
2687 self.save_sync_compat.clone(),
2688 );
2689 if auth_ok {
2690 settings.message = Some((
2691 "Authentication updated successfully".to_string(),
2692 Color::Green,
2693 ));
2694 } else {
2695 settings.message = Some((
2696 "Saved configuration but credentials could not be loaded from the OS keyring (see logs)."
2697 .to_string(),
2698 Color::Yellow,
2699 ));
2700 }
2701 self.screen = AppScreen::Settings(Box::new(settings));
2702 }
2703 Err(e) => {
2704 wizard.error = Some(format!("{e:#}"));
2705 }
2706 }
2707 }
2708 Ok(false)
2709 }
2710
2711 fn render(&mut self, f: &mut ratatui::Frame) {
2716 let area = f.area();
2717 if let Some(ref splash) = self.startup_splash {
2718 connected_splash::render(f, area, splash);
2719 } else {
2720 match &mut self.screen {
2721 AppScreen::MainMenu(menu) => menu.render(f, area),
2722 AppScreen::LibraryBrowse(lib) => {
2723 lib.render(f, area);
2724 if let Some((x, y)) = lib.upload_prompt_cursor(area) {
2725 f.set_cursor_position((x, y));
2726 }
2727 }
2728 AppScreen::Search(search) => {
2729 search.render(f, area);
2730 if let Some((x, y)) = search.cursor_position(area) {
2731 f.set_cursor_position((x, y));
2732 }
2733 }
2734 AppScreen::Settings(settings) => {
2735 settings.render(f, area);
2736 if let Some((x, y)) = settings.cursor_position(area) {
2737 f.set_cursor_position((x, y));
2738 }
2739 }
2740 AppScreen::Browse(browse) => browse.render(f, area),
2741 AppScreen::Execute(execute) => {
2742 execute.render(f, area);
2743 if let Some((x, y)) = execute.cursor_position(area) {
2744 f.set_cursor_position((x, y));
2745 }
2746 }
2747 AppScreen::Result(result) => result.render(f, area),
2748 AppScreen::ResultDetail(detail) => detail.render(f, area),
2749 AppScreen::GameDetail(detail) => detail.render(f, area),
2750 AppScreen::ExtrasPicker(picker) => picker.render(f, area),
2751 AppScreen::Download(d) => d.render(f, area),
2752 AppScreen::SetupWizard(wizard) => {
2753 wizard.render(f, area);
2754 if let Some((x, y)) = wizard.cursor_pos(area) {
2755 f.set_cursor_position((x, y));
2756 }
2757 }
2758 }
2759
2760 if self.show_keyboard_help {
2761 keyboard_help::render_keyboard_help(f, area);
2762 }
2763 }
2764
2765 if let Some(prompt) = &self.startup_update_prompt {
2766 let popup_w = 44;
2767 let popup_h = 10;
2768 let popup_area = ratatui::layout::Rect {
2769 x: area.width.saturating_sub(popup_w) / 2,
2770 y: area.height.saturating_sub(popup_h) / 2,
2771 width: popup_w.min(area.width),
2772 height: popup_h.min(area.height),
2773 };
2774 f.render_widget(ratatui::widgets::Clear, popup_area);
2775
2776 let block = ratatui::widgets::Block::default()
2777 .title(" Update Available ")
2778 .title_alignment(ratatui::layout::Alignment::Center)
2779 .borders(ratatui::widgets::Borders::ALL)
2780 .border_style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan));
2781
2782 if prompt.updating {
2783 let text = vec![
2784 ratatui::text::Line::from(""),
2785 ratatui::text::Line::from("Downloading and installing...")
2786 .alignment(ratatui::layout::Alignment::Center),
2787 ratatui::text::Line::from("Please wait.")
2788 .alignment(ratatui::layout::Alignment::Center),
2789 ratatui::text::Line::from(""),
2790 ratatui::text::Line::from("This may take a few moments.")
2791 .alignment(ratatui::layout::Alignment::Center)
2792 .style(
2793 ratatui::style::Style::default().fg(ratatui::style::Color::DarkGray),
2794 ),
2795 ];
2796 let paragraph = ratatui::widgets::Paragraph::new(text).block(block);
2797 f.render_widget(paragraph, popup_area);
2798 } else {
2799 let text = vec![
2800 ratatui::text::Line::from(vec![
2801 ratatui::text::Span::raw("Current: "),
2802 ratatui::text::Span::styled(
2803 &prompt.status.current_version,
2804 ratatui::style::Style::default().fg(ratatui::style::Color::DarkGray),
2805 ),
2806 ])
2807 .alignment(ratatui::layout::Alignment::Center),
2808 ratatui::text::Line::from(vec![
2809 ratatui::text::Span::raw("Latest: "),
2810 ratatui::text::Span::styled(
2811 &prompt.status.latest_version,
2812 ratatui::style::Style::default()
2813 .fg(ratatui::style::Color::Green)
2814 .add_modifier(ratatui::style::Modifier::BOLD),
2815 ),
2816 ])
2817 .alignment(ratatui::layout::Alignment::Center),
2818 ratatui::text::Line::from(""),
2819 ratatui::text::Line::from("Would you like to update?")
2820 .alignment(ratatui::layout::Alignment::Center),
2821 ratatui::text::Line::from(""),
2822 ratatui::text::Line::from(vec![
2823 ratatui::text::Span::styled(
2824 "Y/Enter",
2825 ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
2826 ),
2827 ratatui::text::Span::raw(": Yes (update) "),
2828 ratatui::text::Span::styled(
2829 "N/Esc",
2830 ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
2831 ),
2832 ratatui::text::Span::raw(": No (skip)"),
2833 ])
2834 .alignment(ratatui::layout::Alignment::Center),
2835 ratatui::text::Line::from(vec![
2836 ratatui::text::Span::styled(
2837 "C",
2838 ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
2839 ),
2840 ratatui::text::Span::raw(": View changelog"),
2841 ])
2842 .alignment(ratatui::layout::Alignment::Center),
2843 ];
2844 let paragraph = ratatui::widgets::Paragraph::new(text).block(block);
2845 f.render_widget(paragraph, popup_area);
2846 }
2847 }
2848
2849 if let Some(ref err) = self.global_error {
2850 let popup_area = ratatui::layout::Rect {
2851 x: area.width.saturating_sub(60) / 2,
2852 y: area.height.saturating_sub(10) / 2,
2853 width: 60.min(area.width),
2854 height: 10.min(area.height),
2855 };
2856 f.render_widget(ratatui::widgets::Clear, popup_area);
2857 let block = ratatui::widgets::Block::default()
2858 .title("Error")
2859 .borders(ratatui::widgets::Borders::ALL)
2860 .style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
2861 let text = format!("{}\n\nPress Esc to dismiss", err);
2862 let paragraph = ratatui::widgets::Paragraph::new(text)
2863 .block(block)
2864 .wrap(ratatui::widgets::Wrap { trim: true });
2865 f.render_widget(paragraph, popup_area);
2866 }
2867
2868 if let Some(ref notice) = self.global_notice {
2869 let popup_area = ratatui::layout::Rect {
2870 x: area.width.saturating_sub(60) / 2,
2871 y: area.height.saturating_sub(10) / 2,
2872 width: 60.min(area.width),
2873 height: 10.min(area.height),
2874 };
2875 f.render_widget(ratatui::widgets::Clear, popup_area);
2876 let block = ratatui::widgets::Block::default()
2877 .title("Notice")
2878 .borders(ratatui::widgets::Borders::ALL)
2879 .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan));
2880 let text = format!("{notice}\n\nPress Esc to dismiss");
2881 let paragraph = ratatui::widgets::Paragraph::new(text)
2882 .block(block)
2883 .wrap(ratatui::widgets::Wrap { trim: true });
2884 f.render_widget(paragraph, popup_area);
2885 }
2886 }
2887}
2888
2889#[cfg(test)]
2890mod tests {
2891 use super::*;
2892 use crate::config::{Config, ExtrasDefaults};
2893 use crate::openapi::EndpointRegistry;
2894 use crate::tui::screens::connected_splash::StartupSplash;
2895 use crate::tui::screens::library_browse::LibraryBrowseScreen;
2896 use crate::tui::screens::{GameDetailPrevious, GameDetailScreen, SearchScreen};
2897 use crate::types::Platform;
2898 use crate::update::UpdateStatus;
2899 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2900 use serde_json::json;
2901
2902 fn platform(id: u64, name: &str, rom_count: u64) -> Platform {
2903 serde_json::from_value(json!({
2904 "id": id,
2905 "slug": format!("p{id}"),
2906 "fs_slug": format!("p{id}"),
2907 "rom_count": rom_count,
2908 "name": name,
2909 "igdb_slug": null,
2910 "moby_slug": null,
2911 "hltb_slug": null,
2912 "custom_name": null,
2913 "igdb_id": null,
2914 "sgdb_id": null,
2915 "moby_id": null,
2916 "launchbox_id": null,
2917 "ss_id": null,
2918 "ra_id": null,
2919 "hasheous_id": null,
2920 "tgdb_id": null,
2921 "flashpoint_id": null,
2922 "category": null,
2923 "generation": null,
2924 "family_name": null,
2925 "family_slug": null,
2926 "url": null,
2927 "url_logo": null,
2928 "firmware": [],
2929 "aspect_ratio": null,
2930 "created_at": "",
2931 "updated_at": "",
2932 "fs_size_bytes": 0,
2933 "is_unidentified": false,
2934 "is_identified": true,
2935 "missing_from_fs": false,
2936 "display_name": null
2937 }))
2938 .expect("valid platform fixture")
2939 }
2940
2941 fn app_with_library(platforms: Vec<Platform>) -> App {
2942 let config = Config {
2943 base_url: "http://127.0.0.1:9".into(),
2944 download_dir: "/tmp".into(),
2945 use_https: false,
2946 auth: None,
2947 extras_defaults: ExtrasDefaults::default(),
2948 save_sync: Default::default(),
2949 roms_layout: Default::default(),
2950 };
2951 let client = RommClient::new(&config, false).expect("client");
2952 let mut app = App::new(
2953 client,
2954 config,
2955 EndpointRegistry::default(),
2956 None,
2957 None,
2958 None,
2959 );
2960 app.screen = AppScreen::LibraryBrowse(LibraryBrowseScreen::new(platforms, vec![]));
2961 app
2962 }
2963
2964 fn update_status_fixture() -> UpdateStatus {
2965 UpdateStatus {
2966 current_version: "0.25.0".into(),
2967 latest_version: "0.26.0".into(),
2968 release_tag: "v0.26.0".into(),
2969 should_update: true,
2970 release_url: "https://github.com/patricksmill/romm-cli/releases/tag/v0.26.0".into(),
2971 changelog_url: "https://github.com/patricksmill/romm-cli/blob/main/CHANGELOG.md".into(),
2972 }
2973 }
2974
2975 fn rom_fixture() -> crate::types::Rom {
2976 serde_json::from_value(json!({
2977 "id": 10,
2978 "platform_id": 1,
2979 "platform_slug": null,
2980 "platform_fs_slug": null,
2981 "platform_custom_name": null,
2982 "platform_display_name": null,
2983 "fs_name": "sample.zip",
2984 "fs_name_no_tags": "sample",
2985 "fs_name_no_ext": "sample",
2986 "fs_extension": "zip",
2987 "fs_path": "/sample.zip",
2988 "fs_size_bytes": 100,
2989 "name": "Sample",
2990 "slug": null,
2991 "summary": null,
2992 "path_cover_small": null,
2993 "path_cover_large": null,
2994 "url_cover": null,
2995 "has_manual": false,
2996 "path_manual": null,
2997 "url_manual": null,
2998 "is_unidentified": false,
2999 "is_identified": true
3000 }))
3001 .expect("valid rom fixture")
3002 }
3003
3004 fn empty_rom_list_with_total(total: u64) -> RomList {
3005 RomList {
3006 items: vec![],
3007 total,
3008 limit: 50,
3009 offset: 0,
3010 }
3011 }
3012
3013 #[tokio::test]
3014 async fn list_move_to_zero_rom_selection_does_not_queue_deferred_load() {
3015 let mut app = app_with_library(vec![platform(1, "HasRoms", 5), platform(2, "Empty", 0)]);
3016
3017 assert!(!app
3018 .handle_key_event(&KeyEvent::new(KeyCode::Down, KeyModifiers::empty()))
3019 .await
3020 .expect("key handled"));
3021 assert!(
3022 app.deferred_load_roms.is_none(),
3023 "selection move to zero-rom platform should not queue deferred ROM load"
3024 );
3025 }
3026
3027 #[test]
3028 fn ctrl_c_is_treated_as_force_quit() {
3029 let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
3030 assert!(App::is_force_quit_key(&ctrl_c));
3031
3032 let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty());
3033 assert!(!App::is_force_quit_key(&plain_c));
3034 }
3035
3036 #[test]
3037 fn primary_rom_load_stale_gen_is_ignored() {
3038 assert!(!super::primary_rom_load_result_is_current(1, 2));
3039 assert!(super::primary_rom_load_result_is_current(3, 3));
3040 }
3041
3042 #[tokio::test]
3043 async fn game_detail_esc_returns_to_previous_library_screen() {
3044 let mut app = app_with_library(vec![platform(1, "NES", 1)]);
3045 let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
3046 let detail = GameDetailScreen::new(
3047 rom_fixture(),
3048 Vec::new(),
3049 GameDetailPrevious::Library(Box::new(previous)),
3050 app.downloads.shared(),
3051 );
3052 app.screen = AppScreen::GameDetail(Box::new(detail));
3053
3054 let quit = app
3055 .handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
3056 .await
3057 .expect("esc handled");
3058 assert!(!quit);
3059 assert!(matches!(app.screen, AppScreen::LibraryBrowse(_)));
3060 }
3061
3062 #[tokio::test]
3063 async fn startup_splash_enter_dismisses_without_quitting_when_update_pending() {
3064 let config = Config {
3065 base_url: "http://127.0.0.1:9".into(),
3066 download_dir: "/tmp".into(),
3067 use_https: false,
3068 auth: None,
3069 extras_defaults: ExtrasDefaults::default(),
3070 save_sync: Default::default(),
3071 roms_layout: Default::default(),
3072 };
3073 let client = RommClient::new(&config, false).expect("client");
3074 let splash = Some(StartupSplash::new(
3075 config.base_url.clone(),
3076 Some("4.0.0".into()),
3077 ));
3078 let mut app = App::new(
3079 client,
3080 config,
3081 EndpointRegistry::default(),
3082 Some("4.0.0".into()),
3083 splash,
3084 Some(update_status_fixture()),
3085 );
3086 assert!(app.startup_splash.is_some());
3087 assert!(app.startup_update_prompt.is_some());
3088
3089 let quit = app
3090 .handle_key_event(&KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
3091 .await
3092 .expect("enter handled");
3093 assert!(!quit, "Enter on connected splash should not quit the app");
3094 assert!(app.startup_splash.is_none(), "splash should be dismissed");
3095 assert!(
3096 app.startup_update_prompt.is_some(),
3097 "update prompt should remain after splash dismiss"
3098 );
3099 }
3100
3101 #[tokio::test]
3102 async fn startup_update_prompt_enter_starts_update_without_quitting() {
3103 let config = Config {
3104 base_url: "http://127.0.0.1:9".into(),
3105 download_dir: "/tmp".into(),
3106 use_https: false,
3107 auth: None,
3108 extras_defaults: ExtrasDefaults::default(),
3109 save_sync: Default::default(),
3110 roms_layout: Default::default(),
3111 };
3112 let client = RommClient::new(&config, false).expect("client");
3113 let mut app = App::new(
3114 client,
3115 config,
3116 EndpointRegistry::default(),
3117 None,
3118 None,
3119 Some(update_status_fixture()),
3120 );
3121 let quit = app
3122 .handle_key_event(&KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
3123 .await
3124 .expect("enter handled");
3125 assert!(!quit, "Enter to confirm update should not quit the app");
3126 assert!(
3127 app.startup_update_prompt
3128 .as_ref()
3129 .is_some_and(|p| p.updating),
3130 "update should be in progress"
3131 );
3132 }
3133
3134 #[tokio::test]
3135 async fn startup_update_prompt_esc_skips_without_quitting() {
3136 let config = Config {
3137 base_url: "http://127.0.0.1:9".into(),
3138 download_dir: "/tmp".into(),
3139 use_https: false,
3140 auth: None,
3141 extras_defaults: ExtrasDefaults::default(),
3142 save_sync: Default::default(),
3143 roms_layout: Default::default(),
3144 };
3145 let client = RommClient::new(&config, false).expect("client");
3146 let mut app = App::new(
3147 client,
3148 config,
3149 EndpointRegistry::default(),
3150 None,
3151 None,
3152 Some(update_status_fixture()),
3153 );
3154 let quit = app
3155 .handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
3156 .await
3157 .expect("esc handled");
3158 assert!(!quit);
3159 assert!(app.startup_update_prompt.is_none());
3160 }
3161
3162 #[tokio::test]
3163 async fn startup_update_prompt_blocks_global_d_shortcut() {
3164 let config = Config {
3165 base_url: "http://127.0.0.1:9".into(),
3166 download_dir: "/tmp".into(),
3167 use_https: false,
3168 auth: None,
3169 extras_defaults: ExtrasDefaults::default(),
3170 save_sync: Default::default(),
3171 roms_layout: Default::default(),
3172 };
3173 let client = RommClient::new(&config, false).expect("client");
3174 let app = App::new(
3175 client,
3176 config,
3177 EndpointRegistry::default(),
3178 None,
3179 None,
3180 Some(update_status_fixture()),
3181 );
3182 assert!(app.blocks_global_d_shortcut());
3183 assert!(app.blocks_global_chord_shortcuts());
3184 }
3185
3186 #[tokio::test]
3187 async fn startup_update_prompt_skip_closes_prompt() {
3188 let config = Config {
3189 base_url: "http://127.0.0.1:9".into(),
3190 download_dir: "/tmp".into(),
3191 use_https: false,
3192 auth: None,
3193 extras_defaults: ExtrasDefaults::default(),
3194 save_sync: Default::default(),
3195 roms_layout: Default::default(),
3196 };
3197 let client = RommClient::new(&config, false).expect("client");
3198 let mut app = App::new(
3199 client,
3200 config,
3201 EndpointRegistry::default(),
3202 None,
3203 None,
3204 Some(update_status_fixture()),
3205 );
3206 assert!(app.startup_update_prompt.is_some());
3207 let quit = app
3208 .handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
3209 .await
3210 .expect("esc handled");
3211 assert!(!quit);
3212 assert!(app.startup_update_prompt.is_none());
3213 }
3214
3215 #[test]
3216 fn search_batch_updates_results_without_stopping_loading() {
3217 let config = Config {
3218 base_url: "http://127.0.0.1:9".into(),
3219 download_dir: "/tmp".into(),
3220 use_https: false,
3221 auth: None,
3222 extras_defaults: ExtrasDefaults::default(),
3223 save_sync: Default::default(),
3224 roms_layout: Default::default(),
3225 };
3226 let client = RommClient::new(&config, false).expect("client");
3227 let mut app = App::new(
3228 client,
3229 config,
3230 EndpointRegistry::default(),
3231 None,
3232 None,
3233 None,
3234 );
3235 let mut search = SearchScreen::new();
3236 search.loading = true;
3237 app.screen = AppScreen::Search(search);
3238
3239 app.search_load_tx
3240 .send(SearchLoadDone {
3241 query: "zelda".to_string(),
3242 event: SearchLoadEvent::Batch(empty_rom_list_with_total(120)),
3243 })
3244 .expect("send batch");
3245
3246 app.poll_search_load_results();
3247
3248 match &app.screen {
3249 AppScreen::Search(search) => {
3250 assert!(search.loading, "loading should continue after batch");
3251 assert!(search.results.is_some(), "batch should populate results");
3252 assert_eq!(search.last_searched_query.as_deref(), Some("zelda"));
3253 }
3254 _ => panic!("expected search screen"),
3255 }
3256 }
3257
3258 #[test]
3259 fn search_complete_event_stops_loading() {
3260 let config = Config {
3261 base_url: "http://127.0.0.1:9".into(),
3262 download_dir: "/tmp".into(),
3263 use_https: false,
3264 auth: None,
3265 extras_defaults: ExtrasDefaults::default(),
3266 save_sync: Default::default(),
3267 roms_layout: Default::default(),
3268 };
3269 let client = RommClient::new(&config, false).expect("client");
3270 let mut app = App::new(
3271 client,
3272 config,
3273 EndpointRegistry::default(),
3274 None,
3275 None,
3276 None,
3277 );
3278 let mut search = SearchScreen::new();
3279 search.loading = true;
3280 app.screen = AppScreen::Search(search);
3281
3282 app.search_load_tx
3283 .send(SearchLoadDone {
3284 query: "zelda".to_string(),
3285 event: SearchLoadEvent::Complete,
3286 })
3287 .expect("send complete");
3288
3289 app.poll_search_load_results();
3290
3291 match &app.screen {
3292 AppScreen::Search(search) => {
3293 assert!(!search.loading, "loading should stop after completion");
3294 }
3295 _ => panic!("expected search screen"),
3296 }
3297 }
3298
3299 #[tokio::test]
3300 async fn pressing_e_with_no_extras_shows_toast_not_picker() {
3301 let mut app = app_with_library(vec![platform(1, "NES", 1)]);
3302 let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
3303 let detail = GameDetailScreen::new(
3304 rom_fixture(),
3305 Vec::new(),
3306 GameDetailPrevious::Library(Box::new(previous)),
3307 app.downloads.shared(),
3308 );
3309 app.screen = AppScreen::GameDetail(Box::new(detail));
3310
3311 app.handle_key_event(&KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()))
3312 .await
3313 .expect("handled");
3314
3315 match &app.screen {
3316 AppScreen::GameDetail(d) => {
3317 assert!(
3318 d.message
3319 .as_deref()
3320 .is_some_and(|m| m.contains("No extras")),
3321 "expected toast, got {:?}",
3322 d.message
3323 );
3324 }
3325 _ => panic!("expected game detail"),
3326 }
3327 }
3328
3329 #[tokio::test]
3330 async fn pressing_e_with_extras_opens_picker() {
3331 let mut rom = rom_fixture();
3332 rom.url_cover = Some("https://example.com/c.png".into());
3333 let mut app = app_with_library(vec![platform(1, "NES", 1)]);
3334 let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
3335 let detail = GameDetailScreen::new(
3336 rom,
3337 Vec::new(),
3338 GameDetailPrevious::Library(Box::new(previous)),
3339 app.downloads.shared(),
3340 );
3341 app.screen = AppScreen::GameDetail(Box::new(detail));
3342
3343 app.handle_key_event(&KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()))
3344 .await
3345 .expect("handled");
3346
3347 assert!(
3348 matches!(app.screen, AppScreen::ExtrasPicker(_)),
3349 "expected extras picker"
3350 );
3351 }
3352}