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