1use anyhow::Result;
14use crossterm::event::{
15 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
16};
17use crossterm::execute;
18use crossterm::terminal::{
19 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
20};
21use ratatui::backend::CrosstermBackend;
22use ratatui::Terminal;
23use std::time::Duration;
24
25use crate::client::RommClient;
26use crate::config::Config;
27use crate::core::cache::{RomCache, RomCacheKey};
28use crate::core::download::DownloadManager;
29use crate::endpoints::{collections::ListCollections, platforms::ListPlatforms, roms::GetRoms};
30use crate::types::RomList;
31
32use super::openapi::{resolve_path_template, EndpointRegistry};
33use super::screens::connected_splash::{self, StartupSplash};
34use super::screens::{
35 BrowseScreen, DownloadScreen, ExecuteScreen, GameDetailPrevious, GameDetailScreen,
36 LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen, SearchScreen,
37 SettingsScreen,
38};
39
40pub enum AppScreen {
49 MainMenu(MainMenuScreen),
50 LibraryBrowse(LibraryBrowseScreen),
51 Search(SearchScreen),
52 Settings(SettingsScreen),
53 Browse(BrowseScreen),
54 Execute(ExecuteScreen),
55 Result(ResultScreen),
56 ResultDetail(ResultDetailScreen),
57 GameDetail(Box<GameDetailScreen>),
58 Download(DownloadScreen),
59}
60
61pub struct App {
70 screen: AppScreen,
71 client: RommClient,
72 config: Config,
73 registry: EndpointRegistry,
74 server_version: Option<String>,
76 rom_cache: RomCache,
77 downloads: DownloadManager,
78 screen_before_download: Option<AppScreen>,
80 deferred_load_roms: Option<(Option<RomCacheKey>, Option<GetRoms>, u64)>,
82 startup_splash: Option<StartupSplash>,
84}
85
86impl App {
87 pub fn new(
89 client: RommClient,
90 config: Config,
91 registry: EndpointRegistry,
92 server_version: Option<String>,
93 startup_splash: Option<StartupSplash>,
94 ) -> Self {
95 Self {
96 screen: AppScreen::MainMenu(MainMenuScreen::new()),
97 client,
98 config,
99 registry,
100 server_version,
101 rom_cache: RomCache::load(),
102 downloads: DownloadManager::new(),
103 screen_before_download: None,
104 deferred_load_roms: None,
105 startup_splash,
106 }
107 }
108
109 pub async fn run(&mut self) -> Result<()> {
119 enable_raw_mode()?;
120 let mut stdout = std::io::stdout();
121 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
122 let backend = CrosstermBackend::new(stdout);
123 let mut terminal = Terminal::new(backend)?;
124
125 loop {
126 if self
127 .startup_splash
128 .as_ref()
129 .is_some_and(|s| s.should_auto_dismiss())
130 {
131 self.startup_splash = None;
132 }
133 terminal.draw(|f| self.render(f))?;
136
137 if event::poll(Duration::from_millis(100))? {
140 if let Event::Key(key) = event::read()? {
141 if key.kind == KeyEventKind::Press && self.handle_key(key.code).await? {
142 break;
143 }
144 }
145 }
146
147 if let Some((key, req, expected)) = self.deferred_load_roms.take() {
152 if let Ok(Some(roms)) = self.load_roms_cached(key, req, expected).await {
153 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
154 lib.set_roms(roms);
155 }
156 }
157 }
158 }
159
160 disable_raw_mode()?;
161 execute!(
162 terminal.backend_mut(),
163 LeaveAlternateScreen,
164 DisableMouseCapture
165 )?;
166 terminal.show_cursor()?;
167 Ok(())
168 }
169
170 async fn load_roms_cached(
177 &mut self,
178 key: Option<RomCacheKey>,
179 req: Option<GetRoms>,
180 expected_count: u64,
181 ) -> Result<Option<RomList>> {
182 if let Some(k) = key {
184 if let Some(cached) = self.rom_cache.get_valid(&k, expected_count) {
185 return Ok(Some(cached.clone()));
186 }
187 }
188 if let Some(r) = req {
190 let mut roms = self.client.call(&r).await?;
191 let total = roms.total;
192 let ceiling = 20000;
193
194 while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
197 let mut next_req = r.clone();
198 next_req.offset = Some(roms.items.len() as u32);
199
200 let next_batch = self.client.call(&next_req).await?;
201 if next_batch.items.is_empty() {
202 break;
203 }
204 roms.items.extend(next_batch.items);
205 }
206
207 if let Some(k) = key {
208 self.rom_cache.insert(k, roms.clone(), expected_count); }
210 return Ok(Some(roms));
211 }
212 Ok(None)
213 }
214
215 async fn handle_key(&mut self, key: KeyCode) -> Result<bool> {
220 if self.startup_splash.is_some() {
221 self.startup_splash = None;
222 return Ok(false);
223 }
224
225 if key == KeyCode::Char('d') && !matches!(&self.screen, AppScreen::Search(_)) {
227 self.toggle_download_screen();
228 return Ok(false);
229 }
230
231 match &self.screen {
232 AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
233 AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
234 AppScreen::Search(_) => self.handle_search(key).await,
235 AppScreen::Settings(_) => self.handle_settings(key),
236 AppScreen::Browse(_) => self.handle_browse(key),
237 AppScreen::Execute(_) => self.handle_execute(key).await,
238 AppScreen::Result(_) => self.handle_result(key),
239 AppScreen::ResultDetail(_) => self.handle_result_detail(key),
240 AppScreen::GameDetail(_) => self.handle_game_detail(key),
241 AppScreen::Download(_) => self.handle_download(key),
242 }
243 }
244
245 fn toggle_download_screen(&mut self) {
248 let current =
249 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
250 match current {
251 AppScreen::Download(_) => {
252 self.screen = self
253 .screen_before_download
254 .take()
255 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
256 }
257 other => {
258 self.screen_before_download = Some(other);
259 self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
260 }
261 }
262 }
263
264 fn handle_download(&mut self, key: KeyCode) -> Result<bool> {
265 if key == KeyCode::Esc || key == KeyCode::Char('d') {
266 self.screen = self
267 .screen_before_download
268 .take()
269 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
270 }
271 Ok(false)
272 }
273
274 async fn handle_main_menu(&mut self, key: KeyCode) -> Result<bool> {
277 let menu = match &mut self.screen {
278 AppScreen::MainMenu(m) => m,
279 _ => return Ok(false),
280 };
281 match key {
282 KeyCode::Up | KeyCode::Char('k') => menu.previous(),
283 KeyCode::Down | KeyCode::Char('j') => menu.next(),
284 KeyCode::Enter => match menu.selected {
285 0 => {
286 let platforms = self.client.call(&ListPlatforms).await?;
287 let collections = self.client.call(&ListCollections).await.unwrap_or_default();
288 let mut lib = LibraryBrowseScreen::new(platforms, collections);
289 if lib.list_len() > 0 {
290 let key = lib.cache_key();
291 let expected = lib.expected_rom_count();
292 let req = lib
293 .get_roms_request_platform()
294 .or_else(|| lib.get_roms_request_collection());
295 if let Ok(Some(roms)) = self.load_roms_cached(key, req, expected).await {
296 lib.set_roms(roms);
297 }
298 }
299 self.screen = AppScreen::LibraryBrowse(lib);
300 }
301 1 => self.screen = AppScreen::Search(SearchScreen::new()),
302 2 => {
303 self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
304 self.screen = AppScreen::Download(DownloadScreen::new(self.downloads.shared()));
305 }
306 3 => {
307 self.screen = AppScreen::Settings(SettingsScreen::new(
308 &self.config,
309 self.server_version.as_deref(),
310 ))
311 }
312 4 => self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone())),
313 5 => return Ok(true),
314 _ => {}
315 },
316 KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
317 _ => {}
318 }
319 Ok(false)
320 }
321
322 async fn handle_library_browse(&mut self, key: KeyCode) -> Result<bool> {
325 use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
326
327 let lib = match &mut self.screen {
328 AppScreen::LibraryBrowse(l) => l,
329 _ => return Ok(false),
330 };
331
332 if let Some(mode) = lib.search_mode {
334 match key {
335 KeyCode::Esc => lib.clear_search(),
336 KeyCode::Backspace => lib.delete_search_char(),
337 KeyCode::Char(c) => lib.add_search_char(c),
338 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_to_match(true),
339 KeyCode::Enter => lib.search_mode = None, _ => {}
341 }
342 return Ok(false);
343 }
344
345 match key {
346 KeyCode::Up | KeyCode::Char('k') => {
347 if lib.view_mode == LibraryViewMode::List {
348 lib.list_previous();
349 if lib.list_len() > 0 {
350 lib.clear_roms(); let key = lib.cache_key();
352 let expected = lib.expected_rom_count();
353 let req = lib
354 .get_roms_request_platform()
355 .or_else(|| lib.get_roms_request_collection());
356 self.deferred_load_roms = Some((key, req, expected));
357 }
358 } else {
359 lib.rom_previous();
360 }
361 }
362 KeyCode::Down | KeyCode::Char('j') => {
363 if lib.view_mode == LibraryViewMode::List {
364 lib.list_next();
365 if lib.list_len() > 0 {
366 lib.clear_roms(); let key = lib.cache_key();
368 let expected = lib.expected_rom_count();
369 let req = lib
370 .get_roms_request_platform()
371 .or_else(|| lib.get_roms_request_collection());
372 self.deferred_load_roms = Some((key, req, expected));
373 }
374 } else {
375 lib.rom_next();
376 }
377 }
378 KeyCode::Left | KeyCode::Char('h') => {
379 if lib.view_mode == LibraryViewMode::Roms {
380 lib.back_to_list();
381 }
382 }
383 KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
384 KeyCode::Tab => {
385 if lib.view_mode == LibraryViewMode::List {
386 lib.switch_view();
387 } else {
388 lib.switch_view(); }
390 }
391 KeyCode::Char('/') if lib.view_mode == LibraryViewMode::Roms => {
392 lib.enter_search(LibrarySearchMode::Filter);
393 }
394 KeyCode::Char('f') if lib.view_mode == LibraryViewMode::Roms => {
395 lib.enter_search(LibrarySearchMode::Jump);
396 }
397 KeyCode::Enter => {
398 if lib.view_mode == LibraryViewMode::List {
399 lib.switch_view();
400 } else if let Some((primary, others)) = lib.get_selected_group() {
401 let lib_screen = std::mem::replace(
402 &mut self.screen,
403 AppScreen::MainMenu(MainMenuScreen::new()),
404 );
405 if let AppScreen::LibraryBrowse(l) = lib_screen {
406 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
407 primary,
408 others,
409 GameDetailPrevious::Library(l),
410 self.downloads.shared(),
411 )));
412 }
413 }
414 }
415 KeyCode::Char('t') => lib.switch_subsection(),
416 KeyCode::Esc => {
417 if lib.view_mode == LibraryViewMode::Roms {
418 lib.back_to_list();
419 } else {
420 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
421 }
422 }
423 KeyCode::Char('q') => return Ok(true),
424 _ => {}
425 }
426 Ok(false)
427 }
428
429 async fn handle_search(&mut self, key: KeyCode) -> Result<bool> {
432 let search = match &mut self.screen {
433 AppScreen::Search(s) => s,
434 _ => return Ok(false),
435 };
436 match key {
437 KeyCode::Backspace => search.delete_char(),
438 KeyCode::Left => search.cursor_left(),
439 KeyCode::Right => search.cursor_right(),
440 KeyCode::Up => search.previous(),
441 KeyCode::Down => search.next(),
442 KeyCode::Char(c) => search.add_char(c),
443 KeyCode::Enter => {
444 if search.result_groups.is_some() {
445 if let Some((primary, others)) = search.get_selected_group() {
446 let prev = std::mem::replace(
447 &mut self.screen,
448 AppScreen::MainMenu(MainMenuScreen::new()),
449 );
450 if let AppScreen::Search(s) = prev {
451 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
452 primary,
453 others,
454 GameDetailPrevious::Search(s),
455 self.downloads.shared(),
456 )));
457 }
458 }
459 } else if !search.query.is_empty() {
460 let req = GetRoms {
461 search_term: Some(search.query.clone()),
462 limit: Some(50),
463 ..Default::default()
464 };
465 if let Ok(roms) = self.client.call(&req).await {
466 search.set_results(roms);
467 }
468 }
469 }
470 KeyCode::Esc => {
471 if search.results.is_some() {
472 search.clear_results();
473 } else {
474 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
475 }
476 }
477 _ => {}
478 }
479 Ok(false)
480 }
481
482 fn handle_settings(&mut self, key: KeyCode) -> Result<bool> {
485 match key {
486 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
487 KeyCode::Char('q') => return Ok(true),
488 _ => {}
489 }
490 Ok(false)
491 }
492
493 fn handle_browse(&mut self, key: KeyCode) -> Result<bool> {
496 use super::screens::browse::ViewMode;
497
498 let browse = match &mut self.screen {
499 AppScreen::Browse(b) => b,
500 _ => return Ok(false),
501 };
502 match key {
503 KeyCode::Up | KeyCode::Char('k') => browse.previous(),
504 KeyCode::Down | KeyCode::Char('j') => browse.next(),
505 KeyCode::Left | KeyCode::Char('h') => {
506 if browse.view_mode == ViewMode::Endpoints {
507 browse.switch_view();
508 }
509 }
510 KeyCode::Right | KeyCode::Char('l') => {
511 if browse.view_mode == ViewMode::Sections {
512 browse.switch_view();
513 }
514 }
515 KeyCode::Tab => browse.switch_view(),
516 KeyCode::Enter => {
517 if browse.view_mode == ViewMode::Endpoints {
518 if let Some(ep) = browse.get_selected_endpoint() {
519 self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
520 }
521 } else {
522 browse.switch_view();
523 }
524 }
525 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
526 _ => {}
527 }
528 Ok(false)
529 }
530
531 async fn handle_execute(&mut self, key: KeyCode) -> Result<bool> {
534 let execute = match &mut self.screen {
535 AppScreen::Execute(e) => e,
536 _ => return Ok(false),
537 };
538 match key {
539 KeyCode::Tab => execute.next_field(),
540 KeyCode::BackTab => execute.previous_field(),
541 KeyCode::Char(c) => execute.add_char_to_focused(c),
542 KeyCode::Backspace => execute.delete_char_from_focused(),
543 KeyCode::Enter => {
544 let endpoint = execute.endpoint.clone();
545 let query = execute.get_query_params();
546 let body = if endpoint.has_body && !execute.body_text.is_empty() {
547 Some(serde_json::from_str(&execute.body_text)?)
548 } else {
549 None
550 };
551 let resolved_path =
552 match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
553 Ok(p) => p,
554 Err(e) => {
555 self.screen = AppScreen::Result(ResultScreen::new(
556 serde_json::json!({ "error": format!("{e}") }),
557 None,
558 None,
559 ));
560 return Ok(false);
561 }
562 };
563 match self
564 .client
565 .request_json(&endpoint.method, &resolved_path, &query, body)
566 .await
567 {
568 Ok(result) => {
569 self.screen = AppScreen::Result(ResultScreen::new(
570 result,
571 Some(&endpoint.method),
572 Some(resolved_path.as_str()),
573 ));
574 }
575 Err(e) => {
576 self.screen = AppScreen::Result(ResultScreen::new(
577 serde_json::json!({ "error": format!("{e}") }),
578 None,
579 None,
580 ));
581 }
582 }
583 }
584 KeyCode::Esc => {
585 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
586 }
587 _ => {}
588 }
589 Ok(false)
590 }
591
592 fn handle_result(&mut self, key: KeyCode) -> Result<bool> {
595 use super::screens::result::ResultViewMode;
596
597 let result = match &mut self.screen {
598 AppScreen::Result(r) => r,
599 _ => return Ok(false),
600 };
601 match key {
602 KeyCode::Up | KeyCode::Char('k') => {
603 if result.view_mode == ResultViewMode::Json {
604 result.scroll_up(1);
605 } else {
606 result.table_previous();
607 }
608 }
609 KeyCode::Down => {
610 if result.view_mode == ResultViewMode::Json {
611 result.scroll_down(1);
612 } else {
613 result.table_next();
614 }
615 }
616 KeyCode::Char('j') => {
617 if result.view_mode == ResultViewMode::Json {
618 result.scroll_down(1);
619 }
620 }
621 KeyCode::PageUp => {
622 if result.view_mode == ResultViewMode::Table {
623 result.table_page_up();
624 } else {
625 result.scroll_up(10);
626 }
627 }
628 KeyCode::PageDown => {
629 if result.view_mode == ResultViewMode::Table {
630 result.table_page_down();
631 } else {
632 result.scroll_down(10);
633 }
634 }
635 KeyCode::Char('t') => {
636 if result.table_row_count > 0 {
637 result.switch_view_mode();
638 }
639 }
640 KeyCode::Enter => {
641 if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 {
642 if let Some(item) = result.get_selected_item_value() {
643 let prev = std::mem::replace(
644 &mut self.screen,
645 AppScreen::MainMenu(MainMenuScreen::new()),
646 );
647 if let AppScreen::Result(rs) = prev {
648 self.screen =
649 AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
650 }
651 }
652 }
653 }
654 KeyCode::Esc => {
655 result.clear_message();
656 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
657 }
658 KeyCode::Char('q') => return Ok(true),
659 _ => {}
660 }
661 Ok(false)
662 }
663
664 fn handle_result_detail(&mut self, key: KeyCode) -> Result<bool> {
667 let detail = match &mut self.screen {
668 AppScreen::ResultDetail(d) => d,
669 _ => return Ok(false),
670 };
671 match key {
672 KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
673 KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
674 KeyCode::PageUp => detail.scroll_up(10),
675 KeyCode::PageDown => detail.scroll_down(10),
676 KeyCode::Char('o') => detail.open_image_url(),
677 KeyCode::Esc => {
678 detail.clear_message();
679 let prev =
680 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
681 if let AppScreen::ResultDetail(d) = prev {
682 self.screen = AppScreen::Result(d.parent);
683 }
684 }
685 KeyCode::Char('q') => return Ok(true),
686 _ => {}
687 }
688 Ok(false)
689 }
690
691 fn handle_game_detail(&mut self, key: KeyCode) -> Result<bool> {
694 let detail = match &mut self.screen {
695 AppScreen::GameDetail(d) => d,
696 _ => return Ok(false),
697 };
698
699 if !detail.download_completion_acknowledged {
702 if let Ok(list) = detail.downloads.lock() {
703 let has_completed = list.iter().any(|j| {
704 j.rom_id == detail.rom.id
705 && matches!(
706 j.status,
707 crate::core::download::DownloadStatus::Done
708 | crate::core::download::DownloadStatus::Error(_)
709 )
710 });
711 let is_still_downloading = list.iter().any(|j| {
712 j.rom_id == detail.rom.id
713 && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
714 });
715 if has_completed && !is_still_downloading {
717 detail.download_completion_acknowledged = true;
718 }
719 }
720 }
721
722 match key {
723 KeyCode::Enter => {
724 if !detail.has_started_download {
727 detail.has_started_download = true;
728 self.downloads
729 .start_download(&detail.rom, self.client.clone());
730 }
731 }
732 KeyCode::Char('o') => detail.open_cover(),
733 KeyCode::Char('m') => detail.toggle_technical(),
734 KeyCode::Esc => {
735 detail.clear_message();
736 let prev =
737 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
738 if let AppScreen::GameDetail(g) = prev {
739 self.screen = match g.previous {
740 GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(l),
741 GameDetailPrevious::Search(s) => AppScreen::Search(s),
742 };
743 }
744 }
745 KeyCode::Char('q') => return Ok(true),
746 _ => {}
747 }
748 Ok(false)
749 }
750
751 fn render(&mut self, f: &mut ratatui::Frame) {
756 let area = f.size();
757 if let Some(ref splash) = self.startup_splash {
758 connected_splash::render(f, area, splash);
759 return;
760 }
761 match &mut self.screen {
762 AppScreen::MainMenu(menu) => menu.render(f, area),
763 AppScreen::LibraryBrowse(lib) => lib.render(f, area),
764 AppScreen::Search(search) => {
765 search.render(f, area);
766 if let Some((x, y)) = search.cursor_position(area) {
767 f.set_cursor(x, y);
768 }
769 }
770 AppScreen::Settings(settings) => settings.render(f, area),
771 AppScreen::Browse(browse) => browse.render(f, area),
772 AppScreen::Execute(execute) => {
773 execute.render(f, area);
774 if let Some((x, y)) = execute.cursor_position(area) {
775 f.set_cursor(x, y);
776 }
777 }
778 AppScreen::Result(result) => result.render(f, area),
779 AppScreen::ResultDetail(detail) => detail.render(f, area),
780 AppScreen::GameDetail(detail) => detail.render(f, area),
781 AppScreen::Download(d) => d.render(f, area),
782 }
783 }
784}