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