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