1use anyhow::Result;
14use crossterm::event::{
15 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
16};
17use crossterm::execute;
18use crossterm::terminal::{
19 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
20};
21use ratatui::backend::CrosstermBackend;
22use ratatui::style::Color;
23use ratatui::Terminal;
24use std::collections::{HashSet, VecDeque};
25use std::time::{Duration, Instant};
26
27use crate::client::RommClient;
28use crate::config::{auth_for_persist_merge, normalize_romm_origin, Config};
29use crate::core::cache::{RomCache, RomCacheKey};
30use crate::core::download::DownloadManager;
31use crate::core::startup_library_snapshot;
32use crate::endpoints::roms::GetRoms;
33use crate::types::{Collection, RomList};
34
35use super::keyboard_help;
36use super::openapi::{resolve_path_template, EndpointRegistry};
37use super::screens::connected_splash::{self, StartupSplash};
38use super::screens::setup_wizard::SetupWizard;
39use super::screens::{
40 BrowseScreen, DownloadScreen, ExecuteScreen, GameDetailPrevious, GameDetailScreen,
41 LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen, SearchScreen,
42 SettingsScreen,
43};
44
45struct LibraryMetadataRefreshDone {
47 gen: u64,
48 collections: Vec<Collection>,
49 collection_digest: Vec<startup_library_snapshot::CollectionDigestEntry>,
50 warnings: Vec<String>,
51}
52
53struct CollectionPrefetchDone {
54 key: RomCacheKey,
55 expected: u64,
56 roms: Option<RomList>,
57 warning: Option<String>,
58}
59
60struct RomLoadDone {
62 gen: u64,
63 key: Option<RomCacheKey>,
64 expected: u64,
65 result: Result<RomList, String>,
66 context: &'static str,
67 started: Instant,
68}
69
70struct SearchLoadDone {
71 result: Result<RomList, String>,
72}
73
74type DeferredLoadRoms = (
76 Option<RomCacheKey>,
77 Option<GetRoms>,
78 u64,
79 &'static str,
80 Instant,
81);
82
83#[inline]
84fn primary_rom_load_result_is_current(done_gen: u64, current_gen: u64) -> bool {
85 done_gen == current_gen
86}
87
88pub enum AppScreen {
97 MainMenu(MainMenuScreen),
98 LibraryBrowse(LibraryBrowseScreen),
99 Search(SearchScreen),
100 Settings(SettingsScreen),
101 Browse(BrowseScreen),
102 Execute(ExecuteScreen),
103 Result(ResultScreen),
104 ResultDetail(ResultDetailScreen),
105 GameDetail(Box<GameDetailScreen>),
106 Download(DownloadScreen),
107 SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
108}
109
110fn blocks_global_d_shortcut(screen: &AppScreen) -> bool {
111 match screen {
112 AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
113 AppScreen::LibraryBrowse(lib) => lib.any_search_bar_open(),
114 _ => false,
115 }
116}
117
118fn allows_global_question_help(screen: &AppScreen) -> bool {
119 match screen {
120 AppScreen::Search(_) | AppScreen::SetupWizard(_) | AppScreen::Execute(_) => false,
121 AppScreen::LibraryBrowse(lib) if lib.any_search_bar_open() => false,
122 AppScreen::Settings(s) if s.editing => false,
123 _ => true,
124 }
125}
126
127pub struct App {
136 pub screen: AppScreen,
137 client: RommClient,
138 config: Config,
139 registry: EndpointRegistry,
140 server_version: Option<String>,
142 rom_cache: RomCache,
143 downloads: DownloadManager,
144 screen_before_download: Option<AppScreen>,
146 deferred_load_roms: Option<DeferredLoadRoms>,
148 startup_splash: Option<StartupSplash>,
150 pub global_error: Option<String>,
151 show_keyboard_help: bool,
152 library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
154 library_metadata_refresh_gen: u64,
156 collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
157 collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
158 collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
159 collection_prefetch_queued_keys: HashSet<RomCacheKey>,
160 collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
161 rom_load_gen: u64,
163 rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
164 rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
165 rom_load_task: Option<tokio::task::JoinHandle<()>>,
166 search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
167 search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
168 search_load_task: Option<tokio::task::JoinHandle<()>>,
169}
170
171impl App {
172 fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
173 key.kind == KeyEventKind::Press
174 && key.modifiers.contains(KeyModifiers::CONTROL)
175 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
176 }
177
178 fn selected_rom_request_for_library(
179 lib: &super::screens::library_browse::LibraryBrowseScreen,
180 ) -> Option<GetRoms> {
181 match lib.subsection {
182 super::screens::library_browse::LibrarySubsection::ByConsole => {
183 lib.get_roms_request_platform()
184 }
185 super::screens::library_browse::LibrarySubsection::ByCollection => {
186 lib.get_roms_request_collection()
187 }
188 }
189 }
190
191 pub fn new(
193 client: RommClient,
194 config: Config,
195 registry: EndpointRegistry,
196 server_version: Option<String>,
197 startup_splash: Option<StartupSplash>,
198 ) -> Self {
199 let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
200 let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
201 let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
202 Self {
203 screen: AppScreen::MainMenu(MainMenuScreen::new()),
204 client,
205 config,
206 registry,
207 server_version,
208 rom_cache: RomCache::load(),
209 downloads: DownloadManager::new(),
210 screen_before_download: None,
211 deferred_load_roms: None,
212 startup_splash,
213 global_error: None,
214 show_keyboard_help: false,
215 library_metadata_rx: None,
216 library_metadata_refresh_gen: 0,
217 collection_prefetch_rx: prefetch_rx,
218 collection_prefetch_tx: prefetch_tx,
219 collection_prefetch_queue: VecDeque::new(),
220 collection_prefetch_queued_keys: HashSet::new(),
221 collection_prefetch_inflight_keys: HashSet::new(),
222 rom_load_gen: 0,
223 rom_load_rx,
224 rom_load_tx,
225 rom_load_task: None,
226 search_load_rx,
227 search_load_tx,
228 search_load_task: None,
229 }
230 }
231
232 fn spawn_library_metadata_refresh(&mut self) {
233 self.library_metadata_refresh_gen = self.library_metadata_refresh_gen.saturating_add(1);
234 let gen = self.library_metadata_refresh_gen;
235 let client = self.client.clone();
236 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
237 self.library_metadata_rx = Some(rx);
238 tokio::spawn(async move {
239 let fetch = startup_library_snapshot::fetch_collection_summaries(&client).await;
240 let _ = tx.send(LibraryMetadataRefreshDone {
241 gen,
242 collections: fetch.collections,
243 collection_digest: fetch.collection_digest,
244 warnings: fetch.warnings,
245 });
246 });
247 }
248
249 pub fn poll_background_tasks(&mut self) {
251 self.poll_library_metadata_refresh();
252 self.poll_rom_load_results();
253 self.poll_collection_prefetch_results();
254 self.poll_search_load_results();
255 self.drive_collection_prefetch_scheduler();
256 }
257
258 fn poll_search_load_results(&mut self) {
259 loop {
260 match self.search_load_rx.try_recv() {
261 Ok(done) => {
262 if let AppScreen::Search(ref mut search) = self.screen {
263 search.loading = false;
264 if let Ok(roms) = done.result {
265 search.set_results(roms);
266 }
267 }
268 }
269 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
270 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
271 }
272 }
273 }
274
275 fn poll_rom_load_results(&mut self) {
276 loop {
277 match self.rom_load_rx.try_recv() {
278 Ok(done) => {
279 if !primary_rom_load_result_is_current(done.gen, self.rom_load_gen) {
280 continue;
281 }
282 let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
283 continue;
284 };
285 match done.result {
286 Ok(roms) => {
287 if let Some(ref k) = done.key {
288 self.rom_cache
289 .insert(k.clone(), roms.clone(), done.expected);
290 }
291 lib.set_roms(roms);
292 tracing::debug!(
293 "rom-list-render context={} latency_ms={}",
294 done.context,
295 done.started.elapsed().as_millis()
296 );
297 }
298 Err(e) => {
299 lib.set_metadata_footer(Some(format!("Could not load games: {e}")));
300 }
301 }
302 lib.set_rom_loading(false);
303 }
304 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
305 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
306 }
307 }
308 }
309
310 fn poll_library_metadata_refresh(&mut self) {
311 let mut batch = Vec::new();
312 let mut disconnected = false;
313 if let Some(rx) = &mut self.library_metadata_rx {
314 loop {
315 match rx.try_recv() {
316 Ok(msg) => batch.push(msg),
317 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
318 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
319 disconnected = true;
320 break;
321 }
322 }
323 }
324 }
325 if disconnected {
326 self.library_metadata_rx = None;
327 }
328 for msg in batch {
329 self.apply_library_metadata_refresh(msg);
330 }
331 }
332
333 fn apply_library_metadata_refresh(&mut self, msg: LibraryMetadataRefreshDone) {
334 if msg.gen != self.library_metadata_refresh_gen {
335 return;
336 }
337 let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
338 return;
339 };
340
341 let had_cached_lists = !lib.platforms.is_empty() || !lib.collections.is_empty();
342 let live_empty = msg.collections.is_empty();
343 if live_empty && had_cached_lists && !msg.warnings.is_empty() {
344 lib.set_metadata_footer(Some(
345 "Could not refresh library metadata (keeping cached list).".into(),
346 ));
347 return;
348 }
349
350 let old_digest =
351 startup_library_snapshot::build_collection_digest_from_collections(&lib.collections);
352 let digest_changed = old_digest != msg.collection_digest;
353 let selection_changed =
354 lib.replace_metadata_preserving_selection(Vec::new(), msg.collections, false, true);
355 startup_library_snapshot::save_snapshot(&lib.platforms, &lib.collections);
356
357 let footer = if msg.warnings.is_empty() {
358 if digest_changed {
359 Some("Collection metadata updated.".into())
360 } else {
361 Some("Collection metadata already up to date.".into())
362 }
363 } else {
364 let w = msg.warnings.join(" | ");
365 let short: String = if w.chars().count() > 160 {
366 let prefix: String = w.chars().take(157).collect();
367 format!("{prefix}…")
368 } else {
369 w
370 };
371 Some(format!("Partial refresh: {}", short))
372 };
373 lib.set_metadata_footer(footer);
374
375 if selection_changed && lib.list_len() > 0 {
376 lib.clear_roms();
377 let key = lib.cache_key();
378 let expected = lib.expected_rom_count();
379 let req = Self::selected_rom_request_for_library(lib);
380 lib.set_rom_loading(expected > 0);
381 self.deferred_load_roms =
382 Some((key, req, expected, "refresh_selection", Instant::now()));
383 }
384 self.queue_collection_prefetches_from_screen(1, "refresh_warmup");
385 }
386
387 fn queue_collection_prefetches_from_screen(&mut self, radius: usize, _reason: &'static str) {
388 let AppScreen::LibraryBrowse(ref lib) = self.screen else {
389 return;
390 };
391 for (key, req, expected) in lib.collection_prefetch_candidates(radius) {
392 if self.rom_cache.get_valid(&key, expected).is_some() {
393 continue;
394 }
395 if self.collection_prefetch_queued_keys.contains(&key)
396 || self.collection_prefetch_inflight_keys.contains(&key)
397 {
398 continue;
399 }
400 self.collection_prefetch_queued_keys.insert(key.clone());
401 self.collection_prefetch_queue
402 .push_back((key, req, expected));
403 }
404 }
405
406 fn drive_collection_prefetch_scheduler(&mut self) {
407 const PREFETCH_MAX_INFLIGHT: usize = 2;
408 while self.collection_prefetch_inflight_keys.len() < PREFETCH_MAX_INFLIGHT {
409 let Some((key, req, expected)) = self.collection_prefetch_queue.pop_back() else {
410 break;
411 };
412 self.collection_prefetch_queued_keys.remove(&key);
413 self.collection_prefetch_inflight_keys.insert(key.clone());
414 let tx = self.collection_prefetch_tx.clone();
415 let client = self.client.clone();
416 tokio::spawn(async move {
417 let result = Self::fetch_roms_full(client, req).await;
418 let (roms, warning) = match result {
419 Ok(list) => (Some(list), None),
420 Err(e) => (None, Some(format!("Collection prefetch failed: {e:#}"))),
421 };
422 let _ = tx.send(CollectionPrefetchDone {
423 key,
424 expected,
425 roms,
426 warning,
427 });
428 });
429 }
430 }
431
432 fn poll_collection_prefetch_results(&mut self) {
433 loop {
434 match self.collection_prefetch_rx.try_recv() {
435 Ok(done) => {
436 self.collection_prefetch_inflight_keys.remove(&done.key);
437 if let Some(roms) = done.roms {
438 self.rom_cache.insert(done.key, roms, done.expected);
439 } else if let Some(warning) = done.warning {
440 tracing::debug!("{warning}");
441 }
442 }
443 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
444 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
445 }
446 }
447 }
448
449 pub fn set_error(&mut self, err: anyhow::Error) {
450 self.global_error = Some(format!("{:#}", err));
451 }
452
453 pub async fn run(&mut self) -> Result<()> {
463 enable_raw_mode()?;
464 let mut stdout = std::io::stdout();
465 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
466 let backend = CrosstermBackend::new(stdout);
467 let mut terminal = Terminal::new(backend)?;
468
469 loop {
470 self.poll_background_tasks();
471 if self
472 .startup_splash
473 .as_ref()
474 .is_some_and(|s| s.should_auto_dismiss())
475 {
476 self.startup_splash = None;
477 }
478 terminal.draw(|f| self.render(f))?;
481
482 if event::poll(Duration::from_millis(100))? {
485 if let Event::Key(key) = event::read()? {
486 if Self::is_force_quit_key(&key) {
487 break;
488 }
489 if key.kind == KeyEventKind::Press && self.handle_key(key.code).await? {
490 break;
491 }
492 }
493 }
494
495 if let Some((key, req, expected, context, started)) = self.deferred_load_roms.take() {
499 if let Some(ref k) = key {
501 if let Some(cached) = self.rom_cache.get_valid(k, expected) {
502 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
503 lib.set_roms(cached.clone());
504 lib.set_rom_loading(false);
505 tracing::debug!(
506 "rom-list-render context={} latency_ms={} (cache_hit)",
507 context,
508 started.elapsed().as_millis()
509 );
510 }
511 continue;
512 }
513 }
514
515 if started.elapsed() < std::time::Duration::from_millis(250) {
517 self.deferred_load_roms = Some((key, req, expected, context, started));
519 continue;
520 }
521
522 self.rom_load_gen = self.rom_load_gen.saturating_add(1);
523 let gen = self.rom_load_gen;
524 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
525 lib.set_rom_loading(expected > 0);
526 }
527 if expected == 0 {
528 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
529 lib.set_rom_loading(false);
530 }
531 continue;
532 }
533
534 let Some(r) = req else {
535 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
536 lib.set_rom_loading(false);
537 }
538 continue;
539 };
540 let client = self.client.clone();
541 let tx = self.rom_load_tx.clone();
542
543 if let Some(task) = self.rom_load_task.take() {
544 task.abort();
545 }
546
547 self.rom_load_task = Some(tokio::spawn(async move {
548 let result = Self::fetch_roms_full(client, r)
549 .await
550 .map_err(|e| format!("{e:#}"));
551 let _ = tx.send(RomLoadDone {
552 gen,
553 key,
554 expected,
555 result,
556 context,
557 started,
558 });
559 }));
560 }
561 }
562
563 disable_raw_mode()?;
564 execute!(
565 terminal.backend_mut(),
566 LeaveAlternateScreen,
567 DisableMouseCapture
568 )?;
569 terminal.show_cursor()?;
570 Ok(())
571 }
572
573 async fn fetch_roms_full(client: RommClient, req: GetRoms) -> Result<RomList> {
578 let mut roms = client.call(&req).await?;
579 let total = roms.total;
580 let ceiling = 20000;
581 while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
582 let mut next_req = req.clone();
583 next_req.offset = Some(roms.items.len() as u32);
584 let next_batch = client.call(&next_req).await?;
585 if next_batch.items.is_empty() {
586 break;
587 }
588 roms.items.extend(next_batch.items);
589 }
590 Ok(roms)
591 }
592
593 pub async fn handle_key(&mut self, key: KeyCode) -> Result<bool> {
598 if self.global_error.is_some() {
599 if key == KeyCode::Esc || key == KeyCode::Enter {
600 self.global_error = None;
601 }
602 return Ok(false);
603 }
604
605 if self.startup_splash.is_some() {
606 self.startup_splash = None;
607 return Ok(false);
608 }
609
610 if self.show_keyboard_help {
611 if matches!(
612 key,
613 KeyCode::Esc | KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('?')
614 ) {
615 self.show_keyboard_help = false;
616 }
617 return Ok(false);
618 }
619
620 if key == KeyCode::F(1) {
621 self.show_keyboard_help = true;
622 return Ok(false);
623 }
624 if key == KeyCode::Char('?') && allows_global_question_help(&self.screen) {
625 self.show_keyboard_help = true;
626 return Ok(false);
627 }
628
629 if key == KeyCode::Char('d') && !blocks_global_d_shortcut(&self.screen) {
631 self.toggle_download_screen();
632 return Ok(false);
633 }
634
635 match &self.screen {
636 AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
637 AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
638 AppScreen::Search(_) => self.handle_search(key).await,
639 AppScreen::Settings(_) => self.handle_settings(key).await,
640 AppScreen::Browse(_) => self.handle_browse(key),
641 AppScreen::Execute(_) => self.handle_execute(key).await,
642 AppScreen::Result(_) => self.handle_result(key),
643 AppScreen::ResultDetail(_) => self.handle_result_detail(key),
644 AppScreen::GameDetail(_) => self.handle_game_detail(key),
645 AppScreen::Download(_) => self.handle_download(key),
646 AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
647 }
648 }
649
650 fn toggle_download_screen(&mut self) {
653 let current =
654 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
655 match current {
656 AppScreen::Download(_) => {
657 self.screen = self
658 .screen_before_download
659 .take()
660 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
661 }
662 other => {
663 self.screen_before_download = Some(other);
664 self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
665 }
666 }
667 }
668
669 fn handle_download(&mut self, key: KeyCode) -> Result<bool> {
670 if key == KeyCode::Esc || key == KeyCode::Char('d') {
671 self.screen = self
672 .screen_before_download
673 .take()
674 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
675 }
676 Ok(false)
677 }
678
679 async fn handle_main_menu(&mut self, key: KeyCode) -> Result<bool> {
682 let menu = match &mut self.screen {
683 AppScreen::MainMenu(m) => m,
684 _ => return Ok(false),
685 };
686 match key {
687 KeyCode::Up | KeyCode::Char('k') => menu.previous(),
688 KeyCode::Down | KeyCode::Char('j') => menu.next(),
689 KeyCode::Enter => match menu.selected {
690 0 => {
691 let start = Instant::now();
692 let snap = startup_library_snapshot::load_snapshot();
693 let (platforms, collections, from_disk) = match snap {
694 Some(s) => (s.platforms, s.collections, true),
695 None => (Vec::new(), Vec::new(), false),
696 };
697 let mut lib = LibraryBrowseScreen::new(platforms, collections);
698 if from_disk && lib.list_len() > 0 {
699 lib.set_metadata_footer(Some(
700 "Refreshing library metadata in background…".into(),
701 ));
702 } else if lib.list_len() == 0 {
703 lib.set_metadata_footer(Some("Loading library metadata…".into()));
704 }
705 if lib.list_len() > 0 {
706 let key = lib.cache_key();
707 let expected = lib.expected_rom_count();
708 let req = Self::selected_rom_request_for_library(&lib);
709 lib.set_rom_loading(expected > 0);
710 self.deferred_load_roms = Some((
711 key,
712 req,
713 expected,
714 "startup_first_selection",
715 Instant::now(),
716 ));
717 }
718 self.screen = AppScreen::LibraryBrowse(lib);
719 self.spawn_library_metadata_refresh();
720 tracing::debug!(
721 "library-open latency_ms={} snapshot_hit={}",
722 start.elapsed().as_millis(),
723 from_disk
724 );
725 }
726 1 => self.screen = AppScreen::Search(SearchScreen::new()),
727 2 => {
728 self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
729 self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
730 }
731 3 => {
732 self.screen = AppScreen::Settings(SettingsScreen::new(
733 &self.config,
734 self.server_version.as_deref(),
735 ))
736 }
737 4 => return Ok(true),
738 _ => {}
739 },
740 KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
741 _ => {}
742 }
743 Ok(false)
744 }
745
746 async fn handle_library_browse(&mut self, key: KeyCode) -> Result<bool> {
749 use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
750
751 let lib = match &mut self.screen {
752 AppScreen::LibraryBrowse(l) => l,
753 _ => return Ok(false),
754 };
755
756 if lib.view_mode == LibraryViewMode::List {
758 if let Some(mode) = lib.list_search.mode {
759 let old_key = lib.cache_key();
760 match key {
761 KeyCode::Esc => lib.clear_list_search(),
762 KeyCode::Backspace => lib.delete_list_search_char(),
763 KeyCode::Char(c) => lib.add_list_search_char(c),
764 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.list_jump_match(true),
765 KeyCode::Enter => lib.commit_list_filter_bar(),
766 _ => {}
767 }
768 let new_key = lib.cache_key();
769 if old_key != new_key && lib.list_len() > 0 {
770 lib.clear_roms();
771 let expected = lib.expected_rom_count();
772 if expected > 0 {
773 let req = Self::selected_rom_request_for_library(lib);
774 lib.set_rom_loading(true);
775 self.deferred_load_roms =
776 Some((new_key, req, expected, "search_filter", Instant::now()));
777 } else {
778 lib.set_rom_loading(false);
779 self.deferred_load_roms = None;
780 }
781 }
782 return Ok(false);
783 }
784 }
785
786 if lib.view_mode == LibraryViewMode::Roms {
788 if let Some(mode) = lib.rom_search.mode {
789 match key {
790 KeyCode::Esc => lib.clear_rom_search(),
791 KeyCode::Backspace => lib.delete_rom_search_char(),
792 KeyCode::Char(c) => lib.add_rom_search_char(c),
793 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_rom_match(true),
794 KeyCode::Enter => lib.commit_rom_filter_bar(),
795 _ => {}
796 }
797 return Ok(false);
798 }
799 }
800
801 match key {
802 KeyCode::Up | KeyCode::Char('k') => {
803 if lib.view_mode == LibraryViewMode::List {
804 lib.list_previous();
805 if lib.list_len() > 0 {
806 lib.clear_roms(); let key = lib.cache_key();
808 let expected = lib.expected_rom_count();
809 if expected > 0 {
810 let req = Self::selected_rom_request_for_library(lib);
811 lib.set_rom_loading(true);
812 self.deferred_load_roms =
813 Some((key, req, expected, "list_move_up", Instant::now()));
814 } else {
815 lib.set_rom_loading(false);
816 self.deferred_load_roms = None;
817 }
818 if lib.subsection
819 == super::screens::library_browse::LibrarySubsection::ByCollection
820 {
821 tracing::debug!("collections-selection move=up expected={expected}");
822 self.queue_collection_prefetches_from_screen(1, "move_up");
823 }
824 }
825 } else {
826 lib.rom_previous();
827 }
828 }
829 KeyCode::Down | KeyCode::Char('j') => {
830 if lib.view_mode == LibraryViewMode::List {
831 lib.list_next();
832 if lib.list_len() > 0 {
833 lib.clear_roms(); let key = lib.cache_key();
835 let expected = lib.expected_rom_count();
836 if expected > 0 {
837 let req = Self::selected_rom_request_for_library(lib);
838 lib.set_rom_loading(true);
839 self.deferred_load_roms =
840 Some((key, req, expected, "list_move_down", Instant::now()));
841 } else {
842 lib.set_rom_loading(false);
843 self.deferred_load_roms = None;
844 }
845 if lib.subsection
846 == super::screens::library_browse::LibrarySubsection::ByCollection
847 {
848 tracing::debug!("collections-selection move=down expected={expected}");
849 self.queue_collection_prefetches_from_screen(1, "move_down");
850 }
851 }
852 } else {
853 lib.rom_next();
854 }
855 }
856 KeyCode::Left | KeyCode::Char('h') if lib.view_mode == LibraryViewMode::Roms => {
857 lib.back_to_list();
858 }
859 KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
860 KeyCode::Tab => {
861 if lib.view_mode == LibraryViewMode::List {
862 lib.switch_view();
863 } else {
864 lib.switch_view(); }
866 }
867 KeyCode::Char('/') => match lib.view_mode {
868 LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Filter),
869 LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Filter),
870 },
871 KeyCode::Char('f') => match lib.view_mode {
872 LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Jump),
873 LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Jump),
874 },
875 KeyCode::Enter => {
876 if lib.view_mode == LibraryViewMode::List {
877 lib.switch_view();
878 } else if let Some((primary, others)) = lib.get_selected_group() {
879 let lib_screen = std::mem::replace(
880 &mut self.screen,
881 AppScreen::MainMenu(MainMenuScreen::new()),
882 );
883 if let AppScreen::LibraryBrowse(l) = lib_screen {
884 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
885 primary,
886 others,
887 GameDetailPrevious::Library(l),
888 self.downloads.shared(),
889 )));
890 }
891 }
892 }
893 KeyCode::Char('t') => {
894 lib.switch_subsection();
895 if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 {
898 let key = lib.cache_key();
899 let expected = lib.expected_rom_count();
900 if expected > 0 {
901 let req = Self::selected_rom_request_for_library(lib);
902 lib.set_rom_loading(true);
903 self.deferred_load_roms =
904 Some((key, req, expected, "switch_subsection", Instant::now()));
905 } else {
906 lib.set_rom_loading(false);
907 self.deferred_load_roms = None;
908 }
909 }
910 if lib.subsection == super::screens::library_browse::LibrarySubsection::ByCollection
911 {
912 tracing::debug!("collections-subsection entered");
913 self.queue_collection_prefetches_from_screen(1, "enter_collections");
914 }
915 }
916 KeyCode::Esc => {
917 if lib.view_mode == LibraryViewMode::Roms {
918 if lib.rom_search.filter_browsing {
919 lib.clear_rom_search();
920 } else {
921 lib.back_to_list();
922 }
923 } else if lib.list_search.filter_browsing {
924 lib.clear_list_search();
925 } else {
926 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
927 }
928 }
929 KeyCode::Char('q') => return Ok(true),
930 _ => {}
931 }
932 Ok(false)
933 }
934
935 async fn handle_search(&mut self, key: KeyCode) -> Result<bool> {
938 let search = match &mut self.screen {
939 AppScreen::Search(s) => s,
940 _ => return Ok(false),
941 };
942 match key {
943 KeyCode::Backspace => search.delete_char(),
944 KeyCode::Left => search.cursor_left(),
945 KeyCode::Right => search.cursor_right(),
946 KeyCode::Up => search.previous(),
947 KeyCode::Down => search.next(),
948 KeyCode::Char(c) => search.add_char(c),
949 KeyCode::Enter => {
950 if search.query.is_empty() {
951 } else if search.result_groups.is_some() && search.results_match_current_query() {
953 if let Some((primary, others)) = search.get_selected_group() {
954 let prev = std::mem::replace(
955 &mut self.screen,
956 AppScreen::MainMenu(MainMenuScreen::new()),
957 );
958 if let AppScreen::Search(s) = prev {
959 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
960 primary,
961 others,
962 GameDetailPrevious::Search(s),
963 self.downloads.shared(),
964 )));
965 }
966 }
967 } else {
968 let req = GetRoms {
969 search_term: Some(search.query.clone()),
970 limit: Some(50),
971 ..Default::default()
972 };
973 search.loading = true;
974 if let Some(task) = self.search_load_task.take() {
975 task.abort();
976 }
977 let client = self.client.clone();
978 let tx = self.search_load_tx.clone();
979 self.search_load_task = Some(tokio::spawn(async move {
980 let result = client.call(&req).await.map_err(|e| format!("{e:#}"));
981 let _ = tx.send(SearchLoadDone { result });
982 }));
983 }
984 }
985 KeyCode::Esc => {
986 if search.results.is_some() {
987 search.clear_results();
988 } else {
989 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
990 }
991 }
992 _ => {}
993 }
994 Ok(false)
995 }
996
997 async fn refresh_settings_server_version(&mut self) -> Result<()> {
1000 let (base_url, download_dir, use_https, verbose, auth) = {
1001 let settings = match &self.screen {
1002 AppScreen::Settings(s) => s,
1003 _ => return Ok(()),
1004 };
1005 let mut base_url = normalize_romm_origin(settings.base_url.trim());
1006 if settings.use_https && base_url.starts_with("http://") {
1007 base_url = base_url.replace("http://", "https://");
1008 }
1009 if !settings.use_https && base_url.starts_with("https://") {
1010 base_url = base_url.replace("https://", "http://");
1011 }
1012 (
1013 base_url,
1014 settings.download_dir.clone(),
1015 settings.use_https,
1016 self.client.verbose(),
1017 self.config.auth.clone(),
1018 )
1019 };
1020 let cfg = Config {
1021 base_url,
1022 download_dir,
1023 use_https,
1024 auth,
1025 };
1026 let client = match RommClient::new(&cfg, verbose) {
1027 Ok(c) => c,
1028 Err(_) => {
1029 if let AppScreen::Settings(s) = &mut self.screen {
1030 s.server_version = "unavailable (invalid URL or client error)".to_string();
1031 self.server_version = None;
1032 }
1033 return Ok(());
1034 }
1035 };
1036 let ver = client.rom_server_version_from_heartbeat().await;
1037 if let AppScreen::Settings(s) = &mut self.screen {
1038 match ver {
1039 Some(v) => {
1040 s.server_version = v.clone();
1041 self.server_version = Some(v);
1042 }
1043 None => {
1044 s.server_version = "unavailable (heartbeat failed)".to_string();
1045 self.server_version = None;
1046 }
1047 }
1048 }
1049 Ok(())
1050 }
1051
1052 async fn handle_settings(&mut self, key: KeyCode) -> Result<bool> {
1053 let settings = match &mut self.screen {
1054 AppScreen::Settings(s) => s,
1055 _ => return Ok(false),
1056 };
1057
1058 if settings.editing {
1059 match key {
1060 KeyCode::Enter => {
1061 let idx = settings.selected_index;
1062 settings.save_edit();
1063 if idx == 0 {
1064 self.refresh_settings_server_version().await?;
1065 }
1066 }
1067 KeyCode::Esc => settings.cancel_edit(),
1068 KeyCode::Backspace => settings.delete_char(),
1069 KeyCode::Left => settings.move_cursor_left(),
1070 KeyCode::Right => settings.move_cursor_right(),
1071 KeyCode::Char(c) => settings.add_char(c),
1072 _ => {}
1073 }
1074 return Ok(false);
1075 }
1076
1077 match key {
1078 KeyCode::Up | KeyCode::Char('k') => settings.previous(),
1079 KeyCode::Down | KeyCode::Char('j') => settings.next(),
1080 KeyCode::Enter => {
1081 if settings.selected_index == 3 {
1082 self.screen =
1083 AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
1084 } else {
1085 let toggle_https = settings.selected_index == 2;
1086 settings.enter_edit();
1087 if toggle_https {
1088 self.refresh_settings_server_version().await?;
1089 }
1090 }
1091 }
1092 KeyCode::Char('s' | 'S') => {
1093 use crate::config::persist_user_config;
1095 let auth = auth_for_persist_merge(self.config.auth.clone());
1096 if let Err(e) = persist_user_config(
1097 &settings.base_url,
1098 &settings.download_dir,
1099 settings.use_https,
1100 auth,
1101 ) {
1102 settings.message = Some((format!("Error saving: {e}"), Color::Red));
1103 } else {
1104 settings.message = Some(("Saved to config.json".to_string(), Color::Green));
1105 self.config.base_url = settings.base_url.clone();
1107 self.config.download_dir = settings.download_dir.clone();
1108 self.config.use_https = settings.use_https;
1109 if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
1111 self.client = new_client;
1112 }
1113 }
1114 }
1115 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
1116 KeyCode::Char('q') => return Ok(true),
1117 _ => {}
1118 }
1119 Ok(false)
1120 }
1121
1122 fn handle_browse(&mut self, key: KeyCode) -> Result<bool> {
1125 use super::screens::browse::ViewMode;
1126
1127 let browse = match &mut self.screen {
1128 AppScreen::Browse(b) => b,
1129 _ => return Ok(false),
1130 };
1131 match key {
1132 KeyCode::Up | KeyCode::Char('k') => browse.previous(),
1133 KeyCode::Down | KeyCode::Char('j') => browse.next(),
1134 KeyCode::Left | KeyCode::Char('h') if browse.view_mode == ViewMode::Endpoints => {
1135 browse.switch_view();
1136 }
1137 KeyCode::Right | KeyCode::Char('l') if browse.view_mode == ViewMode::Sections => {
1138 browse.switch_view();
1139 }
1140 KeyCode::Tab => browse.switch_view(),
1141 KeyCode::Enter => {
1142 if browse.view_mode == ViewMode::Endpoints {
1143 if let Some(ep) = browse.get_selected_endpoint() {
1144 self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
1145 }
1146 } else {
1147 browse.switch_view();
1148 }
1149 }
1150 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
1151 _ => {}
1152 }
1153 Ok(false)
1154 }
1155
1156 async fn handle_execute(&mut self, key: KeyCode) -> Result<bool> {
1159 let execute = match &mut self.screen {
1160 AppScreen::Execute(e) => e,
1161 _ => return Ok(false),
1162 };
1163 match key {
1164 KeyCode::Tab => execute.next_field(),
1165 KeyCode::BackTab => execute.previous_field(),
1166 KeyCode::Char(c) => execute.add_char_to_focused(c),
1167 KeyCode::Backspace => execute.delete_char_from_focused(),
1168 KeyCode::Enter => {
1169 let endpoint = execute.endpoint.clone();
1170 let query = execute.get_query_params();
1171 let body = if endpoint.has_body && !execute.body_text.is_empty() {
1172 Some(serde_json::from_str(&execute.body_text)?)
1173 } else {
1174 None
1175 };
1176 let resolved_path =
1177 match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
1178 Ok(p) => p,
1179 Err(e) => {
1180 self.screen = AppScreen::Result(ResultScreen::new(
1181 serde_json::json!({ "error": format!("{e}") }),
1182 None,
1183 None,
1184 ));
1185 return Ok(false);
1186 }
1187 };
1188 match self
1189 .client
1190 .request_json(&endpoint.method, &resolved_path, &query, body)
1191 .await
1192 {
1193 Ok(result) => {
1194 self.screen = AppScreen::Result(ResultScreen::new(
1195 result,
1196 Some(&endpoint.method),
1197 Some(resolved_path.as_str()),
1198 ));
1199 }
1200 Err(e) => {
1201 self.screen = AppScreen::Result(ResultScreen::new(
1202 serde_json::json!({ "error": format!("{e}") }),
1203 None,
1204 None,
1205 ));
1206 }
1207 }
1208 }
1209 KeyCode::Esc => {
1210 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
1211 }
1212 _ => {}
1213 }
1214 Ok(false)
1215 }
1216
1217 fn handle_result(&mut self, key: KeyCode) -> Result<bool> {
1220 use super::screens::result::ResultViewMode;
1221
1222 let result = match &mut self.screen {
1223 AppScreen::Result(r) => r,
1224 _ => return Ok(false),
1225 };
1226 match key {
1227 KeyCode::Up | KeyCode::Char('k') => {
1228 if result.view_mode == ResultViewMode::Json {
1229 result.scroll_up(1);
1230 } else {
1231 result.table_previous();
1232 }
1233 }
1234 KeyCode::Down => {
1235 if result.view_mode == ResultViewMode::Json {
1236 result.scroll_down(1);
1237 } else {
1238 result.table_next();
1239 }
1240 }
1241 KeyCode::Char('j') if result.view_mode == ResultViewMode::Json => {
1242 result.scroll_down(1);
1243 }
1244 KeyCode::PageUp => {
1245 if result.view_mode == ResultViewMode::Table {
1246 result.table_page_up();
1247 } else {
1248 result.scroll_up(10);
1249 }
1250 }
1251 KeyCode::PageDown => {
1252 if result.view_mode == ResultViewMode::Table {
1253 result.table_page_down();
1254 } else {
1255 result.scroll_down(10);
1256 }
1257 }
1258 KeyCode::Char('t') if result.table_row_count > 0 => {
1259 result.switch_view_mode();
1260 }
1261 KeyCode::Enter
1262 if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 =>
1263 {
1264 if let Some(item) = result.get_selected_item_value() {
1265 let prev = std::mem::replace(
1266 &mut self.screen,
1267 AppScreen::MainMenu(MainMenuScreen::new()),
1268 );
1269 if let AppScreen::Result(rs) = prev {
1270 self.screen = AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
1271 }
1272 }
1273 }
1274 KeyCode::Esc => {
1275 result.clear_message();
1276 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
1277 }
1278 KeyCode::Char('q') => return Ok(true),
1279 _ => {}
1280 }
1281 Ok(false)
1282 }
1283
1284 fn handle_result_detail(&mut self, key: KeyCode) -> Result<bool> {
1287 let detail = match &mut self.screen {
1288 AppScreen::ResultDetail(d) => d,
1289 _ => return Ok(false),
1290 };
1291 match key {
1292 KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
1293 KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
1294 KeyCode::PageUp => detail.scroll_up(10),
1295 KeyCode::PageDown => detail.scroll_down(10),
1296 KeyCode::Char('o') => detail.open_image_url(),
1297 KeyCode::Esc => {
1298 detail.clear_message();
1299 let prev =
1300 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1301 if let AppScreen::ResultDetail(d) = prev {
1302 self.screen = AppScreen::Result(d.parent);
1303 }
1304 }
1305 KeyCode::Char('q') => return Ok(true),
1306 _ => {}
1307 }
1308 Ok(false)
1309 }
1310
1311 fn handle_game_detail(&mut self, key: KeyCode) -> Result<bool> {
1314 let detail = match &mut self.screen {
1315 AppScreen::GameDetail(d) => d,
1316 _ => return Ok(false),
1317 };
1318
1319 if !detail.download_completion_acknowledged {
1322 if let Ok(list) = detail.downloads.lock() {
1323 let has_completed = list.iter().any(|j| {
1324 j.rom_id == detail.rom.id
1325 && matches!(
1326 j.status,
1327 crate::core::download::DownloadStatus::Done
1328 | crate::core::download::DownloadStatus::Error(_)
1329 )
1330 });
1331 let is_still_downloading = list.iter().any(|j| {
1332 j.rom_id == detail.rom.id
1333 && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
1334 });
1335 if has_completed && !is_still_downloading {
1337 detail.download_completion_acknowledged = true;
1338 }
1339 }
1340 }
1341
1342 match key {
1343 KeyCode::Enter if !detail.has_started_download => {
1346 detail.has_started_download = true;
1347 self.downloads
1348 .start_download(&detail.rom, self.client.clone());
1349 }
1350 KeyCode::Char('o') => detail.open_cover(),
1351 KeyCode::Char('m') => detail.toggle_technical(),
1352 KeyCode::Esc => {
1353 detail.clear_message();
1354 let prev =
1355 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1356 if let AppScreen::GameDetail(g) = prev {
1357 self.screen = match g.previous {
1358 GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(l),
1359 GameDetailPrevious::Search(s) => AppScreen::Search(s),
1360 };
1361 }
1362 }
1363 KeyCode::Char('q') => return Ok(true),
1364 _ => {}
1365 }
1366 Ok(false)
1367 }
1368
1369 async fn handle_setup_wizard(&mut self, key: KeyCode) -> Result<bool> {
1372 let wizard = match &mut self.screen {
1373 AppScreen::SetupWizard(w) => w,
1374 _ => return Ok(false),
1375 };
1376
1377 let event = crossterm::event::KeyEvent::new(key, crossterm::event::KeyModifiers::empty());
1379 if wizard.handle_key(event)? {
1380 self.screen = AppScreen::Settings(SettingsScreen::new(
1382 &self.config,
1383 self.server_version.as_deref(),
1384 ));
1385 return Ok(false);
1386 }
1387
1388 if wizard.testing {
1389 let result = wizard.try_connect_and_persist(self.client.verbose()).await;
1390 wizard.testing = false;
1391 match result {
1392 Ok(cfg) => {
1393 let auth_ok = cfg.auth.is_some();
1394 self.config = cfg;
1395 if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
1396 self.client = new_client;
1397 }
1398 let mut settings =
1399 SettingsScreen::new(&self.config, self.server_version.as_deref());
1400 if auth_ok {
1401 settings.message = Some((
1402 "Authentication updated successfully".to_string(),
1403 Color::Green,
1404 ));
1405 } else {
1406 settings.message = Some((
1407 "Saved configuration but credentials could not be loaded from the OS keyring (see logs)."
1408 .to_string(),
1409 Color::Yellow,
1410 ));
1411 }
1412 self.screen = AppScreen::Settings(settings);
1413 }
1414 Err(e) => {
1415 wizard.error = Some(format!("{e:#}"));
1416 }
1417 }
1418 }
1419 Ok(false)
1420 }
1421
1422 fn render(&mut self, f: &mut ratatui::Frame) {
1427 let area = f.size();
1428 if let Some(ref splash) = self.startup_splash {
1429 connected_splash::render(f, area, splash);
1430 return;
1431 }
1432 match &mut self.screen {
1433 AppScreen::MainMenu(menu) => menu.render(f, area),
1434 AppScreen::LibraryBrowse(lib) => lib.render(f, area),
1435 AppScreen::Search(search) => {
1436 search.render(f, area);
1437 if let Some((x, y)) = search.cursor_position(area) {
1438 f.set_cursor(x, y);
1439 }
1440 }
1441 AppScreen::Settings(settings) => {
1442 settings.render(f, area);
1443 if let Some((x, y)) = settings.cursor_position(area) {
1444 f.set_cursor(x, y);
1445 }
1446 }
1447 AppScreen::Browse(browse) => browse.render(f, area),
1448 AppScreen::Execute(execute) => {
1449 execute.render(f, area);
1450 if let Some((x, y)) = execute.cursor_position(area) {
1451 f.set_cursor(x, y);
1452 }
1453 }
1454 AppScreen::Result(result) => result.render(f, area),
1455 AppScreen::ResultDetail(detail) => detail.render(f, area),
1456 AppScreen::GameDetail(detail) => detail.render(f, area),
1457 AppScreen::Download(d) => d.render(f, area),
1458 AppScreen::SetupWizard(wizard) => {
1459 wizard.render(f, area);
1460 if let Some((x, y)) = wizard.cursor_pos(area) {
1461 f.set_cursor(x, y);
1462 }
1463 }
1464 }
1465
1466 if self.show_keyboard_help {
1467 keyboard_help::render_keyboard_help(f, area);
1468 }
1469
1470 if let Some(ref err) = self.global_error {
1471 let popup_area = ratatui::layout::Rect {
1472 x: area.width.saturating_sub(60) / 2,
1473 y: area.height.saturating_sub(10) / 2,
1474 width: 60.min(area.width),
1475 height: 10.min(area.height),
1476 };
1477 f.render_widget(ratatui::widgets::Clear, popup_area);
1478 let block = ratatui::widgets::Block::default()
1479 .title("Error")
1480 .borders(ratatui::widgets::Borders::ALL)
1481 .style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
1482 let text = format!("{}\n\nPress Esc to dismiss", err);
1483 let paragraph = ratatui::widgets::Paragraph::new(text)
1484 .block(block)
1485 .wrap(ratatui::widgets::Wrap { trim: true });
1486 f.render_widget(paragraph, popup_area);
1487 }
1488 }
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493 use super::*;
1494 use crate::config::Config;
1495 use crate::tui::openapi::EndpointRegistry;
1496 use crate::tui::screens::library_browse::LibraryBrowseScreen;
1497 use crate::types::Platform;
1498 use crossterm::event::{KeyEvent, KeyModifiers};
1499 use serde_json::json;
1500
1501 fn platform(id: u64, name: &str, rom_count: u64) -> Platform {
1502 serde_json::from_value(json!({
1503 "id": id,
1504 "slug": format!("p{id}"),
1505 "fs_slug": format!("p{id}"),
1506 "rom_count": rom_count,
1507 "name": name,
1508 "igdb_slug": null,
1509 "moby_slug": null,
1510 "hltb_slug": null,
1511 "custom_name": null,
1512 "igdb_id": null,
1513 "sgdb_id": null,
1514 "moby_id": null,
1515 "launchbox_id": null,
1516 "ss_id": null,
1517 "ra_id": null,
1518 "hasheous_id": null,
1519 "tgdb_id": null,
1520 "flashpoint_id": null,
1521 "category": null,
1522 "generation": null,
1523 "family_name": null,
1524 "family_slug": null,
1525 "url": null,
1526 "url_logo": null,
1527 "firmware": [],
1528 "aspect_ratio": null,
1529 "created_at": "",
1530 "updated_at": "",
1531 "fs_size_bytes": 0,
1532 "is_unidentified": false,
1533 "is_identified": true,
1534 "missing_from_fs": false,
1535 "display_name": null
1536 }))
1537 .expect("valid platform fixture")
1538 }
1539
1540 fn app_with_library(platforms: Vec<Platform>) -> App {
1541 let config = Config {
1542 base_url: "http://127.0.0.1:9".into(),
1543 download_dir: "/tmp".into(),
1544 use_https: false,
1545 auth: None,
1546 };
1547 let client = RommClient::new(&config, false).expect("client");
1548 let mut app = App::new(client, config, EndpointRegistry::default(), None, None);
1549 app.screen = AppScreen::LibraryBrowse(LibraryBrowseScreen::new(platforms, vec![]));
1550 app
1551 }
1552
1553 #[tokio::test]
1554 async fn list_move_to_zero_rom_selection_does_not_queue_deferred_load() {
1555 let mut app = app_with_library(vec![platform(1, "HasRoms", 5), platform(2, "Empty", 0)]);
1556
1557 assert!(!app.handle_key(KeyCode::Down).await.expect("key handled"));
1558 assert!(
1559 app.deferred_load_roms.is_none(),
1560 "selection move to zero-rom platform should not queue deferred ROM load"
1561 );
1562 }
1563
1564 #[test]
1565 fn ctrl_c_is_treated_as_force_quit() {
1566 let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
1567 assert!(App::is_force_quit_key(&ctrl_c));
1568
1569 let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty());
1570 assert!(!App::is_force_quit_key(&plain_c));
1571 }
1572
1573 #[test]
1574 fn primary_rom_load_stale_gen_is_ignored() {
1575 assert!(!super::primary_rom_load_result_is_current(1, 2));
1576 assert!(super::primary_rom_load_result_is_current(3, 3));
1577 }
1578}