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