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::{auth_for_persist_merge, normalize_romm_origin, Config};
32use crate::core::cache::{RomCache, RomCacheKey};
33use crate::core::download::DownloadManager;
34use crate::core::startup_library_snapshot;
35use crate::endpoints::roms::GetRoms;
36use crate::types::{Collection, Platform, RomList};
37use crate::update::UpdateStatus;
38
39use super::keyboard_help;
40use super::openapi::{resolve_path_template, EndpointRegistry};
41use super::screens::connected_splash::{self, StartupSplash};
42use super::screens::setup_wizard::SetupWizard;
43use super::screens::{
44 BrowseScreen, DownloadScreen, ExecuteScreen, GameDetailPrevious, GameDetailScreen,
45 LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen, SearchScreen,
46 SettingsScreen,
47};
48
49struct LibraryMetadataRefreshDone {
51 gen: u64,
52 platforms: Vec<Platform>,
53 collections: Vec<Collection>,
54 collection_digest: Vec<startup_library_snapshot::CollectionDigestEntry>,
55 warnings: Vec<String>,
56}
57
58struct CollectionPrefetchDone {
59 key: RomCacheKey,
60 expected: u64,
61 roms: Option<RomList>,
62 warning: Option<String>,
63}
64
65enum RomLoadEvent {
66 Batch(RomList),
67 Failed(String),
68 Complete,
69}
70
71struct RomLoadDone {
73 gen: u64,
74 key: Option<RomCacheKey>,
75 expected: u64,
76 event: RomLoadEvent,
77 context: &'static str,
78 started: Instant,
79}
80
81enum SearchLoadEvent {
82 Batch(RomList),
83 Failed(String),
84 Complete,
85}
86
87struct SearchLoadDone {
88 query: String,
89 event: SearchLoadEvent,
90}
91
92struct CoverLoadDone {
93 rom_id: u64,
94 result: Result<image::DynamicImage, String>,
95}
96
97struct StartupUpdatePrompt {
98 status: UpdateStatus,
99}
100
101type DeferredLoadRoms = (
103 Option<RomCacheKey>,
104 Option<GetRoms>,
105 u64,
106 &'static str,
107 Instant,
108);
109
110#[inline]
111fn primary_rom_load_result_is_current(done_gen: u64, current_gen: u64) -> bool {
112 done_gen == current_gen
113}
114
115pub enum AppScreen {
124 MainMenu(MainMenuScreen),
125 LibraryBrowse(LibraryBrowseScreen),
126 Search(SearchScreen),
127 Settings(SettingsScreen),
128 Browse(BrowseScreen),
129 Execute(ExecuteScreen),
130 Result(ResultScreen),
131 ResultDetail(ResultDetailScreen),
132 GameDetail(Box<GameDetailScreen>),
133 Download(DownloadScreen),
134 SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
135}
136
137struct LibraryUploadComplete {
139 platform_id: u64,
140 scan_after: bool,
141}
142
143pub struct App {
152 pub screen: AppScreen,
153 client: RommClient,
154 config: Config,
155 registry: EndpointRegistry,
156 server_version: Option<String>,
158 rom_cache: RomCache,
159 downloads: DownloadManager,
160 screen_before_download: Option<AppScreen>,
162 deferred_load_roms: Option<DeferredLoadRoms>,
164 startup_splash: Option<StartupSplash>,
166 pub global_error: Option<String>,
167 show_keyboard_help: bool,
168 startup_update_prompt: Option<StartupUpdatePrompt>,
169 library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
171 library_metadata_refresh_gen: u64,
173 collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
174 collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
175 collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
176 collection_prefetch_queued_keys: HashSet<RomCacheKey>,
177 collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
178 rom_load_gen: u64,
180 rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
181 rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
182 rom_load_task: Option<tokio::task::JoinHandle<()>>,
183 search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
184 search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
185 search_load_task: Option<tokio::task::JoinHandle<()>>,
186 cover_load_rx: tokio::sync::mpsc::UnboundedReceiver<CoverLoadDone>,
187 cover_load_tx: tokio::sync::mpsc::UnboundedSender<CoverLoadDone>,
188 cover_load_task: Option<tokio::task::JoinHandle<()>>,
189 library_scan_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Result<(), String>>>,
191 library_scan_inflight: bool,
192 library_scan_pending_invalidate: Option<ScanCacheInvalidate>,
194 force_rom_reload_after_metadata: bool,
196 library_upload_inflight: bool,
198 library_upload_progress_rx: Option<tokio::sync::mpsc::UnboundedReceiver<(u64, u64)>>,
199 library_upload_done_rx:
200 Option<tokio::sync::mpsc::UnboundedReceiver<Result<LibraryUploadComplete, String>>>,
201}
202
203impl App {
204 fn blocks_global_d_shortcut(&self) -> bool {
205 let base = match &self.screen {
206 AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
207 AppScreen::LibraryBrowse(lib) => {
208 lib.any_search_bar_open() || lib.any_upload_prompt_open()
209 }
210 _ => false,
211 };
212 base || self.library_upload_inflight
213 }
214
215 fn allows_global_question_help(&self) -> bool {
216 match &self.screen {
217 AppScreen::Search(_) | AppScreen::SetupWizard(_) | AppScreen::Execute(_) => false,
218 AppScreen::LibraryBrowse(lib)
219 if lib.any_search_bar_open() || lib.any_upload_prompt_open() =>
220 {
221 false
222 }
223 AppScreen::Settings(s) if s.editing || s.path_picker.is_some() => false,
224 _ => true,
225 }
226 }
227
228 fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
229 key.kind == KeyEventKind::Press
230 && key.modifiers.contains(KeyModifiers::CONTROL)
231 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
232 }
233
234 fn selected_rom_request_for_library(
235 lib: &super::screens::library_browse::LibraryBrowseScreen,
236 ) -> Option<GetRoms> {
237 match lib.subsection {
238 super::screens::library_browse::LibrarySubsection::ByConsole => {
239 lib.get_roms_request_platform()
240 }
241 super::screens::library_browse::LibrarySubsection::ByCollection => {
242 lib.get_roms_request_collection()
243 }
244 }
245 }
246
247 pub fn new(
249 client: RommClient,
250 config: Config,
251 registry: EndpointRegistry,
252 server_version: Option<String>,
253 startup_splash: Option<StartupSplash>,
254 startup_update: Option<UpdateStatus>,
255 ) -> Self {
256 let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
257 let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
258 let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
259 let (cover_load_tx, cover_load_rx) = tokio::sync::mpsc::unbounded_channel();
260 Self {
261 screen: AppScreen::MainMenu(MainMenuScreen::new()),
262 client,
263 config,
264 registry,
265 server_version,
266 rom_cache: RomCache::load(),
267 downloads: DownloadManager::new(),
268 screen_before_download: None,
269 deferred_load_roms: None,
270 startup_splash,
271 global_error: None,
272 show_keyboard_help: false,
273 startup_update_prompt: startup_update.map(|status| StartupUpdatePrompt { status }),
274 library_metadata_rx: None,
275 library_metadata_refresh_gen: 0,
276 collection_prefetch_rx: prefetch_rx,
277 collection_prefetch_tx: prefetch_tx,
278 collection_prefetch_queue: VecDeque::new(),
279 collection_prefetch_queued_keys: HashSet::new(),
280 collection_prefetch_inflight_keys: HashSet::new(),
281 rom_load_gen: 0,
282 rom_load_rx,
283 rom_load_tx,
284 rom_load_task: None,
285 search_load_rx,
286 search_load_tx,
287 search_load_task: None,
288 cover_load_rx,
289 cover_load_tx,
290 cover_load_task: None,
291 library_scan_rx: None,
292 library_scan_inflight: false,
293 library_scan_pending_invalidate: None,
294 force_rom_reload_after_metadata: false,
295 library_upload_inflight: false,
296 library_upload_progress_rx: None,
297 library_upload_done_rx: None,
298 }
299 }
300
301 fn spawn_library_metadata_refresh(&mut self) {
302 self.library_metadata_refresh_gen = self.library_metadata_refresh_gen.saturating_add(1);
303 let gen = self.library_metadata_refresh_gen;
304 let client = self.client.clone();
305 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
306 self.library_metadata_rx = Some(rx);
307 tokio::spawn(async move {
308 let fetch = startup_library_snapshot::fetch_merged_library_metadata(&client).await;
309 let _ = tx.send(LibraryMetadataRefreshDone {
310 gen,
311 platforms: fetch.platforms,
312 collections: fetch.collections,
313 collection_digest: fetch.collection_digest,
314 warnings: fetch.warnings,
315 });
316 });
317 }
318
319 pub fn poll_background_tasks(&mut self) {
321 self.poll_library_metadata_refresh();
322 self.poll_rom_load_results();
323 self.poll_collection_prefetch_results();
324 self.poll_search_load_results();
325 self.poll_cover_load_results();
326 self.poll_library_upload();
327 self.poll_library_scan();
328 self.drive_collection_prefetch_scheduler();
329 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
330 lib.poll_footer_clear();
331 }
332 }
333
334 fn spawn_library_rescan_worker(&mut self, cache_on_success: ScanCacheInvalidate) {
335 if self.library_scan_inflight {
336 return;
337 }
338 self.library_scan_inflight = true;
339 self.library_scan_pending_invalidate = Some(cache_on_success);
340 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
341 lib.set_metadata_footer(Some("Server library scan running…".into()));
342 }
343 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
344 self.library_scan_rx = Some(rx);
345 let client = self.client.clone();
346 tokio::spawn(async move {
347 let result = async {
348 let start =
349 crate::commands::library_scan::start_scan_library(&client, None).await?;
350 crate::commands::library_scan::wait_for_task_terminal(
351 &client,
352 &start.task_id,
353 Duration::from_secs(3600),
354 None,
355 |_| {},
356 )
357 .await?;
358 Ok::<(), anyhow::Error>(())
359 }
360 .await
361 .map_err(|e| e.to_string());
362 let _ = tx.send(result);
363 });
364 }
365
366 fn poll_library_scan(&mut self) {
367 let Some(rx) = &mut self.library_scan_rx else {
368 return;
369 };
370 match rx.try_recv() {
371 Ok(result) => {
372 self.library_scan_rx = None;
373 self.library_scan_inflight = false;
374 match result {
375 Ok(()) => self.on_library_scan_completed_success(),
376 Err(e) => {
377 self.library_scan_pending_invalidate = None;
378 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
379 lib.set_metadata_footer(Some(format!("Library scan failed: {e}")));
380 } else {
381 self.global_error = Some(format!("Library scan failed: {e}"));
382 }
383 }
384 }
385 }
386 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
387 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
388 self.library_scan_rx = None;
389 self.library_scan_inflight = false;
390 self.library_scan_pending_invalidate = None;
391 }
392 }
393 }
394
395 fn apply_library_scan_cache_invalidate(&mut self, inv: &ScanCacheInvalidate) {
396 match inv {
397 ScanCacheInvalidate::None => {}
398 ScanCacheInvalidate::Platform(pid) => {
399 self.rom_cache.remove(&RomCacheKey::Platform(*pid));
400 }
401 ScanCacheInvalidate::AllPlatforms => {
402 self.rom_cache.remove_all_platform_entries();
403 if let AppScreen::LibraryBrowse(lib) = &self.screen {
404 if let Some(ref k) = lib.cache_key() {
405 if !matches!(k, RomCacheKey::Platform(_)) {
406 self.rom_cache.remove(k);
407 }
408 }
409 }
410 }
411 }
412 }
413
414 fn on_library_scan_completed_success(&mut self) {
415 let inv = self
416 .library_scan_pending_invalidate
417 .take()
418 .unwrap_or(ScanCacheInvalidate::AllPlatforms);
419 self.apply_library_scan_cache_invalidate(&inv);
420 if matches!(self.screen, AppScreen::LibraryBrowse(_)) {
421 self.force_rom_reload_after_metadata = true;
422 self.spawn_library_metadata_refresh();
423 }
424 }
425
426 fn format_upload_bytes(n: u64) -> String {
427 const KB: u64 = 1024;
428 const MB: u64 = KB * 1024;
429 const GB: u64 = MB * 1024;
430 if n >= GB {
431 format!("{:.2} GiB", n as f64 / GB as f64)
432 } else if n >= MB {
433 format!("{:.2} MiB", n as f64 / MB as f64)
434 } else if n >= KB {
435 format!("{:.1} KiB", n as f64 / KB as f64)
436 } else {
437 format!("{n} B")
438 }
439 }
440
441 fn spawn_library_upload_worker(&mut self, platform_id: u64, path: PathBuf, scan_after: bool) {
442 if self.library_upload_inflight || self.library_scan_inflight {
443 return;
444 }
445 self.library_upload_inflight = true;
446 self.library_upload_progress_rx = None;
447 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
448 lib.set_metadata_footer(Some("Preparing upload…".into()));
449 }
450 let (prog_tx, prog_rx) = tokio::sync::mpsc::unbounded_channel();
451 let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
452 self.library_upload_progress_rx = Some(prog_rx);
453 self.library_upload_done_rx = Some(done_rx);
454 let client = self.client.clone();
455 tokio::spawn(async move {
456 let result: Result<LibraryUploadComplete, String> = async {
457 client
458 .upload_rom(platform_id, &path, move |uploaded, total| {
459 let _ = prog_tx.send((uploaded, total));
460 })
461 .await
462 .map_err(|e| e.to_string())?;
463 Ok(LibraryUploadComplete {
464 platform_id,
465 scan_after,
466 })
467 }
468 .await;
469 let _ = done_tx.send(result);
470 });
471 }
472
473 fn poll_library_upload(&mut self) {
474 if let Some(rx) = &mut self.library_upload_progress_rx {
475 while let Ok((up, tot)) = rx.try_recv() {
476 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
477 lib.set_metadata_footer(Some(format!(
478 "Uploading {} / {}…",
479 Self::format_upload_bytes(up),
480 Self::format_upload_bytes(tot)
481 )));
482 }
483 }
484 }
485
486 let Some(rx) = &mut self.library_upload_done_rx else {
487 return;
488 };
489 match rx.try_recv() {
490 Ok(result) => {
491 self.library_upload_done_rx = None;
492 self.library_upload_progress_rx = None;
493 self.library_upload_inflight = false;
494 match result {
495 Ok(done) => {
496 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
497 if done.scan_after {
498 lib.set_metadata_footer(Some(
499 "Upload complete. Starting library scan…".into(),
500 ));
501 self.spawn_library_rescan_worker(ScanCacheInvalidate::Platform(
502 done.platform_id,
503 ));
504 } else {
505 lib.set_metadata_footer(Some("Upload complete.".into()));
506 }
507 }
508 }
509 Err(e) => {
510 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
511 lib.set_metadata_footer(Some(format!("Upload failed: {e}")));
512 } else {
513 self.global_error = Some(format!("Upload failed: {e}"));
514 }
515 }
516 }
517 }
518 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
519 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
520 self.library_upload_done_rx = None;
521 self.library_upload_progress_rx = None;
522 self.library_upload_inflight = false;
523 }
524 }
525 }
526
527 fn poll_search_load_results(&mut self) {
528 loop {
529 match self.search_load_rx.try_recv() {
530 Ok(done) => {
531 if let AppScreen::Search(ref mut search) = self.screen {
532 match done.event {
533 SearchLoadEvent::Batch(roms) => {
534 search.set_results_for_query(done.query, roms);
535 }
536 SearchLoadEvent::Failed(err) => {
537 search.loading = false;
538 self.global_error = Some(err);
539 }
540 SearchLoadEvent::Complete => {
541 search.loading = false;
542 }
543 }
544 }
545 }
546 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
547 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
548 }
549 }
550 }
551
552 fn spawn_cover_load_worker(&mut self, rom_id: u64, url: String) {
553 if let Some(task) = self.cover_load_task.take() {
554 task.abort();
555 }
556 let tx = self.cover_load_tx.clone();
557 self.cover_load_task = Some(tokio::spawn(async move {
558 let result = async {
559 let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
560 let status = response.status();
561 if !status.is_success() {
562 return Err(format!("HTTP {}", status.as_u16()));
563 }
564 let bytes = response.bytes().await.map_err(|e| e.to_string())?;
565 image::load_from_memory(&bytes).map_err(|e| e.to_string())
566 }
567 .await;
568 let _ = tx.send(CoverLoadDone { rom_id, result });
569 }));
570 }
571
572 fn poll_cover_load_results(&mut self) {
573 loop {
574 match self.cover_load_rx.try_recv() {
575 Ok(done) => {
576 if let AppScreen::GameDetail(detail) = &mut self.screen {
577 if detail.rom.id != done.rom_id {
578 continue;
579 }
580 match done.result {
581 Ok(image) => detail.apply_cover_image(image),
582 Err(err) => detail.apply_cover_error(format!(
583 "Cover failed: {}",
584 crate::tui::utils::truncate(&err, 120)
585 )),
586 }
587 }
588 }
589 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
590 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
591 }
592 }
593 }
594
595 fn maybe_start_game_detail_cover_load(&mut self) {
596 let (rom_id, url) = match &mut self.screen {
597 AppScreen::GameDetail(detail) => {
598 if !detail.should_request_cover_load() {
599 return;
600 }
601 detail.set_cover_loading();
602 let Some(url) = detail.cover_last_url.clone() else {
603 return;
604 };
605 (detail.rom.id, url)
606 }
607 _ => return,
608 };
609 self.spawn_cover_load_worker(rom_id, url);
610 }
611
612 fn poll_rom_load_results(&mut self) {
613 loop {
614 match self.rom_load_rx.try_recv() {
615 Ok(done) => {
616 if !primary_rom_load_result_is_current(done.gen, self.rom_load_gen) {
617 continue;
618 }
619 let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
620 continue;
621 };
622 match done.event {
623 RomLoadEvent::Batch(roms) => {
624 if let Some(ref k) = done.key {
625 self.rom_cache
626 .insert(k.clone(), roms.clone(), done.expected);
627 }
628 lib.set_roms(roms);
629 tracing::debug!(
630 "rom-list-render batch context={} latency_ms={}",
631 done.context,
632 done.started.elapsed().as_millis()
633 );
634 }
635 RomLoadEvent::Failed(e) => {
636 lib.set_metadata_footer(Some(format!("Could not load games: {e}")));
637 lib.set_rom_loading(false);
638 }
639 RomLoadEvent::Complete => {
640 lib.set_rom_loading(false);
641 }
642 }
643 }
644 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
645 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
646 }
647 }
648 }
649
650 fn poll_library_metadata_refresh(&mut self) {
651 let mut batch = Vec::new();
652 let mut disconnected = false;
653 if let Some(rx) = &mut self.library_metadata_rx {
654 loop {
655 match rx.try_recv() {
656 Ok(msg) => batch.push(msg),
657 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
658 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
659 disconnected = true;
660 break;
661 }
662 }
663 }
664 }
665 if disconnected {
666 self.library_metadata_rx = None;
667 }
668 for msg in batch {
669 self.apply_library_metadata_refresh(msg);
670 }
671 }
672
673 fn apply_library_metadata_refresh(&mut self, msg: LibraryMetadataRefreshDone) {
674 if msg.gen != self.library_metadata_refresh_gen {
675 return;
676 }
677 let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
678 return;
679 };
680
681 let had_cached_lists = !lib.platforms.is_empty() || !lib.collections.is_empty();
682 let live_empty = msg.collections.is_empty();
683 if live_empty && had_cached_lists && !msg.warnings.is_empty() {
684 lib.set_temporary_metadata_footer(
685 "Could not refresh library metadata (keeping cached list).".into(),
686 std::time::Duration::from_secs(3),
687 );
688 self.force_rom_reload_after_metadata = false;
689 return;
690 }
691
692 let old_digest =
693 startup_library_snapshot::build_collection_digest_from_collections(&lib.collections);
694 let digest_changed = old_digest != msg.collection_digest;
695 let update_platforms = !msg.platforms.is_empty();
696 let selection_changed = lib.replace_metadata_preserving_selection(
697 msg.platforms,
698 msg.collections,
699 update_platforms,
700 true,
701 );
702 startup_library_snapshot::save_snapshot(&lib.platforms, &lib.collections);
703
704 let footer = if msg.warnings.is_empty() {
705 if digest_changed {
706 Some("Collection metadata updated.".into())
707 } else {
708 None
709 }
710 } else {
711 let w = msg.warnings.join(" | ");
712 let short: String = if w.chars().count() > 160 {
713 let prefix: String = w.chars().take(157).collect();
714 format!("{prefix}…")
715 } else {
716 w
717 };
718 Some(format!("Partial refresh: {}", short))
719 };
720 lib.set_metadata_footer(footer);
721
722 if selection_changed && lib.list_len() > 0 {
723 lib.clear_roms();
724 let key = lib.cache_key();
725 let expected = lib.expected_rom_count();
726 let req = Self::selected_rom_request_for_library(lib);
727 lib.set_rom_loading(expected > 0);
728 self.deferred_load_roms =
729 Some((key, req, expected, "refresh_selection", Instant::now()));
730 }
731
732 let force_reload = std::mem::take(&mut self.force_rom_reload_after_metadata);
733 if force_reload && lib.list_len() > 0 && !selection_changed {
734 lib.clear_roms();
735 let key = lib.cache_key();
736 let expected = lib.expected_rom_count();
737 let req = Self::selected_rom_request_for_library(lib);
738 lib.set_rom_loading(expected > 0);
739 self.deferred_load_roms =
740 Some((key, req, expected, "post_scan_reload", Instant::now()));
741 }
742
743 self.queue_collection_prefetches_from_screen(1, "refresh_warmup");
744 }
745
746 fn queue_collection_prefetches_from_screen(&mut self, radius: usize, _reason: &'static str) {
747 let AppScreen::LibraryBrowse(ref lib) = self.screen else {
748 return;
749 };
750 for (key, req, expected) in lib.collection_prefetch_candidates(radius) {
751 if self.rom_cache.get_valid(&key, expected).is_some() {
752 continue;
753 }
754 if self.collection_prefetch_queued_keys.contains(&key)
755 || self.collection_prefetch_inflight_keys.contains(&key)
756 {
757 continue;
758 }
759 self.collection_prefetch_queued_keys.insert(key.clone());
760 self.collection_prefetch_queue
761 .push_back((key, req, expected));
762 }
763 }
764
765 fn drive_collection_prefetch_scheduler(&mut self) {
766 const PREFETCH_MAX_INFLIGHT: usize = 2;
767 while self.collection_prefetch_inflight_keys.len() < PREFETCH_MAX_INFLIGHT {
768 let Some((key, req, expected)) = self.collection_prefetch_queue.pop_back() else {
769 break;
770 };
771 self.collection_prefetch_queued_keys.remove(&key);
772 self.collection_prefetch_inflight_keys.insert(key.clone());
773 let tx = self.collection_prefetch_tx.clone();
774 let client = self.client.clone();
775 tokio::spawn(async move {
776 let result = Self::fetch_roms_full(client, req).await;
777 let (roms, warning) = match result {
778 Ok(list) => (Some(list), None),
779 Err(e) => (None, Some(format!("Collection prefetch failed: {e:#}"))),
780 };
781 let _ = tx.send(CollectionPrefetchDone {
782 key,
783 expected,
784 roms,
785 warning,
786 });
787 });
788 }
789 }
790
791 fn poll_collection_prefetch_results(&mut self) {
792 loop {
793 match self.collection_prefetch_rx.try_recv() {
794 Ok(done) => {
795 self.collection_prefetch_inflight_keys.remove(&done.key);
796 if let Some(roms) = done.roms {
797 self.rom_cache.insert(done.key, roms, done.expected);
798 } else if let Some(warning) = done.warning {
799 tracing::debug!("{warning}");
800 }
801 }
802 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
803 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
804 }
805 }
806 }
807
808 pub fn set_error(&mut self, err: anyhow::Error) {
809 self.global_error = Some(format!("{:#}", err));
810 }
811
812 pub async fn run(&mut self) -> Result<()> {
822 enable_raw_mode()?;
823 let mut stdout = std::io::stdout();
824 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
825 let backend = CrosstermBackend::new(stdout);
826 let mut terminal = Terminal::new(backend)?;
827
828 loop {
829 self.poll_background_tasks();
830 if self
831 .startup_splash
832 .as_ref()
833 .is_some_and(|s| s.should_auto_dismiss())
834 {
835 self.startup_splash = None;
836 }
837 terminal.draw(|f| self.render(f))?;
840
841 if event::poll(Duration::from_millis(100))? {
844 if let Event::Key(key_event) = event::read()? {
845 if Self::is_force_quit_key(&key_event) {
846 break;
847 }
848 if key_event.kind == KeyEventKind::Press
849 && key_event.modifiers.contains(KeyModifiers::CONTROL)
850 && matches!(key_event.code, KeyCode::Char('r') | KeyCode::Char('R'))
851 {
852 if let AppScreen::LibraryBrowse(ref lib) = self.screen {
853 if !lib.any_search_bar_open()
854 && !lib.any_upload_prompt_open()
855 && !self.library_upload_inflight
856 && !self.library_scan_inflight
857 {
858 self.spawn_library_rescan_worker(ScanCacheInvalidate::AllPlatforms);
859 }
860 }
861 continue;
862 }
863 if key_event.kind == KeyEventKind::Press
864 && key_event.modifiers.contains(KeyModifiers::CONTROL)
865 && matches!(key_event.code, KeyCode::Char('u') | KeyCode::Char('U'))
866 {
867 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
868 if lib.any_upload_prompt_open() {
869 lib.close_upload_prompt();
870 } else if !lib.any_search_bar_open()
871 && !self.library_upload_inflight
872 && !self.library_scan_inflight
873 {
874 if lib.subsection
875 == super::screens::library_browse::LibrarySubsection::ByConsole
876 {
877 lib.open_upload_prompt();
878 } else {
879 lib.set_metadata_footer(Some(
880 "Upload requires Consoles view — press t".into(),
881 ));
882 }
883 }
884 }
885 continue;
886 }
887 if key_event.kind == KeyEventKind::Press
888 && self.handle_key_event(&key_event).await?
889 {
890 break;
891 }
892 }
893 }
894
895 if let Some((key, req, expected, context, started)) = self.deferred_load_roms.take() {
899 if let Some(ref k) = key {
901 if let Some(cached) = self.rom_cache.get_valid(k, expected) {
902 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
903 lib.set_roms(cached.clone());
904 lib.set_rom_loading(false);
905 tracing::debug!(
906 "rom-list-render context={} latency_ms={} (cache_hit)",
907 context,
908 started.elapsed().as_millis()
909 );
910 }
911 continue;
912 }
913 }
914
915 if started.elapsed() < std::time::Duration::from_millis(250) {
917 self.deferred_load_roms = Some((key, req, expected, context, started));
919 continue;
920 }
921
922 self.rom_load_gen = self.rom_load_gen.saturating_add(1);
923 let gen = self.rom_load_gen;
924 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
925 lib.set_rom_loading(expected > 0);
926 }
927 if expected == 0 {
928 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
929 lib.set_rom_loading(false);
930 }
931 continue;
932 }
933
934 let Some(r) = req else {
935 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
936 lib.set_rom_loading(false);
937 }
938 continue;
939 };
940 let client = self.client.clone();
941 let tx = self.rom_load_tx.clone();
942
943 if let Some(task) = self.rom_load_task.take() {
944 task.abort();
945 }
946
947 self.rom_load_task = Some(tokio::spawn(async move {
948 let mut req = r;
949 let mut aggregated: Option<RomList> = None;
950
951 loop {
952 match client.call(&req).await {
953 Ok(mut batch) => {
954 if let Some(ref mut all) = aggregated {
955 if batch.items.is_empty() {
956 break;
957 }
958 all.items.append(&mut batch.items);
959 let _ = tx.send(RomLoadDone {
960 gen,
961 key: key.clone(),
962 expected,
963 event: RomLoadEvent::Batch(all.clone()),
964 context,
965 started,
966 });
967 if all.items.len() as u64 >= all.total {
968 break;
969 }
970 req.offset = Some(all.items.len() as u32);
971 } else {
972 let loaded = batch.items.len() as u64;
973 let total = batch.total;
974 let _ = tx.send(RomLoadDone {
975 gen,
976 key: key.clone(),
977 expected,
978 event: RomLoadEvent::Batch(batch.clone()),
979 context,
980 started,
981 });
982 req.offset = Some(loaded as u32);
983 aggregated = Some(batch);
984 if loaded >= total {
985 break;
986 }
987 }
988 }
989 Err(e) => {
990 let _ = tx.send(RomLoadDone {
991 gen,
992 key: key.clone(),
993 expected,
994 event: RomLoadEvent::Failed(format!("{e:#}")),
995 context,
996 started,
997 });
998 return;
999 }
1000 }
1001 if let Some(ref all) = aggregated {
1003 if all.items.len() >= 20000 {
1004 break;
1005 }
1006 }
1007 }
1008
1009 let _ = tx.send(RomLoadDone {
1010 gen,
1011 key,
1012 expected,
1013 event: RomLoadEvent::Complete,
1014 context,
1015 started,
1016 });
1017 }));
1018 }
1019 }
1020
1021 disable_raw_mode()?;
1022 execute!(
1023 terminal.backend_mut(),
1024 LeaveAlternateScreen,
1025 DisableMouseCapture
1026 )?;
1027 terminal.show_cursor()?;
1028 Ok(())
1029 }
1030
1031 async fn fetch_roms_full(client: RommClient, req: GetRoms) -> Result<RomList> {
1035 let mut roms = client.call(&req).await?;
1036 let total = roms.total;
1037 let ceiling = 20000;
1038 while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
1039 let mut next_req = req.clone();
1040 next_req.offset = Some(roms.items.len() as u32);
1041 let next_batch = client.call(&next_req).await?;
1042 if next_batch.items.is_empty() {
1043 break;
1044 }
1045 roms.items.extend(next_batch.items);
1046 }
1047 Ok(roms)
1048 }
1049
1050 pub async fn handle_key_event(&mut self, key: &KeyEvent) -> Result<bool> {
1055 if key.kind != KeyEventKind::Press {
1056 return Ok(false);
1057 }
1058
1059 if self.startup_update_prompt.is_some() {
1060 return self.handle_startup_update_prompt(key).await;
1061 }
1062
1063 if self.global_error.is_some() {
1064 if key.code == KeyCode::Esc || key.code == KeyCode::Enter {
1065 self.global_error = None;
1066 }
1067 return Ok(false);
1068 }
1069
1070 if self.startup_splash.is_some() {
1071 self.startup_splash = None;
1072 return Ok(false);
1073 }
1074
1075 if self.show_keyboard_help {
1076 if matches!(
1077 key.code,
1078 KeyCode::Esc | KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('?')
1079 ) {
1080 self.show_keyboard_help = false;
1081 }
1082 return Ok(false);
1083 }
1084
1085 if key.code == KeyCode::F(1) {
1086 self.show_keyboard_help = true;
1087 return Ok(false);
1088 }
1089 if key.code == KeyCode::Char('?') && self.allows_global_question_help() {
1090 self.show_keyboard_help = true;
1091 return Ok(false);
1092 }
1093
1094 if key.code == KeyCode::Char('d') && !self.blocks_global_d_shortcut() {
1096 self.toggle_download_screen();
1097 return Ok(false);
1098 }
1099
1100 match &self.screen {
1101 AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
1102 AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
1103 AppScreen::Search(_) => self.handle_search(key).await,
1104 AppScreen::Settings(_) => self.handle_settings(key).await,
1105 AppScreen::Browse(_) => self.handle_browse(key),
1106 AppScreen::Execute(_) => self.handle_execute(key).await,
1107 AppScreen::Result(_) => self.handle_result(key),
1108 AppScreen::ResultDetail(_) => self.handle_result_detail(key),
1109 AppScreen::GameDetail(_) => self.handle_game_detail(key),
1110 AppScreen::Download(_) => self.handle_download(key),
1111 AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
1112 }
1113 }
1114
1115 async fn handle_startup_update_prompt(&mut self, key: &KeyEvent) -> Result<bool> {
1116 let Some(prompt) = &self.startup_update_prompt else {
1117 return Ok(false);
1118 };
1119 match key.code {
1120 KeyCode::Char('u') | KeyCode::Char('U') | KeyCode::Enter => {
1121 match crate::update::apply_update(None).await {
1122 Ok(version) => {
1123 self.global_error = Some(format!(
1124 "Updated to {version}. Restart romm-cli to use the new version."
1125 ));
1126 }
1127 Err(err) => {
1128 self.global_error = Some(format!("Update failed: {err:#}"));
1129 }
1130 }
1131 self.startup_update_prompt = None;
1132 Ok(false)
1133 }
1134 KeyCode::Char('c') | KeyCode::Char('C') => {
1135 if let Err(err) = crate::update::open_changelog_in_browser() {
1136 self.global_error = Some(format!("Could not open changelog: {err:#}"));
1137 } else {
1138 self.global_error =
1139 Some(format!("Opened changelog: {}", prompt.status.changelog_url));
1140 }
1141 Ok(false)
1142 }
1143 KeyCode::Esc
1144 | KeyCode::Char('s')
1145 | KeyCode::Char('S')
1146 | KeyCode::Char('q')
1147 | KeyCode::Char('Q') => {
1148 self.startup_update_prompt = None;
1149 Ok(false)
1150 }
1151 _ => Ok(false),
1152 }
1153 }
1154
1155 fn toggle_download_screen(&mut self) {
1158 let current =
1159 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1160 match current {
1161 AppScreen::Download(_) => {
1162 self.screen = self
1163 .screen_before_download
1164 .take()
1165 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
1166 }
1167 other => {
1168 self.screen_before_download = Some(other);
1169 self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
1170 }
1171 }
1172 }
1173
1174 fn handle_download(&mut self, key: &KeyEvent) -> Result<bool> {
1175 if key.code == KeyCode::Esc || key.code == KeyCode::Char('d') {
1176 self.screen = self
1177 .screen_before_download
1178 .take()
1179 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
1180 }
1181 Ok(false)
1182 }
1183
1184 async fn handle_main_menu(&mut self, key: &KeyEvent) -> Result<bool> {
1187 let menu = match &mut self.screen {
1188 AppScreen::MainMenu(m) => m,
1189 _ => return Ok(false),
1190 };
1191 match key.code {
1192 KeyCode::Up | KeyCode::Char('k') => menu.previous(),
1193 KeyCode::Down | KeyCode::Char('j') => menu.next(),
1194 KeyCode::Enter => match menu.selected {
1195 0 => {
1196 let start = Instant::now();
1197 let snap = startup_library_snapshot::load_snapshot();
1198 let (platforms, collections, from_disk) = match snap {
1199 Some(s) => (s.platforms, s.collections, true),
1200 None => (Vec::new(), Vec::new(), false),
1201 };
1202 let mut lib = LibraryBrowseScreen::new(platforms, collections);
1203 if from_disk && lib.list_len() > 0 {
1204 lib.set_metadata_footer(Some(
1205 "Refreshing library metadata in background…".into(),
1206 ));
1207 } else if lib.list_len() == 0 {
1208 lib.set_metadata_footer(Some("Loading library metadata…".into()));
1209 }
1210 if lib.list_len() > 0 {
1211 let key = lib.cache_key();
1212 let expected = lib.expected_rom_count();
1213 let req = Self::selected_rom_request_for_library(&lib);
1214 lib.set_rom_loading(expected > 0);
1215 self.deferred_load_roms = Some((
1216 key,
1217 req,
1218 expected,
1219 "startup_first_selection",
1220 Instant::now(),
1221 ));
1222 }
1223 self.screen = AppScreen::LibraryBrowse(lib);
1224 self.spawn_library_metadata_refresh();
1225 tracing::debug!(
1226 "library-open latency_ms={} snapshot_hit={}",
1227 start.elapsed().as_millis(),
1228 from_disk
1229 );
1230 }
1231 1 => self.screen = AppScreen::Search(SearchScreen::new()),
1232 2 => {
1233 self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
1234 self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
1235 }
1236 3 => {
1237 self.screen = AppScreen::Settings(SettingsScreen::new(
1238 &self.config,
1239 self.server_version.as_deref(),
1240 ))
1241 }
1242 4 => return Ok(true),
1243 _ => {}
1244 },
1245 KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
1246 _ => {}
1247 }
1248 Ok(false)
1249 }
1250
1251 async fn handle_library_browse(&mut self, key: &KeyEvent) -> Result<bool> {
1254 use super::path_picker::PathPickerEvent;
1255 use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
1256
1257 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1258 if lib.upload_prompt.is_some() {
1259 if let Some(up) = lib.upload_prompt.as_mut() {
1260 if key.code == KeyCode::Esc {
1261 lib.close_upload_prompt();
1262 return Ok(false);
1263 }
1264 if key.modifiers.contains(KeyModifiers::CONTROL)
1265 && matches!(key.code, KeyCode::Char('s') | KeyCode::Char('S'))
1266 {
1267 up.scan_after = !up.scan_after;
1268 return Ok(false);
1269 }
1270 match up.picker.handle_key(key) {
1271 PathPickerEvent::Confirmed(path) => {
1272 let scan_after = up.scan_after;
1273 if !Path::new(&path).is_file() {
1274 lib.set_metadata_footer(Some(format!(
1275 "Not a file: {}",
1276 path.display()
1277 )));
1278 return Ok(false);
1279 }
1280 let Some(pid) = lib.selected_platform_id() else {
1281 lib.set_metadata_footer(Some(
1282 "Select a console before uploading.".into(),
1283 ));
1284 return Ok(false);
1285 };
1286 lib.close_upload_prompt();
1287 self.spawn_library_upload_worker(pid, path, scan_after);
1288 }
1289 PathPickerEvent::None => {}
1290 }
1291 }
1292 return Ok(false);
1293 }
1294 }
1295
1296 if self.library_upload_inflight {
1297 return Ok(false);
1298 }
1299
1300 let lib = match &mut self.screen {
1301 AppScreen::LibraryBrowse(l) => l,
1302 _ => return Ok(false),
1303 };
1304
1305 if lib.view_mode == LibraryViewMode::List {
1307 if let Some(mode) = lib.list_search.mode {
1308 let old_key = lib.cache_key();
1309 match key.code {
1310 KeyCode::Esc => lib.clear_list_search(),
1311 KeyCode::Backspace => lib.delete_list_search_char(),
1312 KeyCode::Char(c) => lib.add_list_search_char(c),
1313 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.list_jump_match(true),
1314 KeyCode::Enter => lib.commit_list_filter_bar(),
1315 _ => {}
1316 }
1317 let new_key = lib.cache_key();
1318 if old_key != new_key && lib.list_len() > 0 {
1319 lib.clear_roms();
1320 let expected = lib.expected_rom_count();
1321 if expected > 0 {
1322 let req = Self::selected_rom_request_for_library(lib);
1323 lib.set_rom_loading(true);
1324 self.deferred_load_roms =
1325 Some((new_key, req, expected, "search_filter", Instant::now()));
1326 } else {
1327 lib.set_rom_loading(false);
1328 self.deferred_load_roms = None;
1329 }
1330 }
1331 return Ok(false);
1332 }
1333 }
1334
1335 if lib.view_mode == LibraryViewMode::Roms {
1337 if let Some(mode) = lib.rom_search.mode {
1338 match key.code {
1339 KeyCode::Esc => lib.clear_rom_search(),
1340 KeyCode::Backspace => lib.delete_rom_search_char(),
1341 KeyCode::Char(c) => lib.add_rom_search_char(c),
1342 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_rom_match(true),
1343 KeyCode::Enter => lib.commit_rom_filter_bar(),
1344 _ => {}
1345 }
1346 return Ok(false);
1347 }
1348 }
1349
1350 match key.code {
1351 KeyCode::Up | KeyCode::Char('k') => {
1352 if lib.view_mode == LibraryViewMode::List {
1353 lib.list_previous();
1354 if lib.list_len() > 0 {
1355 lib.clear_roms(); let key = lib.cache_key();
1357 let expected = lib.expected_rom_count();
1358 if expected > 0 {
1359 let req = Self::selected_rom_request_for_library(lib);
1360 lib.set_rom_loading(true);
1361 self.deferred_load_roms =
1362 Some((key, req, expected, "list_move_up", Instant::now()));
1363 } else {
1364 lib.set_rom_loading(false);
1365 self.deferred_load_roms = None;
1366 }
1367 if lib.subsection
1368 == super::screens::library_browse::LibrarySubsection::ByCollection
1369 {
1370 tracing::debug!("collections-selection move=up expected={expected}");
1371 self.queue_collection_prefetches_from_screen(1, "move_up");
1372 }
1373 }
1374 } else {
1375 lib.rom_previous();
1376 }
1377 }
1378 KeyCode::Down | KeyCode::Char('j') => {
1379 if lib.view_mode == LibraryViewMode::List {
1380 lib.list_next();
1381 if lib.list_len() > 0 {
1382 lib.clear_roms(); let key = lib.cache_key();
1384 let expected = lib.expected_rom_count();
1385 if expected > 0 {
1386 let req = Self::selected_rom_request_for_library(lib);
1387 lib.set_rom_loading(true);
1388 self.deferred_load_roms =
1389 Some((key, req, expected, "list_move_down", Instant::now()));
1390 } else {
1391 lib.set_rom_loading(false);
1392 self.deferred_load_roms = None;
1393 }
1394 if lib.subsection
1395 == super::screens::library_browse::LibrarySubsection::ByCollection
1396 {
1397 tracing::debug!("collections-selection move=down expected={expected}");
1398 self.queue_collection_prefetches_from_screen(1, "move_down");
1399 }
1400 }
1401 } else {
1402 lib.rom_next();
1403 }
1404 }
1405 KeyCode::Left | KeyCode::Char('h') if lib.view_mode == LibraryViewMode::Roms => {
1406 lib.back_to_list();
1407 }
1408 KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
1409 KeyCode::Tab => {
1410 if lib.view_mode == LibraryViewMode::List {
1411 lib.switch_view();
1412 } else {
1413 lib.switch_view(); }
1415 }
1416 KeyCode::Char('/') => match lib.view_mode {
1417 LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Filter),
1418 LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Filter),
1419 },
1420 KeyCode::Char('f') => match lib.view_mode {
1421 LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Jump),
1422 LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Jump),
1423 },
1424 KeyCode::Enter => {
1425 if lib.view_mode == LibraryViewMode::List {
1426 lib.switch_view();
1427 } else if let Some((primary, others)) = lib.get_selected_group() {
1428 let lib_screen = std::mem::replace(
1429 &mut self.screen,
1430 AppScreen::MainMenu(MainMenuScreen::new()),
1431 );
1432 if let AppScreen::LibraryBrowse(l) = lib_screen {
1433 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
1434 primary,
1435 others,
1436 GameDetailPrevious::Library(Box::new(l)),
1437 self.downloads.shared(),
1438 )));
1439 self.maybe_start_game_detail_cover_load();
1440 }
1441 }
1442 }
1443 KeyCode::Char('t') => {
1444 lib.switch_subsection();
1445 if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 {
1448 let key = lib.cache_key();
1449 let expected = lib.expected_rom_count();
1450 if expected > 0 {
1451 let req = Self::selected_rom_request_for_library(lib);
1452 lib.set_rom_loading(true);
1453 self.deferred_load_roms =
1454 Some((key, req, expected, "switch_subsection", Instant::now()));
1455 } else {
1456 lib.set_rom_loading(false);
1457 self.deferred_load_roms = None;
1458 }
1459 }
1460 if lib.subsection == super::screens::library_browse::LibrarySubsection::ByCollection
1461 {
1462 tracing::debug!("collections-subsection entered");
1463 self.queue_collection_prefetches_from_screen(1, "enter_collections");
1464 }
1465 }
1466 KeyCode::Esc => {
1467 if lib.view_mode == LibraryViewMode::Roms {
1468 if lib.rom_search.filter_browsing {
1469 lib.clear_rom_search();
1470 } else {
1471 lib.back_to_list();
1472 }
1473 } else if lib.list_search.filter_browsing {
1474 lib.clear_list_search();
1475 } else {
1476 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
1477 }
1478 }
1479 KeyCode::Char('q') => return Ok(true),
1480 _ => {}
1481 }
1482 Ok(false)
1483 }
1484
1485 async fn handle_search(&mut self, key: &KeyEvent) -> Result<bool> {
1488 let search = match &mut self.screen {
1489 AppScreen::Search(s) => s,
1490 _ => return Ok(false),
1491 };
1492 match key.code {
1493 KeyCode::Backspace => search.delete_char(),
1494 KeyCode::Left => search.cursor_left(),
1495 KeyCode::Right => search.cursor_right(),
1496 KeyCode::Up => search.previous(),
1497 KeyCode::Down => search.next(),
1498 KeyCode::Char(c) => search.add_char(c),
1499 KeyCode::Enter => {
1500 if search.query.is_empty() {
1501 } else if search.result_groups.is_some() && search.results_match_current_query() {
1503 if let Some((primary, others)) = search.get_selected_group() {
1504 let prev = std::mem::replace(
1505 &mut self.screen,
1506 AppScreen::MainMenu(MainMenuScreen::new()),
1507 );
1508 if let AppScreen::Search(s) = prev {
1509 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
1510 primary,
1511 others,
1512 GameDetailPrevious::Search(s),
1513 self.downloads.shared(),
1514 )));
1515 self.maybe_start_game_detail_cover_load();
1516 }
1517 }
1518 } else {
1519 let query = search.query.clone();
1520 let req = GetRoms {
1521 search_term: Some(query.clone()),
1522 limit: Some(50),
1523 ..Default::default()
1524 };
1525 search.loading = true;
1526 if let Some(task) = self.search_load_task.take() {
1527 task.abort();
1528 }
1529 let client = self.client.clone();
1530 let tx = self.search_load_tx.clone();
1531 self.search_load_task = Some(tokio::spawn(async move {
1532 let mut req = req;
1533 let mut aggregated: Option<RomList> = None;
1534
1535 loop {
1536 match client.call(&req).await {
1537 Ok(mut batch) => {
1538 if let Some(ref mut all) = aggregated {
1539 if batch.items.is_empty() {
1540 break;
1541 }
1542 all.items.append(&mut batch.items);
1543 let _ = tx.send(SearchLoadDone {
1544 query: query.clone(),
1545 event: SearchLoadEvent::Batch(all.clone()),
1546 });
1547 if all.items.len() as u64 >= all.total {
1548 break;
1549 }
1550 req.offset = Some(all.items.len() as u32);
1551 } else {
1552 let loaded = batch.items.len() as u64;
1553 let total = batch.total;
1554 let _ = tx.send(SearchLoadDone {
1555 query: query.clone(),
1556 event: SearchLoadEvent::Batch(batch.clone()),
1557 });
1558 req.offset = Some(loaded as u32);
1559 aggregated = Some(batch);
1560 if loaded >= total {
1561 break;
1562 }
1563 }
1564 }
1565 Err(e) => {
1566 let _ = tx.send(SearchLoadDone {
1567 query: query.clone(),
1568 event: SearchLoadEvent::Failed(format!("{e:#}")),
1569 });
1570 return;
1571 }
1572 }
1573 }
1574
1575 let _ = tx.send(SearchLoadDone {
1576 query,
1577 event: SearchLoadEvent::Complete,
1578 });
1579 }));
1580 }
1581 }
1582 KeyCode::Esc => {
1583 if search.results.is_some() {
1584 search.clear_results();
1585 } else {
1586 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
1587 }
1588 }
1589 _ => {}
1590 }
1591 Ok(false)
1592 }
1593
1594 async fn refresh_settings_server_version(&mut self) -> Result<()> {
1597 let (base_url, download_dir, use_https, verbose, auth) = {
1598 let settings = match &self.screen {
1599 AppScreen::Settings(s) => s,
1600 _ => return Ok(()),
1601 };
1602 let mut base_url = normalize_romm_origin(settings.base_url.trim());
1603 if settings.use_https && base_url.starts_with("http://") {
1604 base_url = base_url.replace("http://", "https://");
1605 }
1606 if !settings.use_https && base_url.starts_with("https://") {
1607 base_url = base_url.replace("https://", "http://");
1608 }
1609 (
1610 base_url,
1611 settings.download_dir.clone(),
1612 settings.use_https,
1613 self.client.verbose(),
1614 self.config.auth.clone(),
1615 )
1616 };
1617 let cfg = Config {
1618 base_url,
1619 download_dir,
1620 use_https,
1621 auth,
1622 };
1623 let client = match RommClient::new(&cfg, verbose) {
1624 Ok(c) => c,
1625 Err(_) => {
1626 if let AppScreen::Settings(s) = &mut self.screen {
1627 s.server_version = "unavailable (invalid URL or client error)".to_string();
1628 self.server_version = None;
1629 }
1630 return Ok(());
1631 }
1632 };
1633 let ver = client.rom_server_version_from_heartbeat().await;
1634 if let AppScreen::Settings(s) = &mut self.screen {
1635 match ver {
1636 Some(v) => {
1637 s.server_version = v.clone();
1638 self.server_version = Some(v);
1639 }
1640 None => {
1641 s.server_version = "unavailable (heartbeat failed)".to_string();
1642 self.server_version = None;
1643 }
1644 }
1645 }
1646 Ok(())
1647 }
1648
1649 async fn handle_settings(&mut self, key: &KeyEvent) -> Result<bool> {
1650 use super::path_picker::PathPickerEvent;
1651 use crate::core::download::validate_configured_download_directory;
1652
1653 let settings = match &mut self.screen {
1654 AppScreen::Settings(s) => s,
1655 _ => return Ok(false),
1656 };
1657
1658 if let Some(ref mut picker) = settings.path_picker {
1659 if key.code == KeyCode::Esc {
1660 settings.path_picker = None;
1661 return Ok(false);
1662 }
1663 match picker.handle_key(key) {
1664 PathPickerEvent::Confirmed(p) => {
1665 match validate_configured_download_directory(p.to_string_lossy().as_ref()) {
1666 Ok(canonical) => {
1667 settings.download_dir = canonical.display().to_string();
1668 settings.path_picker = None;
1669 settings.message = Some((
1670 "ROMs directory updated (press S to save)".to_string(),
1671 Color::Green,
1672 ));
1673 }
1674 Err(e) => {
1675 settings.message =
1676 Some((format!("Invalid ROMs directory: {e:#}"), Color::Red));
1677 }
1678 }
1679 }
1680 PathPickerEvent::None => {}
1681 }
1682 return Ok(false);
1683 }
1684
1685 if settings.confirm.is_some() {
1686 match key.code {
1687 KeyCode::Enter => match settings.confirm.take().unwrap() {
1688 super::screens::settings::SettingsConfirm::Reset => {
1689 let _ = crate::config::reset_all_settings();
1690 settings.message = Some((
1691 "Settings deleted. Please restart romm-cli.".to_string(),
1692 Color::Yellow,
1693 ));
1694 }
1695 super::screens::settings::SettingsConfirm::ClearCache => {
1696 match crate::core::cache::RomCache::clear_file() {
1697 Ok(true) => {
1698 self.rom_cache = crate::core::cache::RomCache::load();
1699 settings.message =
1700 Some(("ROM cache cleared.".to_string(), Color::Green));
1701 }
1702 Ok(false) => {
1703 settings.message = Some((
1704 "ROM cache file does not exist.".to_string(),
1705 Color::Yellow,
1706 ));
1707 }
1708 Err(e) => {
1709 settings.message =
1710 Some((format!("Failed to clear cache: {e}"), Color::Red));
1711 }
1712 }
1713 }
1714 },
1715 KeyCode::Esc => {
1716 settings.confirm = None;
1717 }
1718 _ => {}
1719 }
1720 return Ok(false);
1721 }
1722
1723 if settings.editing {
1724 match key.code {
1725 KeyCode::Enter => {
1726 let idx = settings.selected_index;
1727 settings.save_edit();
1728 if idx == 0 {
1729 self.refresh_settings_server_version().await?;
1730 }
1731 }
1732 KeyCode::Esc => settings.cancel_edit(),
1733 KeyCode::Backspace => settings.delete_char(),
1734 KeyCode::Left => settings.move_cursor_left(),
1735 KeyCode::Right => settings.move_cursor_right(),
1736 KeyCode::Char(c) => settings.add_char(c),
1737 _ => {}
1738 }
1739 return Ok(false);
1740 }
1741
1742 match key.code {
1743 KeyCode::Up | KeyCode::Char('k') => settings.previous(),
1744 KeyCode::Down | KeyCode::Char('j') => settings.next(),
1745 KeyCode::Enter => {
1746 if settings.selected_index == 3 {
1747 self.screen =
1748 AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
1749 } else {
1750 let toggle_https = settings.selected_index == 2;
1751 settings.enter_edit();
1752 if toggle_https {
1753 self.refresh_settings_server_version().await?;
1754 }
1755 }
1756 }
1757 KeyCode::Char('s' | 'S') => {
1758 use crate::config::persist_user_config;
1760 let auth = auth_for_persist_merge(self.config.auth.clone());
1761 if let Err(e) = persist_user_config(
1762 &settings.base_url,
1763 &settings.download_dir,
1764 settings.use_https,
1765 auth,
1766 ) {
1767 settings.message = Some((format!("Error saving: {e}"), Color::Red));
1768 } else {
1769 settings.message = Some(("Saved to config.json".to_string(), Color::Green));
1770 self.config.base_url = settings.base_url.clone();
1772 self.config.download_dir = settings.download_dir.clone();
1773 self.config.use_https = settings.use_https;
1774 if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
1776 self.client = new_client;
1777 }
1778 }
1779 }
1780 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
1781 KeyCode::Char('q') => return Ok(true),
1782 _ => {}
1783 }
1784 Ok(false)
1785 }
1786
1787 fn handle_browse(&mut self, key: &KeyEvent) -> Result<bool> {
1790 use super::screens::browse::ViewMode;
1791
1792 let browse = match &mut self.screen {
1793 AppScreen::Browse(b) => b,
1794 _ => return Ok(false),
1795 };
1796 match key.code {
1797 KeyCode::Up | KeyCode::Char('k') => browse.previous(),
1798 KeyCode::Down | KeyCode::Char('j') => browse.next(),
1799 KeyCode::Left | KeyCode::Char('h') if browse.view_mode == ViewMode::Endpoints => {
1800 browse.switch_view();
1801 }
1802 KeyCode::Right | KeyCode::Char('l') if browse.view_mode == ViewMode::Sections => {
1803 browse.switch_view();
1804 }
1805 KeyCode::Tab => browse.switch_view(),
1806 KeyCode::Enter => {
1807 if browse.view_mode == ViewMode::Endpoints {
1808 if let Some(ep) = browse.get_selected_endpoint() {
1809 self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
1810 }
1811 } else {
1812 browse.switch_view();
1813 }
1814 }
1815 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
1816 _ => {}
1817 }
1818 Ok(false)
1819 }
1820
1821 async fn handle_execute(&mut self, key: &KeyEvent) -> Result<bool> {
1824 let execute = match &mut self.screen {
1825 AppScreen::Execute(e) => e,
1826 _ => return Ok(false),
1827 };
1828 match key.code {
1829 KeyCode::Tab => execute.next_field(),
1830 KeyCode::BackTab => execute.previous_field(),
1831 KeyCode::Char(c) => execute.add_char_to_focused(c),
1832 KeyCode::Backspace => execute.delete_char_from_focused(),
1833 KeyCode::Enter => {
1834 let endpoint = execute.endpoint.clone();
1835 let query = execute.get_query_params();
1836 let body = if endpoint.has_body && !execute.body_text.is_empty() {
1837 Some(serde_json::from_str(&execute.body_text)?)
1838 } else {
1839 None
1840 };
1841 let resolved_path =
1842 match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
1843 Ok(p) => p,
1844 Err(e) => {
1845 self.screen = AppScreen::Result(ResultScreen::new(
1846 serde_json::json!({ "error": format!("{e}") }),
1847 None,
1848 None,
1849 ));
1850 return Ok(false);
1851 }
1852 };
1853 match self
1854 .client
1855 .request_json(&endpoint.method, &resolved_path, &query, body)
1856 .await
1857 {
1858 Ok(result) => {
1859 self.screen = AppScreen::Result(ResultScreen::new(
1860 result,
1861 Some(&endpoint.method),
1862 Some(resolved_path.as_str()),
1863 ));
1864 }
1865 Err(e) => {
1866 self.screen = AppScreen::Result(ResultScreen::new(
1867 serde_json::json!({ "error": format!("{e}") }),
1868 None,
1869 None,
1870 ));
1871 }
1872 }
1873 }
1874 KeyCode::Esc => {
1875 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
1876 }
1877 _ => {}
1878 }
1879 Ok(false)
1880 }
1881
1882 fn handle_result(&mut self, key: &KeyEvent) -> Result<bool> {
1885 use super::screens::result::ResultViewMode;
1886
1887 let result = match &mut self.screen {
1888 AppScreen::Result(r) => r,
1889 _ => return Ok(false),
1890 };
1891 match key.code {
1892 KeyCode::Up | KeyCode::Char('k') => {
1893 if result.view_mode == ResultViewMode::Json {
1894 result.scroll_up(1);
1895 } else {
1896 result.table_previous();
1897 }
1898 }
1899 KeyCode::Down => {
1900 if result.view_mode == ResultViewMode::Json {
1901 result.scroll_down(1);
1902 } else {
1903 result.table_next();
1904 }
1905 }
1906 KeyCode::Char('j') if result.view_mode == ResultViewMode::Json => {
1907 result.scroll_down(1);
1908 }
1909 KeyCode::PageUp => {
1910 if result.view_mode == ResultViewMode::Table {
1911 result.table_page_up();
1912 } else {
1913 result.scroll_up(10);
1914 }
1915 }
1916 KeyCode::PageDown => {
1917 if result.view_mode == ResultViewMode::Table {
1918 result.table_page_down();
1919 } else {
1920 result.scroll_down(10);
1921 }
1922 }
1923 KeyCode::Char('t') if result.table_row_count > 0 => {
1924 result.switch_view_mode();
1925 }
1926 KeyCode::Enter
1927 if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 =>
1928 {
1929 if let Some(item) = result.get_selected_item_value() {
1930 let prev = std::mem::replace(
1931 &mut self.screen,
1932 AppScreen::MainMenu(MainMenuScreen::new()),
1933 );
1934 if let AppScreen::Result(rs) = prev {
1935 self.screen = AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
1936 }
1937 }
1938 }
1939 KeyCode::Esc => {
1940 result.clear_message();
1941 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
1942 }
1943 KeyCode::Char('q') => return Ok(true),
1944 _ => {}
1945 }
1946 Ok(false)
1947 }
1948
1949 fn handle_result_detail(&mut self, key: &KeyEvent) -> Result<bool> {
1952 let detail = match &mut self.screen {
1953 AppScreen::ResultDetail(d) => d,
1954 _ => return Ok(false),
1955 };
1956 match key.code {
1957 KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
1958 KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
1959 KeyCode::PageUp => detail.scroll_up(10),
1960 KeyCode::PageDown => detail.scroll_down(10),
1961 KeyCode::Char('o') => detail.open_image_url(),
1962 KeyCode::Esc => {
1963 detail.clear_message();
1964 let prev =
1965 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1966 if let AppScreen::ResultDetail(d) = prev {
1967 self.screen = AppScreen::Result(d.parent);
1968 }
1969 }
1970 KeyCode::Char('q') => return Ok(true),
1971 _ => {}
1972 }
1973 Ok(false)
1974 }
1975
1976 fn handle_game_detail(&mut self, key: &KeyEvent) -> Result<bool> {
1979 let detail = match &mut self.screen {
1980 AppScreen::GameDetail(d) => d,
1981 _ => return Ok(false),
1982 };
1983
1984 if !detail.download_completion_acknowledged {
1987 if let Ok(list) = detail.downloads.lock() {
1988 let has_completed = list.iter().any(|j| {
1989 j.rom_id == detail.rom.id
1990 && matches!(
1991 j.status,
1992 crate::core::download::DownloadStatus::Done
1993 | crate::core::download::DownloadStatus::SkippedAlreadyExists
1994 | crate::core::download::DownloadStatus::Cancelled
1995 | crate::core::download::DownloadStatus::FinalizeFailed(_)
1996 | crate::core::download::DownloadStatus::Error(_)
1997 )
1998 });
1999 let is_still_downloading = list.iter().any(|j| {
2000 j.rom_id == detail.rom.id
2001 && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
2002 });
2003 if has_completed && !is_still_downloading {
2005 detail.download_completion_acknowledged = true;
2006 }
2007 }
2008 }
2009
2010 match key.code {
2011 KeyCode::Enter if !detail.has_started_download => {
2014 match self.downloads.start_download(
2015 &detail.rom,
2016 self.client.clone(),
2017 Some(self.config.download_dir.as_str()),
2018 ) {
2019 Ok(()) => detail.has_started_download = true,
2020 Err(err) => {
2021 detail.has_started_download = false;
2022 detail.message = Some(format!(
2023 "Download blocked: {err}. Fix ROMs directory in settings/setup."
2024 ));
2025 }
2026 }
2027 }
2028 KeyCode::Char('o') => detail.open_cover(),
2029 KeyCode::Char('m') => detail.toggle_technical(),
2030 KeyCode::Esc => {
2031 detail.clear_message();
2032 let prev =
2033 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2034 if let AppScreen::GameDetail(g) = prev {
2035 self.screen = match g.previous {
2036 GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(*l),
2037 GameDetailPrevious::Search(s) => AppScreen::Search(s),
2038 };
2039 }
2040 }
2041 KeyCode::Char('q') => return Ok(true),
2042 _ => {}
2043 }
2044 Ok(false)
2045 }
2046
2047 async fn handle_setup_wizard(&mut self, key: &KeyEvent) -> Result<bool> {
2050 let wizard = match &mut self.screen {
2051 AppScreen::SetupWizard(w) => w,
2052 _ => return Ok(false),
2053 };
2054
2055 if wizard.handle_key(key)? {
2056 self.screen = AppScreen::Settings(SettingsScreen::new(
2058 &self.config,
2059 self.server_version.as_deref(),
2060 ));
2061 return Ok(false);
2062 }
2063
2064 if wizard.testing {
2065 let result = wizard.try_connect_and_persist(self.client.verbose()).await;
2066 wizard.testing = false;
2067 match result {
2068 Ok(cfg) => {
2069 let auth_ok = cfg.auth.is_some();
2070 self.config = cfg;
2071 if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
2072 self.client = new_client;
2073 }
2074 let mut settings =
2075 SettingsScreen::new(&self.config, self.server_version.as_deref());
2076 if auth_ok {
2077 settings.message = Some((
2078 "Authentication updated successfully".to_string(),
2079 Color::Green,
2080 ));
2081 } else {
2082 settings.message = Some((
2083 "Saved configuration but credentials could not be loaded from the OS keyring (see logs)."
2084 .to_string(),
2085 Color::Yellow,
2086 ));
2087 }
2088 self.screen = AppScreen::Settings(settings);
2089 }
2090 Err(e) => {
2091 wizard.error = Some(format!("{e:#}"));
2092 }
2093 }
2094 }
2095 Ok(false)
2096 }
2097
2098 fn render(&mut self, f: &mut ratatui::Frame) {
2103 let area = f.area();
2104 if let Some(ref splash) = self.startup_splash {
2105 connected_splash::render(f, area, splash);
2106 return;
2107 }
2108 match &mut self.screen {
2109 AppScreen::MainMenu(menu) => menu.render(f, area),
2110 AppScreen::LibraryBrowse(lib) => {
2111 lib.render(f, area);
2112 if let Some((x, y)) = lib.upload_prompt_cursor(area) {
2113 f.set_cursor_position((x, y));
2114 }
2115 }
2116 AppScreen::Search(search) => {
2117 search.render(f, area);
2118 if let Some((x, y)) = search.cursor_position(area) {
2119 f.set_cursor_position((x, y));
2120 }
2121 }
2122 AppScreen::Settings(settings) => {
2123 settings.render(f, area);
2124 if let Some((x, y)) = settings.cursor_position(area) {
2125 f.set_cursor_position((x, y));
2126 }
2127 }
2128 AppScreen::Browse(browse) => browse.render(f, area),
2129 AppScreen::Execute(execute) => {
2130 execute.render(f, area);
2131 if let Some((x, y)) = execute.cursor_position(area) {
2132 f.set_cursor_position((x, y));
2133 }
2134 }
2135 AppScreen::Result(result) => result.render(f, area),
2136 AppScreen::ResultDetail(detail) => detail.render(f, area),
2137 AppScreen::GameDetail(detail) => detail.render(f, area),
2138 AppScreen::Download(d) => d.render(f, area),
2139 AppScreen::SetupWizard(wizard) => {
2140 wizard.render(f, area);
2141 if let Some((x, y)) = wizard.cursor_pos(area) {
2142 f.set_cursor_position((x, y));
2143 }
2144 }
2145 }
2146
2147 if self.show_keyboard_help {
2148 keyboard_help::render_keyboard_help(f, area);
2149 }
2150
2151 if let Some(prompt) = &self.startup_update_prompt {
2152 let popup_area = ratatui::layout::Rect {
2153 x: area.width.saturating_sub(76) / 2,
2154 y: area.height.saturating_sub(11) / 2,
2155 width: 76.min(area.width),
2156 height: 11.min(area.height),
2157 };
2158 f.render_widget(ratatui::widgets::Clear, popup_area);
2159 let block = ratatui::widgets::Block::default()
2160 .title("Update available")
2161 .borders(ratatui::widgets::Borders::ALL);
2162 let text = format!(
2163 "Current: {}\nLatest: {}\n\n\
2164 U/Enter: update now\n\
2165 C: open changelog\n\
2166 S/Esc: skip",
2167 prompt.status.current_version, prompt.status.latest_version
2168 );
2169 let paragraph = ratatui::widgets::Paragraph::new(text)
2170 .block(block)
2171 .wrap(ratatui::widgets::Wrap { trim: true });
2172 f.render_widget(paragraph, popup_area);
2173 }
2174
2175 if let Some(ref err) = self.global_error {
2176 let popup_area = ratatui::layout::Rect {
2177 x: area.width.saturating_sub(60) / 2,
2178 y: area.height.saturating_sub(10) / 2,
2179 width: 60.min(area.width),
2180 height: 10.min(area.height),
2181 };
2182 f.render_widget(ratatui::widgets::Clear, popup_area);
2183 let block = ratatui::widgets::Block::default()
2184 .title("Error")
2185 .borders(ratatui::widgets::Borders::ALL)
2186 .style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
2187 let text = format!("{}\n\nPress Esc to dismiss", err);
2188 let paragraph = ratatui::widgets::Paragraph::new(text)
2189 .block(block)
2190 .wrap(ratatui::widgets::Wrap { trim: true });
2191 f.render_widget(paragraph, popup_area);
2192 }
2193 }
2194}
2195
2196#[cfg(test)]
2197mod tests {
2198 use super::*;
2199 use crate::config::Config;
2200 use crate::tui::openapi::EndpointRegistry;
2201 use crate::tui::screens::library_browse::LibraryBrowseScreen;
2202 use crate::tui::screens::{GameDetailPrevious, GameDetailScreen, SearchScreen};
2203 use crate::types::Platform;
2204 use crate::update::UpdateStatus;
2205 use crossterm::event::{KeyEvent, KeyModifiers};
2206 use serde_json::json;
2207
2208 fn platform(id: u64, name: &str, rom_count: u64) -> Platform {
2209 serde_json::from_value(json!({
2210 "id": id,
2211 "slug": format!("p{id}"),
2212 "fs_slug": format!("p{id}"),
2213 "rom_count": rom_count,
2214 "name": name,
2215 "igdb_slug": null,
2216 "moby_slug": null,
2217 "hltb_slug": null,
2218 "custom_name": null,
2219 "igdb_id": null,
2220 "sgdb_id": null,
2221 "moby_id": null,
2222 "launchbox_id": null,
2223 "ss_id": null,
2224 "ra_id": null,
2225 "hasheous_id": null,
2226 "tgdb_id": null,
2227 "flashpoint_id": null,
2228 "category": null,
2229 "generation": null,
2230 "family_name": null,
2231 "family_slug": null,
2232 "url": null,
2233 "url_logo": null,
2234 "firmware": [],
2235 "aspect_ratio": null,
2236 "created_at": "",
2237 "updated_at": "",
2238 "fs_size_bytes": 0,
2239 "is_unidentified": false,
2240 "is_identified": true,
2241 "missing_from_fs": false,
2242 "display_name": null
2243 }))
2244 .expect("valid platform fixture")
2245 }
2246
2247 fn app_with_library(platforms: Vec<Platform>) -> App {
2248 let config = Config {
2249 base_url: "http://127.0.0.1:9".into(),
2250 download_dir: "/tmp".into(),
2251 use_https: false,
2252 auth: None,
2253 };
2254 let client = RommClient::new(&config, false).expect("client");
2255 let mut app = App::new(
2256 client,
2257 config,
2258 EndpointRegistry::default(),
2259 None,
2260 None,
2261 None,
2262 );
2263 app.screen = AppScreen::LibraryBrowse(LibraryBrowseScreen::new(platforms, vec![]));
2264 app
2265 }
2266
2267 fn update_status_fixture() -> UpdateStatus {
2268 UpdateStatus {
2269 current_version: "0.25.0".into(),
2270 latest_version: "0.26.0".into(),
2271 should_update: true,
2272 release_url: "https://github.com/patricksmill/romm-cli/releases/tag/v0.26.0".into(),
2273 changelog_url: "https://github.com/patricksmill/romm-cli/blob/main/CHANGELOG.md".into(),
2274 }
2275 }
2276
2277 fn rom_fixture() -> crate::types::Rom {
2278 serde_json::from_value(json!({
2279 "id": 10,
2280 "platform_id": 1,
2281 "platform_slug": null,
2282 "platform_fs_slug": null,
2283 "platform_custom_name": null,
2284 "platform_display_name": null,
2285 "fs_name": "sample.zip",
2286 "fs_name_no_tags": "sample",
2287 "fs_name_no_ext": "sample",
2288 "fs_extension": "zip",
2289 "fs_path": "/sample.zip",
2290 "fs_size_bytes": 100,
2291 "name": "Sample",
2292 "slug": null,
2293 "summary": null,
2294 "path_cover_small": null,
2295 "path_cover_large": null,
2296 "url_cover": null,
2297 "is_unidentified": false,
2298 "is_identified": true
2299 }))
2300 .expect("valid rom fixture")
2301 }
2302
2303 fn empty_rom_list_with_total(total: u64) -> RomList {
2304 RomList {
2305 items: vec![],
2306 total,
2307 limit: 50,
2308 offset: 0,
2309 }
2310 }
2311
2312 #[tokio::test]
2313 async fn list_move_to_zero_rom_selection_does_not_queue_deferred_load() {
2314 let mut app = app_with_library(vec![platform(1, "HasRoms", 5), platform(2, "Empty", 0)]);
2315
2316 assert!(!app
2317 .handle_key_event(&KeyEvent::new(KeyCode::Down, KeyModifiers::empty()))
2318 .await
2319 .expect("key handled"));
2320 assert!(
2321 app.deferred_load_roms.is_none(),
2322 "selection move to zero-rom platform should not queue deferred ROM load"
2323 );
2324 }
2325
2326 #[test]
2327 fn ctrl_c_is_treated_as_force_quit() {
2328 let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
2329 assert!(App::is_force_quit_key(&ctrl_c));
2330
2331 let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty());
2332 assert!(!App::is_force_quit_key(&plain_c));
2333 }
2334
2335 #[test]
2336 fn primary_rom_load_stale_gen_is_ignored() {
2337 assert!(!super::primary_rom_load_result_is_current(1, 2));
2338 assert!(super::primary_rom_load_result_is_current(3, 3));
2339 }
2340
2341 #[tokio::test]
2342 async fn game_detail_esc_returns_to_previous_library_screen() {
2343 let mut app = app_with_library(vec![platform(1, "NES", 1)]);
2344 let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
2345 let detail = GameDetailScreen::new(
2346 rom_fixture(),
2347 Vec::new(),
2348 GameDetailPrevious::Library(Box::new(previous)),
2349 app.downloads.shared(),
2350 );
2351 app.screen = AppScreen::GameDetail(Box::new(detail));
2352
2353 let quit = app
2354 .handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
2355 .await
2356 .expect("esc handled");
2357 assert!(!quit);
2358 assert!(matches!(app.screen, AppScreen::LibraryBrowse(_)));
2359 }
2360
2361 #[tokio::test]
2362 async fn startup_update_prompt_skip_closes_prompt() {
2363 let config = Config {
2364 base_url: "http://127.0.0.1:9".into(),
2365 download_dir: "/tmp".into(),
2366 use_https: false,
2367 auth: None,
2368 };
2369 let client = RommClient::new(&config, false).expect("client");
2370 let mut app = App::new(
2371 client,
2372 config,
2373 EndpointRegistry::default(),
2374 None,
2375 None,
2376 Some(update_status_fixture()),
2377 );
2378 assert!(app.startup_update_prompt.is_some());
2379 let quit = app
2380 .handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
2381 .await
2382 .expect("esc handled");
2383 assert!(!quit);
2384 assert!(app.startup_update_prompt.is_none());
2385 }
2386
2387 #[test]
2388 fn search_batch_updates_results_without_stopping_loading() {
2389 let config = Config {
2390 base_url: "http://127.0.0.1:9".into(),
2391 download_dir: "/tmp".into(),
2392 use_https: false,
2393 auth: None,
2394 };
2395 let client = RommClient::new(&config, false).expect("client");
2396 let mut app = App::new(
2397 client,
2398 config,
2399 EndpointRegistry::default(),
2400 None,
2401 None,
2402 None,
2403 );
2404 let mut search = SearchScreen::new();
2405 search.loading = true;
2406 app.screen = AppScreen::Search(search);
2407
2408 app.search_load_tx
2409 .send(SearchLoadDone {
2410 query: "zelda".to_string(),
2411 event: SearchLoadEvent::Batch(empty_rom_list_with_total(120)),
2412 })
2413 .expect("send batch");
2414
2415 app.poll_search_load_results();
2416
2417 match &app.screen {
2418 AppScreen::Search(search) => {
2419 assert!(search.loading, "loading should continue after batch");
2420 assert!(search.results.is_some(), "batch should populate results");
2421 assert_eq!(search.last_searched_query.as_deref(), Some("zelda"));
2422 }
2423 _ => panic!("expected search screen"),
2424 }
2425 }
2426
2427 #[test]
2428 fn search_complete_event_stops_loading() {
2429 let config = Config {
2430 base_url: "http://127.0.0.1:9".into(),
2431 download_dir: "/tmp".into(),
2432 use_https: false,
2433 auth: None,
2434 };
2435 let client = RommClient::new(&config, false).expect("client");
2436 let mut app = App::new(
2437 client,
2438 config,
2439 EndpointRegistry::default(),
2440 None,
2441 None,
2442 None,
2443 );
2444 let mut search = SearchScreen::new();
2445 search.loading = true;
2446 app.screen = AppScreen::Search(search);
2447
2448 app.search_load_tx
2449 .send(SearchLoadDone {
2450 query: "zelda".to_string(),
2451 event: SearchLoadEvent::Complete,
2452 })
2453 .expect("send complete");
2454
2455 app.poll_search_load_results();
2456
2457 match &app.screen {
2458 AppScreen::Search(search) => {
2459 assert!(!search.loading, "loading should stop after completion");
2460 }
2461 _ => panic!("expected search screen"),
2462 }
2463 }
2464}