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