1use ratatui::layout::{Constraint, Layout, Rect};
2use ratatui::style::{Color, Style};
3use ratatui::widgets::{
4 Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table,
5};
6use ratatui::Frame;
7
8use crate::core::cache::RomCacheKey;
9use crate::core::utils::{self, RomGroup};
10use crate::endpoints::roms::GetRoms;
11use crate::tui::text_search::{
12 filter_source_indices, jump_next_index, normalize_label, SearchState,
13};
14use crate::types::{Collection, Platform, Rom, RomList};
15
16use crate::tui::path_picker::{PathPicker, PathPickerMode};
17
18pub use crate::tui::text_search::LibrarySearchMode;
19
20#[derive(Debug)]
22pub struct UploadPrompt {
23 pub picker: PathPicker,
24 pub scan_after: bool,
26}
27
28impl Default for UploadPrompt {
29 fn default() -> Self {
30 Self {
31 picker: PathPicker::new(PathPickerMode::File, ""),
32 scan_after: true,
33 }
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum LibrarySubsection {
40 ByConsole,
41 ByCollection,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq)]
46pub enum LibraryViewMode {
47 List,
49 Roms,
51}
52
53pub struct LibraryBrowseScreen {
55 pub platforms: Vec<Platform>,
56 pub collections: Vec<Collection>,
57 pub subsection: LibrarySubsection,
58 pub list_index: usize,
59 pub view_mode: LibraryViewMode,
60 pub roms: Option<RomList>,
61 pub rom_groups: Option<Vec<RomGroup>>,
63 pub rom_selected: usize,
64 pub scroll_offset: usize,
65 visible_rows: usize,
67 pub list_search: SearchState,
69 pub rom_search: SearchState,
71 pub metadata_footer: Option<String>,
73 pub metadata_footer_clear_at: Option<std::time::Instant>,
75 pub rom_loading: bool,
77 pub upload_prompt: Option<UploadPrompt>,
79}
80
81impl LibraryBrowseScreen {
82 pub fn new(platforms: Vec<Platform>, collections: Vec<Collection>) -> Self {
83 Self {
84 platforms,
85 collections,
86 subsection: LibrarySubsection::ByConsole,
87 list_index: 0,
88 view_mode: LibraryViewMode::List,
89 roms: None,
90 rom_groups: None,
91 rom_selected: 0,
92 scroll_offset: 0,
93 visible_rows: 20,
94 list_search: SearchState::new(),
95 rom_search: SearchState::new(),
96 metadata_footer: None,
97 metadata_footer_clear_at: None,
98 rom_loading: false,
99 upload_prompt: None,
100 }
101 }
102
103 pub fn set_metadata_footer(&mut self, msg: Option<String>) {
104 self.metadata_footer = msg;
105 self.metadata_footer_clear_at = None;
106 }
107
108 pub fn set_temporary_metadata_footer(&mut self, msg: String, duration: std::time::Duration) {
109 self.metadata_footer = Some(msg);
110 self.metadata_footer_clear_at = Some(std::time::Instant::now() + duration);
111 }
112
113 pub fn poll_footer_clear(&mut self) {
114 if let Some(clear_at) = self.metadata_footer_clear_at {
115 if std::time::Instant::now() >= clear_at {
116 self.metadata_footer = None;
117 self.metadata_footer_clear_at = None;
118 }
119 }
120 }
121
122 pub fn any_upload_prompt_open(&self) -> bool {
124 self.upload_prompt.is_some()
125 }
126
127 pub fn open_upload_prompt(&mut self) {
128 self.upload_prompt = Some(UploadPrompt::default());
129 }
130
131 pub fn close_upload_prompt(&mut self) {
132 self.upload_prompt = None;
133 }
134
135 fn collection_key(c: &Collection) -> RomCacheKey {
136 if c.is_virtual {
137 RomCacheKey::VirtualCollection(c.virtual_id.clone().unwrap_or_default())
138 } else if c.is_smart {
139 RomCacheKey::SmartCollection(c.id)
140 } else {
141 RomCacheKey::Collection(c.id)
142 }
143 }
144
145 fn cache_key_for_position(
146 &self,
147 subsection: LibrarySubsection,
148 source_idx: usize,
149 ) -> Option<RomCacheKey> {
150 match subsection {
151 LibrarySubsection::ByConsole => self
152 .platforms
153 .get(source_idx)
154 .map(|p| RomCacheKey::Platform(p.id)),
155 LibrarySubsection::ByCollection => {
156 self.collections.get(source_idx).map(Self::collection_key)
157 }
158 }
159 }
160
161 fn expected_rom_count_for_position(
162 &self,
163 subsection: LibrarySubsection,
164 source_idx: usize,
165 ) -> u64 {
166 match subsection {
167 LibrarySubsection::ByConsole => self
168 .platforms
169 .get(source_idx)
170 .map(|p| p.rom_count)
171 .unwrap_or(0),
172 LibrarySubsection::ByCollection => self
173 .collections
174 .get(source_idx)
175 .and_then(|c| c.rom_count)
176 .unwrap_or(0),
177 }
178 }
179
180 fn get_roms_request_for_position(
181 &self,
182 subsection: LibrarySubsection,
183 source_idx: usize,
184 ) -> Option<GetRoms> {
185 let count = self
186 .expected_rom_count_for_position(subsection, source_idx)
187 .min(20000);
188 if count == 0 {
189 return None;
190 }
191 match subsection {
192 LibrarySubsection::ByConsole => self.platforms.get(source_idx).map(|p| GetRoms {
193 platform_id: Some(p.id),
194 limit: Some(50),
195 ..Default::default()
196 }),
197 LibrarySubsection::ByCollection => self.collections.get(source_idx).map(|c| {
198 if c.is_virtual {
199 GetRoms {
200 virtual_collection_id: c.virtual_id.clone(),
201 limit: Some(50),
202 ..Default::default()
203 }
204 } else if c.is_smart {
205 GetRoms {
206 smart_collection_id: Some(c.id),
207 limit: Some(50),
208 ..Default::default()
209 }
210 } else {
211 GetRoms {
212 collection_id: Some(c.id),
213 limit: Some(50),
214 ..Default::default()
215 }
216 }
217 }),
218 }
219 }
220
221 pub fn replace_metadata_preserving_selection(
226 &mut self,
227 platforms: Vec<Platform>,
228 collections: Vec<Collection>,
229 update_platforms: bool,
230 update_collections: bool,
231 ) -> bool {
232 let subsection = self.subsection;
233 let old_source = self.selected_list_source_index();
234 let old_key = old_source.and_then(|i| self.cache_key_for_position(subsection, i));
235 let old_expected = old_source
236 .map(|i| self.expected_rom_count_for_position(subsection, i))
237 .unwrap_or(0);
238
239 if update_platforms {
240 self.platforms = platforms;
241 }
242 if update_collections {
243 self.collections = collections;
244 }
245
246 self.list_search.clear();
247 let new_source = old_key.as_ref().and_then(|k| match subsection {
248 LibrarySubsection::ByConsole => self
249 .platforms
250 .iter()
251 .position(|p| matches!(k, RomCacheKey::Platform(id) if *id == p.id)),
252 LibrarySubsection::ByCollection => self.collections.iter().position(|c| {
253 let ck = Self::collection_key(c);
254 &ck == k
255 }),
256 });
257
258 self.list_index = new_source.unwrap_or(0);
259 self.clamp_list_index();
260
261 let new_source = self.selected_list_source_index();
262 let new_key = new_source.and_then(|i| self.cache_key_for_position(subsection, i));
263 let new_expected = new_source
264 .map(|i| self.expected_rom_count_for_position(subsection, i))
265 .unwrap_or(0);
266
267 let changed = old_key != new_key || old_expected != new_expected;
268 if changed {
269 self.clear_roms();
270 self.view_mode = LibraryViewMode::List;
271 self.rom_selected = 0;
272 self.scroll_offset = 0;
273 }
274 changed
275 }
276
277 pub fn collection_prefetch_candidates(
279 &self,
280 radius: usize,
281 ) -> Vec<(RomCacheKey, GetRoms, u64)> {
282 if self.subsection != LibrarySubsection::ByCollection {
283 return Vec::new();
284 }
285 let visible = self.visible_list_source_indices();
286 if visible.is_empty() {
287 return Vec::new();
288 }
289 let center = self.list_index.min(visible.len() - 1);
290 let start = center.saturating_sub(radius);
291 let end = (center + radius + 1).min(visible.len());
292 let mut out = Vec::new();
293 for (pos, source_idx) in visible[start..end].iter().enumerate() {
294 if start + pos == center {
295 continue;
296 }
297 if let (Some(key), Some(req)) = (
298 self.cache_key_for_position(LibrarySubsection::ByCollection, *source_idx),
299 self.get_roms_request_for_position(LibrarySubsection::ByCollection, *source_idx),
300 ) {
301 let expected = self
302 .expected_rom_count_for_position(LibrarySubsection::ByCollection, *source_idx);
303 out.push((key, req, expected));
304 }
305 }
306 out
307 }
308
309 pub fn any_search_bar_open(&self) -> bool {
311 self.list_search.mode.is_some() || self.rom_search.mode.is_some()
312 }
313
314 fn list_row_labels(&self) -> Vec<String> {
316 match self.subsection {
317 LibrarySubsection::ByConsole => self
318 .platforms
319 .iter()
320 .map(|p| {
321 let name = p.display_name.as_deref().unwrap_or(&p.name);
322 format!("{} ({} roms)", name, p.rom_count)
323 })
324 .collect(),
325 LibrarySubsection::ByCollection => self
326 .collections
327 .iter()
328 .map(|c| {
329 let title = if c.is_virtual {
330 format!("{} [auto]", c.name)
331 } else if c.is_smart {
332 format!("{} [smart]", c.name)
333 } else {
334 c.name.clone()
335 };
336 format!("{} ({} roms)", title, c.rom_count.unwrap_or(0))
337 })
338 .collect(),
339 }
340 }
341
342 fn visible_list_source_indices(&self) -> Vec<usize> {
343 let labels = self.list_row_labels();
344 if self.list_search.filter_active() {
345 filter_source_indices(&labels, &self.list_search.normalized_query)
346 } else {
347 (0..labels.len()).collect()
348 }
349 }
350
351 fn clamp_list_index(&mut self) {
352 let v = self.visible_list_source_indices();
353 if v.is_empty() || self.list_index >= v.len() {
354 self.list_index = 0;
355 }
356 }
357
358 fn selected_list_source_index(&self) -> Option<usize> {
360 let v = self.visible_list_source_indices();
361 v.get(self.list_index).copied()
362 }
363
364 pub fn list_len(&self) -> usize {
365 self.visible_list_source_indices().len()
366 }
367
368 pub fn list_next(&mut self) {
369 let len = self.list_len();
370 if len > 0 {
371 self.list_index = (self.list_index + 1) % len;
372 }
373 }
374
375 pub fn list_previous(&mut self) {
376 let len = self.list_len();
377 if len > 0 {
378 self.list_index = if self.list_index == 0 {
379 len - 1
380 } else {
381 self.list_index - 1
382 };
383 }
384 }
385
386 pub fn rom_next(&mut self) {
387 let groups = self.visible_rom_groups();
388 let len = groups.len();
389 if len > 0 {
390 self.rom_selected = (self.rom_selected + 1) % len;
391 self.update_rom_scroll(self.visible_rows);
392 }
393 }
394
395 pub fn rom_previous(&mut self) {
396 let groups = self.visible_rom_groups();
397 let len = groups.len();
398 if len > 0 {
399 self.rom_selected = if self.rom_selected == 0 {
400 len - 1
401 } else {
402 self.rom_selected - 1
403 };
404 self.update_rom_scroll(self.visible_rows);
405 }
406 }
407
408 fn update_rom_scroll(&mut self, visible: usize) {
409 if self.rom_groups.is_none() {
410 return;
411 }
412 let list_len = self.visible_rom_groups().len();
413 self.update_rom_scroll_with_len(list_len, visible);
414 }
415
416 fn update_rom_scroll_with_len(&mut self, list_len: usize, visible: usize) {
417 let visible = visible.max(1);
418 let max_scroll = list_len.saturating_sub(visible);
419 if self.rom_selected >= self.scroll_offset + visible {
420 self.scroll_offset = (self.rom_selected + 1).saturating_sub(visible);
421 } else if self.rom_selected < self.scroll_offset {
422 self.scroll_offset = self.rom_selected;
423 }
424 self.scroll_offset = self.scroll_offset.min(max_scroll);
425 }
426
427 pub fn switch_subsection(&mut self) {
428 self.subsection = match self.subsection {
429 LibrarySubsection::ByConsole => LibrarySubsection::ByCollection,
430 LibrarySubsection::ByCollection => LibrarySubsection::ByConsole,
431 };
432 self.list_index = 0;
433 self.roms = None;
434 self.rom_loading = false;
435 self.view_mode = LibraryViewMode::List;
436 self.list_search.clear();
437 }
438
439 pub fn switch_view(&mut self) {
440 match self.view_mode {
441 LibraryViewMode::List => {
442 self.list_search.clear();
443 self.view_mode = LibraryViewMode::Roms;
444 }
445 LibraryViewMode::Roms => {
446 self.rom_search.clear();
447 self.view_mode = LibraryViewMode::List;
448 }
449 }
450 self.rom_selected = 0;
451 self.scroll_offset = 0;
452 }
453
454 pub fn back_to_list(&mut self) {
455 self.rom_search.clear();
456 self.view_mode = LibraryViewMode::List;
457 }
458
459 pub fn clear_roms(&mut self) {
460 self.roms = None;
461 self.rom_groups = None;
462 self.rom_selected = 0;
463 self.scroll_offset = 0;
464 self.rom_search.clear();
465 }
466
467 pub fn set_rom_loading(&mut self, loading: bool) {
468 self.rom_loading = loading;
469 }
470
471 pub fn set_roms(&mut self, roms: RomList) {
472 self.roms = Some(roms.clone());
473 self.rom_groups = Some(utils::group_roms_by_name(&roms.items));
474 self.rom_loading = false;
475 }
476
477 pub fn enter_list_search(&mut self, mode: LibrarySearchMode) {
480 self.list_search.enter(mode);
481 self.list_index = 0;
482 }
483
484 pub fn clear_list_search(&mut self) {
485 self.list_search.clear();
486 self.clamp_list_index();
487 }
488
489 pub fn add_list_search_char(&mut self, c: char) {
490 self.list_search.add_char(c);
491 if self.list_search.mode == Some(LibrarySearchMode::Filter) {
492 self.list_index = 0;
493 } else if self.list_search.mode == Some(LibrarySearchMode::Jump) {
494 self.list_jump_match(false);
495 }
496 self.clamp_list_index();
497 }
498
499 pub fn delete_list_search_char(&mut self) {
500 self.list_search.delete_char();
501 if self.list_search.mode == Some(LibrarySearchMode::Filter) {
502 self.list_index = 0;
503 }
504 self.clamp_list_index();
505 }
506
507 pub fn commit_list_filter_bar(&mut self) {
508 self.list_search.commit_filter_bar();
509 self.clamp_list_index();
510 }
511
512 pub fn commit_rom_filter_bar(&mut self) {
513 self.rom_search.commit_filter_bar();
514 }
515
516 pub fn list_jump_match(&mut self, next: bool) {
517 if self.list_search.normalized_query.is_empty() {
518 return;
519 }
520 let labels = self.list_row_labels();
521 if labels.is_empty() {
522 return;
523 }
524 let source = self
525 .selected_list_source_index()
526 .unwrap_or(0)
527 .min(labels.len().saturating_sub(1));
528 if let Some(new_src) =
529 jump_next_index(&labels, source, &self.list_search.normalized_query, next)
530 {
531 let visible = self.visible_list_source_indices();
532 if let Some(pos) = visible.iter().position(|&i| i == new_src) {
533 self.list_index = pos;
534 }
535 }
536 }
537
538 pub fn enter_rom_search(&mut self, mode: LibrarySearchMode) {
541 self.rom_search.enter(mode);
542 self.rom_selected = 0;
543 self.scroll_offset = 0;
544 }
545
546 pub fn clear_rom_search(&mut self) {
547 self.rom_search.clear();
548 }
549
550 pub fn add_rom_search_char(&mut self, c: char) {
551 self.rom_search.add_char(c);
552 if self.rom_search.mode == Some(LibrarySearchMode::Filter) {
553 self.rom_selected = 0;
554 self.scroll_offset = 0;
555 } else if self.rom_search.mode == Some(LibrarySearchMode::Jump) {
556 self.jump_rom_match(false);
557 }
558 }
559
560 pub fn delete_rom_search_char(&mut self) {
561 self.rom_search.delete_char();
562 if self.rom_search.mode == Some(LibrarySearchMode::Filter) {
563 self.rom_selected = 0;
564 self.scroll_offset = 0;
565 }
566 }
567
568 pub fn jump_rom_match(&mut self, next: bool) {
569 if self.rom_search.normalized_query.is_empty() {
570 return;
571 }
572 let Some(ref groups) = self.rom_groups else {
573 return;
574 };
575 let labels: Vec<String> = groups.iter().map(|g| g.name.clone()).collect();
576 if labels.is_empty() {
577 return;
578 }
579 let source = self.rom_selected.min(labels.len().saturating_sub(1));
580 if let Some(idx) = jump_next_index(&labels, source, &self.rom_search.normalized_query, next)
581 {
582 self.rom_selected = idx;
583 self.update_rom_scroll(self.visible_rows);
584 }
585 }
586
587 pub fn get_selected_group(&self) -> Option<(Rom, Vec<Rom>)> {
588 let visible = self.visible_rom_groups();
589 if visible.is_empty() {
590 return None;
591 }
592 let idx = if self.rom_selected >= visible.len() {
593 0
594 } else {
595 self.rom_selected
596 };
597 visible
598 .get(idx)
599 .map(|g| (g.primary.clone(), g.others.clone()))
600 }
601
602 fn visible_rom_groups(&self) -> Vec<RomGroup> {
603 let Some(ref groups) = self.rom_groups else {
604 return Vec::new();
605 };
606 if self.rom_search.filter_active() {
607 groups
608 .iter()
609 .filter(|g| normalize_label(&g.name).contains(&self.rom_search.normalized_query))
610 .cloned()
611 .collect()
612 } else {
613 groups.clone()
614 }
615 }
616
617 fn list_title(&self) -> &str {
618 match self.subsection {
619 LibrarySubsection::ByConsole => "Consoles",
620 LibrarySubsection::ByCollection => "Collections",
621 }
622 }
623
624 pub fn selected_platform_id(&self) -> Option<u64> {
625 match self.subsection {
626 LibrarySubsection::ByConsole => self
627 .selected_list_source_index()
628 .and_then(|i| self.platforms.get(i).map(|p| p.id)),
629 LibrarySubsection::ByCollection => None,
630 }
631 }
632
633 pub fn cache_key(&self) -> Option<RomCacheKey> {
634 match self.subsection {
635 LibrarySubsection::ByConsole => self.selected_platform_id().map(RomCacheKey::Platform),
636 LibrarySubsection::ByCollection => self
637 .selected_list_source_index()
638 .and_then(|i| self.collections.get(i))
639 .map(|c| {
640 if c.is_virtual {
641 RomCacheKey::VirtualCollection(c.virtual_id.clone().unwrap_or_default())
642 } else if c.is_smart {
643 RomCacheKey::SmartCollection(c.id)
644 } else {
645 RomCacheKey::Collection(c.id)
646 }
647 }),
648 }
649 }
650
651 pub fn expected_rom_count(&self) -> u64 {
652 match self.subsection {
653 LibrarySubsection::ByConsole => self
654 .selected_list_source_index()
655 .and_then(|i| self.platforms.get(i).map(|p| p.rom_count))
656 .unwrap_or(0),
657 LibrarySubsection::ByCollection => self
658 .selected_list_source_index()
659 .and_then(|i| self.collections.get(i))
660 .and_then(|c| c.rom_count)
661 .unwrap_or(0),
662 }
663 }
664
665 pub fn get_roms_request_platform(&self) -> Option<GetRoms> {
666 self.selected_list_source_index()
667 .and_then(|i| self.get_roms_request_for_position(LibrarySubsection::ByConsole, i))
668 }
669
670 pub fn get_roms_request_collection(&self) -> Option<GetRoms> {
671 if self.subsection != LibrarySubsection::ByCollection {
672 return None;
673 }
674 self.selected_list_source_index()
675 .and_then(|i| self.get_roms_request_for_position(LibrarySubsection::ByCollection, i))
676 }
677
678 pub fn render(&mut self, f: &mut Frame, area: Rect) {
679 let chunks = Layout::default()
680 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
681 .direction(ratatui::layout::Direction::Horizontal)
682 .split(area);
683
684 let left_area = chunks[0];
685 if self.list_search.mode.is_some() {
686 let left_chunks = Layout::default()
687 .constraints([Constraint::Length(3), Constraint::Min(3)])
688 .direction(ratatui::layout::Direction::Vertical)
689 .split(left_area);
690 if let Some(mode) = self.list_search.mode {
691 let title = match mode {
692 LibrarySearchMode::Filter => "Filter Search (list)",
693 LibrarySearchMode::Jump => "Jump Search (list, Tab next)",
694 };
695 let p =
696 ratatui::widgets::Paragraph::new(format!("Search: {}", self.list_search.query))
697 .block(Block::default().title(title).borders(Borders::ALL));
698 f.render_widget(p, left_chunks[0]);
699 }
700 self.render_list(f, left_chunks[1]);
701 } else {
702 self.render_list(f, left_area);
703 }
704
705 let right_chunks = if self.rom_search.mode.is_some() {
706 Layout::default()
707 .constraints([
708 Constraint::Length(3),
709 Constraint::Min(5),
710 Constraint::Length(3),
711 ])
712 .direction(ratatui::layout::Direction::Vertical)
713 .split(chunks[1])
714 } else {
715 Layout::default()
716 .constraints([Constraint::Min(5), Constraint::Length(3)])
717 .direction(ratatui::layout::Direction::Vertical)
718 .split(chunks[1])
719 };
720
721 if let Some(mode) = self.rom_search.mode {
722 let title = match mode {
723 LibrarySearchMode::Filter => "Filter Search",
724 LibrarySearchMode::Jump => "Jump Search (Tab to next)",
725 };
726 let p = ratatui::widgets::Paragraph::new(format!("Search: {}", self.rom_search.query))
727 .block(Block::default().title(title).borders(Borders::ALL));
728 f.render_widget(p, right_chunks[0]);
729 self.render_roms(f, right_chunks[1]);
730 self.render_help(f, right_chunks[2]);
731 } else {
732 self.render_roms(f, right_chunks[0]);
733 self.render_help(f, right_chunks[1]);
734 }
735
736 if self.upload_prompt.is_some() {
737 self.render_upload_popup(f, area);
738 }
739 }
740
741 fn upload_popup_rect(area: Rect) -> Rect {
742 let w = (area.width * 4 / 5).max(50).min(area.width);
743 let h = (area.height * 7 / 10).max(16).min(area.height);
744 let x = area.x + (area.width.saturating_sub(w)) / 2;
745 let y = area.y + (area.height.saturating_sub(h)) / 2;
746 Rect {
747 x,
748 y,
749 width: w,
750 height: h,
751 }
752 }
753
754 fn upload_popup_platform_name(&self) -> String {
755 match self.subsection {
756 LibrarySubsection::ByConsole => self
757 .selected_list_source_index()
758 .and_then(|i| self.platforms.get(i))
759 .map(|p| p.display_name.as_deref().unwrap_or(&p.name).to_string())
760 .unwrap_or_else(|| "?".to_string()),
761 LibrarySubsection::ByCollection => "(switch to Consoles — t)".to_string(),
762 }
763 }
764
765 fn render_upload_popup(&mut self, f: &mut Frame, area: Rect) {
766 let platform_name = self.upload_popup_platform_name();
767 let Some(ref mut up) = self.upload_prompt else {
768 return;
769 };
770 let popup = Self::upload_popup_rect(area);
771 f.render_widget(Clear, popup);
772 let scan_line = if up.scan_after {
773 "Rescan after upload: yes — Ctrl+s to disable"
774 } else {
775 "Rescan after upload: no — Ctrl+s to enable"
776 };
777 let header = format!("{platform_name}\n{scan_line}");
778 let inner = Block::default()
779 .title("Upload ROM (Ctrl+u)")
780 .borders(Borders::ALL)
781 .inner(popup);
782 let rows = Layout::default()
783 .direction(ratatui::layout::Direction::Vertical)
784 .constraints([Constraint::Length(2), Constraint::Min(8)])
785 .split(inner);
786 f.render_widget(
787 Block::default()
788 .title("Upload ROM (Ctrl+u)")
789 .borders(Borders::ALL),
790 popup,
791 );
792 f.render_widget(Paragraph::new(header), rows[0]);
793 let footer = "Enter: open/select Ctrl+Enter: confirm file ↑ list top: path Tab: path/list Ctrl+s: rescan";
794 up.picker.render(f, rows[1], "Choose ROM file", footer);
795 }
796
797 pub fn upload_prompt_cursor(&self, area: Rect) -> Option<(u16, u16)> {
799 let up = self.upload_prompt.as_ref()?;
800 let popup = Self::upload_popup_rect(area);
801 let inner = Block::default()
802 .title("Upload ROM (Ctrl+u)")
803 .borders(Borders::ALL)
804 .inner(popup);
805 let rows = Layout::default()
806 .direction(ratatui::layout::Direction::Vertical)
807 .constraints([Constraint::Length(2), Constraint::Min(8)])
808 .split(inner);
809 up.picker.cursor_position(rows[1], "Choose ROM file")
810 }
811
812 fn render_list(&self, f: &mut Frame, area: Rect) {
813 let visible = self.visible_list_source_indices();
814 let labels = self.list_row_labels();
815
816 let items: Vec<ListItem> = visible
817 .iter()
818 .enumerate()
819 .map(|(pos, &source_idx)| {
820 let line = labels
821 .get(source_idx)
822 .cloned()
823 .unwrap_or_else(|| "?".to_string());
824 let prefix = if pos == self.list_index && self.view_mode == LibraryViewMode::List {
825 "▶ "
826 } else {
827 " "
828 };
829 ListItem::new(format!("{}{}", prefix, line))
830 })
831 .collect();
832
833 let list = List::new(items)
834 .block(
835 Block::default()
836 .title(self.list_title())
837 .borders(Borders::ALL),
838 )
839 .highlight_symbol(if self.view_mode == LibraryViewMode::List {
840 ">> "
841 } else {
842 " "
843 });
844
845 let mut state = ListState::default();
846 if self.view_mode == LibraryViewMode::List {
847 state.select(Some(self.list_index));
848 }
849
850 f.render_stateful_widget(list, area, &mut state);
851 }
852
853 fn render_roms(&mut self, f: &mut Frame, area: Rect) {
854 let visible = (area.height as usize).saturating_sub(3).max(1);
855 self.visible_rows = visible;
856
857 let groups = self.visible_rom_groups();
858 if groups.is_empty() {
859 let msg = self.empty_rom_state_message();
860 let p = ratatui::widgets::Paragraph::new(msg)
861 .block(Block::default().title("Games").borders(Borders::ALL));
862 f.render_widget(p, area);
863 return;
864 }
865
866 if self.rom_selected >= groups.len() {
867 self.rom_selected = 0;
868 self.scroll_offset = 0;
869 }
870
871 self.update_rom_scroll_with_len(groups.len(), visible);
872
873 let start = self.scroll_offset.min(groups.len().saturating_sub(visible));
874 let end = (start + visible).min(groups.len());
875 let visible_groups = &groups[start..end];
876
877 let header = Row::new(vec![
878 Cell::from("Name").style(Style::default().fg(Color::Cyan))
879 ]);
880 let rows: Vec<Row> = visible_groups
881 .iter()
882 .enumerate()
883 .map(|(i, g)| {
884 let global_idx = start + i;
885 let style = if global_idx == self.rom_selected {
886 Style::default().fg(Color::Yellow)
887 } else {
888 Style::default()
889 };
890 Row::new(vec![Cell::from(g.name.as_str()).style(style)]).height(1)
891 })
892 .collect();
893
894 let total_files = self.roms.as_ref().map(|r| r.items.len()).unwrap_or(0);
895 let total_roms = self.roms.as_ref().map(|r| r.total).unwrap_or(0);
896 let mut title = if self.rom_search.filter_browsing && !self.rom_search.query.is_empty() {
897 format!(
898 "Games (filtered: \"{}\") — {} — {} files",
899 self.rom_search.query,
900 groups.len(),
901 total_files
902 )
903 } else if total_roms > 0 && (total_files as u64) < total_roms {
904 format!(
905 "Games ({} of {}) — {} files",
906 total_files, total_roms, total_files
907 )
908 } else {
909 format!("Games ({}) — {} files", groups.len(), total_files)
910 };
911
912 if self.rom_loading {
913 title.push_str(" [Loading...]");
914 }
915 let widths = [Constraint::Percentage(100)];
916 let table = Table::new(rows, widths)
917 .header(header)
918 .block(Block::default().title(title).borders(Borders::ALL));
919
920 f.render_widget(table, area);
921 }
922
923 fn empty_rom_state_message(&self) -> String {
924 if self.rom_search.mode.is_some() {
925 "No games match your search".to_string()
926 } else if self.rom_loading && self.expected_rom_count() > 0 {
927 "Loading games...".to_string()
928 } else {
929 "Select a console or collection and press Enter to load ROMs".to_string()
930 }
931 }
932
933 fn render_help(&self, f: &mut Frame, area: Rect) {
934 let help = match self.view_mode {
935 LibraryViewMode::List => {
936 if self.list_search.mode.is_some() {
937 "Type filter | Enter: browse matches | Esc: clear"
938 } else if self.list_search.filter_browsing {
939 "↑↓: Navigate | Enter: Load games | Esc: clear filter"
940 } else {
941 "t: Switch | ↑↓: Select | Ctrl+u: Upload | / f: Filter | Enter: Games | Shift+/: Help | Esc: Menu"
942 }
943 }
944 LibraryViewMode::Roms => {
945 if self.rom_search.mode.is_some() {
946 "Type filter | Enter: browse matches | Esc: clear filter"
947 } else if self.rom_search.filter_browsing {
948 "←: Back to list | ↑↓: Navigate | Enter: Game detail | Esc: clear filter"
949 } else {
950 "←: Back | ↑↓: Navigate | Ctrl+u: Upload | / f: Filter | Enter: Detail | Shift+/: Help | Esc: Back"
951 }
952 }
953 };
954 let text = match &self.metadata_footer {
955 Some(m) if !m.is_empty() => format!("{m}\n{help}"),
956 _ => help.to_string(),
957 };
958 let p =
959 ratatui::widgets::Paragraph::new(text).block(Block::default().borders(Borders::ALL));
960 f.render_widget(p, area);
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967 use crate::core::utils;
968 use crate::types::{Platform, Rom};
969 use serde_json::json;
970
971 fn rom(id: u64, name: &str, fs_name: &str) -> Rom {
972 Rom {
973 id,
974 platform_id: 1,
975 platform_slug: None,
976 platform_fs_slug: None,
977 platform_custom_name: None,
978 platform_display_name: None,
979 fs_name: fs_name.to_string(),
980 fs_name_no_tags: name.to_string(),
981 fs_name_no_ext: name.to_string(),
982 fs_extension: "zip".to_string(),
983 fs_path: format!("/{id}.zip"),
984 fs_size_bytes: 1,
985 name: name.to_string(),
986 slug: None,
987 summary: None,
988 path_cover_small: None,
989 path_cover_large: None,
990 url_cover: None,
991 has_manual: false,
992 path_manual: None,
993 url_manual: None,
994 is_unidentified: false,
995 is_identified: true,
996 files: Vec::new(),
997 }
998 }
999
1000 fn platform(id: u64, name: &str, rom_count: u64) -> Platform {
1001 serde_json::from_value(json!({
1002 "id": id,
1003 "slug": format!("p{id}"),
1004 "fs_slug": format!("p{id}"),
1005 "rom_count": rom_count,
1006 "name": name,
1007 "igdb_slug": null,
1008 "moby_slug": null,
1009 "hltb_slug": null,
1010 "custom_name": null,
1011 "igdb_id": null,
1012 "sgdb_id": null,
1013 "moby_id": null,
1014 "launchbox_id": null,
1015 "ss_id": null,
1016 "ra_id": null,
1017 "hasheous_id": null,
1018 "tgdb_id": null,
1019 "flashpoint_id": null,
1020 "category": null,
1021 "generation": null,
1022 "family_name": null,
1023 "family_slug": null,
1024 "url": null,
1025 "url_logo": null,
1026 "firmware": [],
1027 "aspect_ratio": null,
1028 "created_at": "",
1029 "updated_at": "",
1030 "fs_size_bytes": 0,
1031 "is_unidentified": false,
1032 "is_identified": true,
1033 "missing_from_fs": false,
1034 "display_name": null
1035 }))
1036 .expect("valid platform fixture")
1037 }
1038
1039 #[test]
1040 fn get_selected_group_clamps_stale_index_after_filter() {
1041 let mut s = LibraryBrowseScreen::new(vec![], vec![]);
1042 let items = vec![
1043 rom(1, "alpha", "a.zip"),
1044 rom(2, "alphabet", "ab.zip"),
1045 rom(3, "beta", "b.zip"),
1046 ];
1047 s.rom_groups = Some(utils::group_roms_by_name(&items));
1048 s.view_mode = LibraryViewMode::Roms;
1049 s.enter_rom_search(LibrarySearchMode::Filter);
1050 for c in "alp".chars() {
1051 s.add_rom_search_char(c);
1052 }
1053 s.rom_search.mode = None;
1054 s.rom_search.filter_browsing = true;
1055 s.rom_selected = 99;
1056 let (primary, _) = s
1057 .get_selected_group()
1058 .expect("clamped index should yield a group");
1059 assert_eq!(primary.name, "alpha");
1060 }
1061
1062 #[test]
1063 fn rom_next_wraps_within_filtered_list_when_filter_browsing() {
1064 let mut s = LibraryBrowseScreen::new(vec![], vec![]);
1065 let items = vec![
1066 rom(1, "alpha", "a.zip"),
1067 rom(2, "alphabet", "ab.zip"),
1068 rom(3, "beta", "b.zip"),
1069 ];
1070 s.rom_groups = Some(utils::group_roms_by_name(&items));
1071 s.view_mode = LibraryViewMode::Roms;
1072 s.enter_rom_search(LibrarySearchMode::Filter);
1073 for c in "alp".chars() {
1074 s.add_rom_search_char(c);
1075 }
1076 s.rom_search.mode = None;
1077 s.rom_search.filter_browsing = true;
1078 assert_eq!(s.rom_selected, 0);
1079 s.rom_next();
1080 assert_eq!(s.rom_selected, 1);
1081 s.rom_next();
1082 assert_eq!(s.rom_selected, 0);
1083 }
1084
1085 #[test]
1086 fn zero_rom_platform_builds_no_rom_request() {
1087 let s = LibraryBrowseScreen::new(vec![platform(1, "Empty", 0)], vec![]);
1088 assert!(
1089 s.get_roms_request_platform().is_none(),
1090 "zero-rom platform should not produce ROM API request"
1091 );
1092 }
1093
1094 #[test]
1095 fn back_to_list_retains_current_rom_state() {
1096 let mut s = LibraryBrowseScreen::new(vec![platform(1, "SNES", 12)], vec![]);
1097 let items = vec![rom(1, "alpha", "a.zip")];
1098 let rom_list = RomList {
1099 total: 1,
1100 limit: 1,
1101 offset: 0,
1102 items: items.clone(),
1103 };
1104 s.view_mode = LibraryViewMode::Roms;
1105 s.roms = Some(rom_list);
1106 s.rom_groups = Some(utils::group_roms_by_name(&items));
1107 s.set_rom_loading(true);
1108 s.back_to_list();
1109 assert_eq!(s.view_mode, LibraryViewMode::List);
1110 assert!(
1111 s.roms.is_some(),
1112 "back navigation should keep loaded ROM list"
1113 );
1114 assert!(
1115 s.rom_groups.is_some(),
1116 "back navigation should keep grouped ROM rows"
1117 );
1118 assert!(
1119 s.rom_loading,
1120 "back navigation should preserve in-flight loading state"
1121 );
1122 }
1123
1124 #[test]
1125 fn empty_state_message_shows_loading_only_when_loading_flag_is_true() {
1126 let mut s = LibraryBrowseScreen::new(vec![platform(1, "SNES", 12)], vec![]);
1127 s.clear_roms();
1128 s.set_rom_loading(false);
1129 assert_eq!(
1130 s.empty_rom_state_message(),
1131 "Select a console or collection and press Enter to load ROMs"
1132 );
1133 s.set_rom_loading(true);
1134 assert_eq!(s.empty_rom_state_message(), "Loading games...");
1135 }
1136}