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
16pub use crate::tui::text_search::LibrarySearchMode;
17
18/// Path input for TUI upload (single file; type or paste a filesystem path).
19#[derive(Debug, Clone)]
20pub struct UploadPrompt {
21    pub path: String,
22    pub cursor_pos: usize,
23    /// When true, run `scan_library` with wait after upload (matches CLI `roms upload --scan --wait`).
24    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/// Which high-level grouping is currently shown in the left pane.
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub enum LibrarySubsection {
84    ByConsole,
85    ByCollection,
86}
87
88/// Which side of the library view currently has focus.
89#[derive(Debug, Clone, Copy, PartialEq)]
90pub enum LibraryViewMode {
91    /// Left panel: list of consoles or collections
92    List,
93    /// Right panel: list of ROMs for selected console/collection
94    Roms,
95}
96
97/// Main library browser: consoles/collections on the left, games on the right.
98pub 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    /// One row per game name (base + updates/DLC grouped).
106    pub rom_groups: Option<Vec<RomGroup>>,
107    pub rom_selected: usize,
108    pub scroll_offset: usize,
109    /// Visible data rows in the ROM pane (updated at render time).
110    visible_rows: usize,
111    /// Filter/jump for the consoles/collections list (left pane).
112    pub list_search: SearchState,
113    /// Filter/jump for the games table (right pane).
114    pub rom_search: SearchState,
115    /// Non-blocking status from metadata refresh (API warnings, “updated”, etc.).
116    pub metadata_footer: Option<String>,
117    /// True only while ROM data for the current selection is actively loading.
118    pub rom_loading: bool,
119    /// Modal path entry for uploading a ROM to the selected console (`None` when closed).
120    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    /// `Ctrl+u` upload path modal is open.
149    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    /// Replace metadata while preserving subsection and selection when possible.
248    ///
249    /// Returns `true` when the selected row identity or expected count changed,
250    /// which means ROM pane data should be reloaded.
251    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    /// Build near-neighbor collection prefetch candidates around current selection.
304    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    /// True while either pane has the search typing bar open (blocks global shortcuts).
336    pub fn any_search_bar_open(&self) -> bool {
337        self.list_search.mode.is_some() || self.rom_search.mode.is_some()
338    }
339
340    /// Display strings for each row (same text users see, without selection prefix).
341    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    /// Source index into `platforms` / `collections` for the current list selection.
385    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    // -- List search --------------------------------------------------------
504
505    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    // -- ROM search ---------------------------------------------------------
565
566    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    /// Cursor for the upload path field (when [`Self::upload_prompt`] is open).
811    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}