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