Skip to main content

romm_cli/tui/screens/
library_browse.rs

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/// File path picker for TUI upload (single ROM file).
21#[derive(Debug)]
22pub struct UploadPrompt {
23    pub picker: PathPicker,
24    /// When true, run `scan_library` with wait after upload (matches CLI `roms upload --scan --wait`).
25    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/// Which high-level grouping is currently shown in the left pane.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum LibrarySubsection {
40    ByConsole,
41    ByCollection,
42}
43
44/// Which side of the library view currently has focus.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub enum LibraryViewMode {
47    /// Left panel: list of consoles or collections
48    List,
49    /// Right panel: list of ROMs for selected console/collection
50    Roms,
51}
52
53/// Main library browser: consoles/collections on the left, games on the right.
54pub 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    /// One row per game name (base + updates/DLC grouped).
62    pub rom_groups: Option<Vec<RomGroup>>,
63    pub rom_selected: usize,
64    pub scroll_offset: usize,
65    /// Visible data rows in the ROM pane (updated at render time).
66    visible_rows: usize,
67    /// Filter/jump for the consoles/collections list (left pane).
68    pub list_search: SearchState,
69    /// Filter/jump for the games table (right pane).
70    pub rom_search: SearchState,
71    /// Non-blocking status from metadata refresh (API warnings, “updated”, etc.).
72    pub metadata_footer: Option<String>,
73    /// True only while ROM data for the current selection is actively loading.
74    pub rom_loading: bool,
75    /// Modal path entry for uploading a ROM to the selected console (`None` when closed).
76    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    /// `Ctrl+u` upload path modal is open.
105    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    /// Replace metadata while preserving subsection and selection when possible.
204    ///
205    /// Returns `true` when the selected row identity or expected count changed,
206    /// which means ROM pane data should be reloaded.
207    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    /// Build near-neighbor collection prefetch candidates around current selection.
260    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    /// True while either pane has the search typing bar open (blocks global shortcuts).
292    pub fn any_search_bar_open(&self) -> bool {
293        self.list_search.mode.is_some() || self.rom_search.mode.is_some()
294    }
295
296    /// Display strings for each row (same text users see, without selection prefix).
297    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    /// Source index into `platforms` / `collections` for the current list selection.
341    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    // -- List search --------------------------------------------------------
460
461    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    // -- ROM search ---------------------------------------------------------
521
522    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    /// Cursor for the upload path field (when [`Self::upload_prompt`] is open).
780    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}