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