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::{Block, Borders, Cell, List, ListItem, ListState, Row, Table};
4use ratatui::Frame;
5
6use crate::core::cache::RomCacheKey;
7use crate::core::utils::{self, RomGroup};
8use crate::endpoints::roms::GetRoms;
9use crate::tui::text_search::{
10    filter_source_indices, jump_next_index, normalize_label, SearchState,
11};
12use crate::types::{Collection, Platform, Rom, RomList};
13
14pub use crate::tui::text_search::LibrarySearchMode;
15
16/// Which high-level grouping is currently shown in the left pane.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum LibrarySubsection {
19    ByConsole,
20    ByCollection,
21}
22
23/// Which side of the library view currently has focus.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub enum LibraryViewMode {
26    /// Left panel: list of consoles or collections
27    List,
28    /// Right panel: list of ROMs for selected console/collection
29    Roms,
30}
31
32/// Main library browser: consoles/collections on the left, games on the right.
33pub struct LibraryBrowseScreen {
34    pub platforms: Vec<Platform>,
35    pub collections: Vec<Collection>,
36    pub subsection: LibrarySubsection,
37    pub list_index: usize,
38    pub view_mode: LibraryViewMode,
39    pub roms: Option<RomList>,
40    /// One row per game name (base + updates/DLC grouped).
41    pub rom_groups: Option<Vec<RomGroup>>,
42    pub rom_selected: usize,
43    pub scroll_offset: usize,
44    /// Visible data rows in the ROM pane (updated at render time).
45    visible_rows: usize,
46    /// Filter/jump for the consoles/collections list (left pane).
47    pub list_search: SearchState,
48    /// Filter/jump for the games table (right pane).
49    pub rom_search: SearchState,
50}
51
52impl LibraryBrowseScreen {
53    pub fn new(platforms: Vec<Platform>, collections: Vec<Collection>) -> Self {
54        Self {
55            platforms,
56            collections,
57            subsection: LibrarySubsection::ByConsole,
58            list_index: 0,
59            view_mode: LibraryViewMode::List,
60            roms: None,
61            rom_groups: None,
62            rom_selected: 0,
63            scroll_offset: 0,
64            visible_rows: 20,
65            list_search: SearchState::new(),
66            rom_search: SearchState::new(),
67        }
68    }
69
70    /// True while either pane has the search typing bar open (blocks global shortcuts).
71    pub fn any_search_bar_open(&self) -> bool {
72        self.list_search.mode.is_some() || self.rom_search.mode.is_some()
73    }
74
75    /// Display strings for each row (same text users see, without selection prefix).
76    fn list_row_labels(&self) -> Vec<String> {
77        match self.subsection {
78            LibrarySubsection::ByConsole => self
79                .platforms
80                .iter()
81                .map(|p| {
82                    let name = p.display_name.as_deref().unwrap_or(&p.name);
83                    format!("{} ({} roms)", name, p.rom_count)
84                })
85                .collect(),
86            LibrarySubsection::ByCollection => self
87                .collections
88                .iter()
89                .map(|c| {
90                    let title = if c.is_virtual {
91                        format!("{} [auto]", c.name)
92                    } else if c.is_smart {
93                        format!("{} [smart]", c.name)
94                    } else {
95                        c.name.clone()
96                    };
97                    format!("{} ({} roms)", title, c.rom_count.unwrap_or(0))
98                })
99                .collect(),
100        }
101    }
102
103    fn visible_list_source_indices(&self) -> Vec<usize> {
104        let labels = self.list_row_labels();
105        if self.list_search.filter_active() {
106            filter_source_indices(&labels, &self.list_search.normalized_query)
107        } else {
108            (0..labels.len()).collect()
109        }
110    }
111
112    fn clamp_list_index(&mut self) {
113        let v = self.visible_list_source_indices();
114        if v.is_empty() || self.list_index >= v.len() {
115            self.list_index = 0;
116        }
117    }
118
119    /// Source index into `platforms` / `collections` for the current list selection.
120    fn selected_list_source_index(&self) -> Option<usize> {
121        let v = self.visible_list_source_indices();
122        v.get(self.list_index).copied()
123    }
124
125    pub fn list_len(&self) -> usize {
126        self.visible_list_source_indices().len()
127    }
128
129    pub fn list_next(&mut self) {
130        let len = self.list_len();
131        if len > 0 {
132            self.list_index = (self.list_index + 1) % len;
133        }
134    }
135
136    pub fn list_previous(&mut self) {
137        let len = self.list_len();
138        if len > 0 {
139            self.list_index = if self.list_index == 0 {
140                len - 1
141            } else {
142                self.list_index - 1
143            };
144        }
145    }
146
147    pub fn rom_next(&mut self) {
148        let groups = self.visible_rom_groups();
149        let len = groups.len();
150        if len > 0 {
151            self.rom_selected = (self.rom_selected + 1) % len;
152            self.update_rom_scroll(self.visible_rows);
153        }
154    }
155
156    pub fn rom_previous(&mut self) {
157        let groups = self.visible_rom_groups();
158        let len = groups.len();
159        if len > 0 {
160            self.rom_selected = if self.rom_selected == 0 {
161                len - 1
162            } else {
163                self.rom_selected - 1
164            };
165            self.update_rom_scroll(self.visible_rows);
166        }
167    }
168
169    fn update_rom_scroll(&mut self, visible: usize) {
170        if self.rom_groups.is_none() {
171            return;
172        }
173        let list_len = self.visible_rom_groups().len();
174        self.update_rom_scroll_with_len(list_len, visible);
175    }
176
177    fn update_rom_scroll_with_len(&mut self, list_len: usize, visible: usize) {
178        let visible = visible.max(1);
179        let max_scroll = list_len.saturating_sub(visible);
180        if self.rom_selected >= self.scroll_offset + visible {
181            self.scroll_offset = (self.rom_selected + 1).saturating_sub(visible);
182        } else if self.rom_selected < self.scroll_offset {
183            self.scroll_offset = self.rom_selected;
184        }
185        self.scroll_offset = self.scroll_offset.min(max_scroll);
186    }
187
188    pub fn switch_subsection(&mut self) {
189        self.subsection = match self.subsection {
190            LibrarySubsection::ByConsole => LibrarySubsection::ByCollection,
191            LibrarySubsection::ByCollection => LibrarySubsection::ByConsole,
192        };
193        self.list_index = 0;
194        self.roms = None;
195        self.view_mode = LibraryViewMode::List;
196        self.list_search.clear();
197    }
198
199    pub fn switch_view(&mut self) {
200        match self.view_mode {
201            LibraryViewMode::List => {
202                self.list_search.clear();
203                self.view_mode = LibraryViewMode::Roms;
204            }
205            LibraryViewMode::Roms => {
206                self.rom_search.clear();
207                self.view_mode = LibraryViewMode::List;
208            }
209        }
210        self.rom_selected = 0;
211        self.scroll_offset = 0;
212    }
213
214    pub fn back_to_list(&mut self) {
215        self.rom_search.clear();
216        self.view_mode = LibraryViewMode::List;
217        self.clear_roms();
218    }
219
220    pub fn clear_roms(&mut self) {
221        self.roms = None;
222        self.rom_groups = None;
223    }
224
225    pub fn set_roms(&mut self, roms: RomList) {
226        self.roms = Some(roms.clone());
227        self.rom_groups = Some(utils::group_roms_by_name(&roms.items));
228        self.rom_selected = 0;
229        self.scroll_offset = 0;
230        self.rom_search.clear();
231    }
232
233    // -- List search --------------------------------------------------------
234
235    pub fn enter_list_search(&mut self, mode: LibrarySearchMode) {
236        self.list_search.enter(mode);
237        self.list_index = 0;
238    }
239
240    pub fn clear_list_search(&mut self) {
241        self.list_search.clear();
242        self.clamp_list_index();
243    }
244
245    pub fn add_list_search_char(&mut self, c: char) {
246        self.list_search.add_char(c);
247        if self.list_search.mode == Some(LibrarySearchMode::Filter) {
248            self.list_index = 0;
249        } else if self.list_search.mode == Some(LibrarySearchMode::Jump) {
250            self.list_jump_match(false);
251        }
252        self.clamp_list_index();
253    }
254
255    pub fn delete_list_search_char(&mut self) {
256        self.list_search.delete_char();
257        if self.list_search.mode == Some(LibrarySearchMode::Filter) {
258            self.list_index = 0;
259        }
260        self.clamp_list_index();
261    }
262
263    pub fn commit_list_filter_bar(&mut self) {
264        self.list_search.commit_filter_bar();
265        self.clamp_list_index();
266    }
267
268    pub fn commit_rom_filter_bar(&mut self) {
269        self.rom_search.commit_filter_bar();
270    }
271
272    pub fn list_jump_match(&mut self, next: bool) {
273        if self.list_search.normalized_query.is_empty() {
274            return;
275        }
276        let labels = self.list_row_labels();
277        if labels.is_empty() {
278            return;
279        }
280        let source = self
281            .selected_list_source_index()
282            .unwrap_or(0)
283            .min(labels.len().saturating_sub(1));
284        if let Some(new_src) =
285            jump_next_index(&labels, source, &self.list_search.normalized_query, next)
286        {
287            let visible = self.visible_list_source_indices();
288            if let Some(pos) = visible.iter().position(|&i| i == new_src) {
289                self.list_index = pos;
290            }
291        }
292    }
293
294    // -- ROM search ---------------------------------------------------------
295
296    pub fn enter_rom_search(&mut self, mode: LibrarySearchMode) {
297        self.rom_search.enter(mode);
298        self.rom_selected = 0;
299        self.scroll_offset = 0;
300    }
301
302    pub fn clear_rom_search(&mut self) {
303        self.rom_search.clear();
304    }
305
306    pub fn add_rom_search_char(&mut self, c: char) {
307        self.rom_search.add_char(c);
308        if self.rom_search.mode == Some(LibrarySearchMode::Filter) {
309            self.rom_selected = 0;
310            self.scroll_offset = 0;
311        } else if self.rom_search.mode == Some(LibrarySearchMode::Jump) {
312            self.jump_rom_match(false);
313        }
314    }
315
316    pub fn delete_rom_search_char(&mut self) {
317        self.rom_search.delete_char();
318        if self.rom_search.mode == Some(LibrarySearchMode::Filter) {
319            self.rom_selected = 0;
320            self.scroll_offset = 0;
321        }
322    }
323
324    pub fn jump_rom_match(&mut self, next: bool) {
325        if self.rom_search.normalized_query.is_empty() {
326            return;
327        }
328        let Some(ref groups) = self.rom_groups else {
329            return;
330        };
331        let labels: Vec<String> = groups.iter().map(|g| g.name.clone()).collect();
332        if labels.is_empty() {
333            return;
334        }
335        let source = self.rom_selected.min(labels.len().saturating_sub(1));
336        if let Some(idx) = jump_next_index(&labels, source, &self.rom_search.normalized_query, next)
337        {
338            self.rom_selected = idx;
339            self.update_rom_scroll(self.visible_rows);
340        }
341    }
342
343    pub fn get_selected_group(&self) -> Option<(Rom, Vec<Rom>)> {
344        let visible = self.visible_rom_groups();
345        if visible.is_empty() {
346            return None;
347        }
348        let idx = if self.rom_selected >= visible.len() {
349            0
350        } else {
351            self.rom_selected
352        };
353        visible
354            .get(idx)
355            .map(|g| (g.primary.clone(), g.others.clone()))
356    }
357
358    fn visible_rom_groups(&self) -> Vec<RomGroup> {
359        let Some(ref groups) = self.rom_groups else {
360            return Vec::new();
361        };
362        if self.rom_search.filter_active() {
363            groups
364                .iter()
365                .filter(|g| normalize_label(&g.name).contains(&self.rom_search.normalized_query))
366                .cloned()
367                .collect()
368        } else {
369            groups.clone()
370        }
371    }
372
373    fn list_title(&self) -> &str {
374        match self.subsection {
375            LibrarySubsection::ByConsole => "Consoles",
376            LibrarySubsection::ByCollection => "Collections",
377        }
378    }
379
380    fn selected_platform_id(&self) -> Option<u64> {
381        match self.subsection {
382            LibrarySubsection::ByConsole => self
383                .selected_list_source_index()
384                .and_then(|i| self.platforms.get(i).map(|p| p.id)),
385            LibrarySubsection::ByCollection => None,
386        }
387    }
388
389    pub fn cache_key(&self) -> Option<RomCacheKey> {
390        match self.subsection {
391            LibrarySubsection::ByConsole => self.selected_platform_id().map(RomCacheKey::Platform),
392            LibrarySubsection::ByCollection => self
393                .selected_list_source_index()
394                .and_then(|i| self.collections.get(i))
395                .map(|c| {
396                    if c.is_virtual {
397                        RomCacheKey::VirtualCollection(c.virtual_id.clone().unwrap_or_default())
398                    } else if c.is_smart {
399                        RomCacheKey::SmartCollection(c.id)
400                    } else {
401                        RomCacheKey::Collection(c.id)
402                    }
403                }),
404        }
405    }
406
407    pub fn expected_rom_count(&self) -> u64 {
408        match self.subsection {
409            LibrarySubsection::ByConsole => self
410                .selected_list_source_index()
411                .and_then(|i| self.platforms.get(i).map(|p| p.rom_count))
412                .unwrap_or(0),
413            LibrarySubsection::ByCollection => self
414                .selected_list_source_index()
415                .and_then(|i| self.collections.get(i))
416                .and_then(|c| c.rom_count)
417                .unwrap_or(0),
418        }
419    }
420
421    pub fn get_roms_request_platform(&self) -> Option<GetRoms> {
422        let count = self.expected_rom_count().min(20000);
423        self.selected_platform_id().map(|id| GetRoms {
424            platform_id: Some(id),
425            limit: Some(count as u32),
426            ..Default::default()
427        })
428    }
429
430    pub fn get_roms_request_collection(&self) -> Option<GetRoms> {
431        if self.subsection != LibrarySubsection::ByCollection {
432            return None;
433        }
434        let count = self.expected_rom_count().min(20000);
435        self.selected_list_source_index()
436            .and_then(|i| self.collections.get(i))
437            .map(|c| {
438                if c.is_virtual {
439                    GetRoms {
440                        virtual_collection_id: c.virtual_id.clone(),
441                        limit: Some(count as u32),
442                        ..Default::default()
443                    }
444                } else if c.is_smart {
445                    GetRoms {
446                        smart_collection_id: Some(c.id),
447                        limit: Some(count as u32),
448                        ..Default::default()
449                    }
450                } else {
451                    GetRoms {
452                        collection_id: Some(c.id),
453                        limit: Some(count as u32),
454                        ..Default::default()
455                    }
456                }
457            })
458    }
459
460    pub fn render(&mut self, f: &mut Frame, area: Rect) {
461        let chunks = Layout::default()
462            .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
463            .direction(ratatui::layout::Direction::Horizontal)
464            .split(area);
465
466        let left_area = chunks[0];
467        if self.list_search.mode.is_some() {
468            let left_chunks = Layout::default()
469                .constraints([Constraint::Length(3), Constraint::Min(3)])
470                .direction(ratatui::layout::Direction::Vertical)
471                .split(left_area);
472            if let Some(mode) = self.list_search.mode {
473                let title = match mode {
474                    LibrarySearchMode::Filter => "Filter Search (list)",
475                    LibrarySearchMode::Jump => "Jump Search (list, Tab next)",
476                };
477                let p =
478                    ratatui::widgets::Paragraph::new(format!("Search: {}", self.list_search.query))
479                        .block(Block::default().title(title).borders(Borders::ALL));
480                f.render_widget(p, left_chunks[0]);
481            }
482            self.render_list(f, left_chunks[1]);
483        } else {
484            self.render_list(f, left_area);
485        }
486
487        let right_chunks = if self.rom_search.mode.is_some() {
488            Layout::default()
489                .constraints([
490                    Constraint::Length(3),
491                    Constraint::Min(5),
492                    Constraint::Length(3),
493                ])
494                .direction(ratatui::layout::Direction::Vertical)
495                .split(chunks[1])
496        } else {
497            Layout::default()
498                .constraints([Constraint::Min(5), Constraint::Length(3)])
499                .direction(ratatui::layout::Direction::Vertical)
500                .split(chunks[1])
501        };
502
503        if let Some(mode) = self.rom_search.mode {
504            let title = match mode {
505                LibrarySearchMode::Filter => "Filter Search",
506                LibrarySearchMode::Jump => "Jump Search (Tab to next)",
507            };
508            let p = ratatui::widgets::Paragraph::new(format!("Search: {}", self.rom_search.query))
509                .block(Block::default().title(title).borders(Borders::ALL));
510            f.render_widget(p, right_chunks[0]);
511            self.render_roms(f, right_chunks[1]);
512            self.render_help(f, right_chunks[2]);
513        } else {
514            self.render_roms(f, right_chunks[0]);
515            self.render_help(f, right_chunks[1]);
516        }
517    }
518
519    fn render_list(&self, f: &mut Frame, area: Rect) {
520        let visible = self.visible_list_source_indices();
521        let labels = self.list_row_labels();
522
523        let items: Vec<ListItem> = visible
524            .iter()
525            .enumerate()
526            .map(|(pos, &source_idx)| {
527                let line = labels
528                    .get(source_idx)
529                    .cloned()
530                    .unwrap_or_else(|| "?".to_string());
531                let prefix = if pos == self.list_index && self.view_mode == LibraryViewMode::List {
532                    "▶ "
533                } else {
534                    "  "
535                };
536                ListItem::new(format!("{}{}", prefix, line))
537            })
538            .collect();
539
540        let list = List::new(items)
541            .block(
542                Block::default()
543                    .title(self.list_title())
544                    .borders(Borders::ALL),
545            )
546            .highlight_symbol(if self.view_mode == LibraryViewMode::List {
547                ">> "
548            } else {
549                "   "
550            });
551
552        let mut state = ListState::default();
553        if self.view_mode == LibraryViewMode::List {
554            state.select(Some(self.list_index));
555        }
556
557        f.render_stateful_widget(list, area, &mut state);
558    }
559
560    fn render_roms(&mut self, f: &mut Frame, area: Rect) {
561        let visible = (area.height as usize).saturating_sub(3).max(1);
562        self.visible_rows = visible;
563
564        let groups = self.visible_rom_groups();
565        if groups.is_empty() {
566            let msg = if self.rom_search.mode.is_some() {
567                "No games match your search".to_string()
568            } else if self.roms.is_none() && self.expected_rom_count() > 0 {
569                format!("Loading {} games... please wait", self.expected_rom_count())
570            } else {
571                "Select a console or collection and press Enter to load ROMs".to_string()
572            };
573            let p = ratatui::widgets::Paragraph::new(msg)
574                .block(Block::default().title("Games").borders(Borders::ALL));
575            f.render_widget(p, area);
576            return;
577        }
578
579        if self.rom_selected >= groups.len() {
580            self.rom_selected = 0;
581            self.scroll_offset = 0;
582        }
583
584        self.update_rom_scroll_with_len(groups.len(), visible);
585
586        let start = self.scroll_offset.min(groups.len().saturating_sub(visible));
587        let end = (start + visible).min(groups.len());
588        let visible_groups = &groups[start..end];
589
590        let header = Row::new(vec![
591            Cell::from("Name").style(Style::default().fg(Color::Cyan))
592        ]);
593        let rows: Vec<Row> = visible_groups
594            .iter()
595            .enumerate()
596            .map(|(i, g)| {
597                let global_idx = start + i;
598                let style = if global_idx == self.rom_selected {
599                    Style::default().fg(Color::Yellow)
600                } else {
601                    Style::default()
602                };
603                Row::new(vec![Cell::from(g.name.as_str()).style(style)]).height(1)
604            })
605            .collect();
606
607        let total_files = self.roms.as_ref().map(|r| r.items.len()).unwrap_or(0);
608        let total_roms = self.roms.as_ref().map(|r| r.total).unwrap_or(0);
609        let title = if self.rom_search.filter_browsing && !self.rom_search.query.is_empty() {
610            format!(
611                "Games (filtered: \"{}\") — {} — {} files",
612                self.rom_search.query,
613                groups.len(),
614                total_files
615            )
616        } else if total_roms > 0 && (groups.len() as u64) < total_roms {
617            format!(
618                "Games ({} of {}) — {} files",
619                groups.len(),
620                total_roms,
621                total_files
622            )
623        } else {
624            format!("Games ({}) — {} files", groups.len(), total_files)
625        };
626        let widths = [Constraint::Percentage(100)];
627        let table = Table::new(rows, widths)
628            .header(header)
629            .block(Block::default().title(title).borders(Borders::ALL));
630
631        f.render_widget(table, area);
632    }
633
634    fn render_help(&self, f: &mut Frame, area: Rect) {
635        let help = match self.view_mode {
636            LibraryViewMode::List => {
637                if self.list_search.mode.is_some() {
638                    "Type filter | Enter: browse matches | Esc: clear"
639                } else if self.list_search.filter_browsing {
640                    "↑↓: Navigate | Enter: Load games | Esc: clear filter"
641                } else {
642                    "t: Switch | ↑↓: Select | / f: Filter/Jump list | Enter: Games | Esc: Menu"
643                }
644            }
645            LibraryViewMode::Roms => {
646                if self.rom_search.mode.is_some() {
647                    "Type filter | Enter: browse matches | Esc: clear filter"
648                } else if self.rom_search.filter_browsing {
649                    "←: Back to list | ↑↓: Navigate | Enter: Game detail | Esc: clear filter"
650                } else {
651                    "←: Back to list | ↑↓: Navigate | / f: Filter/Jump games | Enter: Game detail | Esc: Back"
652                }
653            }
654        };
655        let p =
656            ratatui::widgets::Paragraph::new(help).block(Block::default().borders(Borders::ALL));
657        f.render_widget(p, area);
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664    use crate::core::utils;
665    use crate::types::Rom;
666
667    fn rom(id: u64, name: &str, fs_name: &str) -> Rom {
668        Rom {
669            id,
670            platform_id: 1,
671            platform_slug: None,
672            platform_fs_slug: None,
673            platform_custom_name: None,
674            platform_display_name: None,
675            fs_name: fs_name.to_string(),
676            fs_name_no_tags: name.to_string(),
677            fs_name_no_ext: name.to_string(),
678            fs_extension: "zip".to_string(),
679            fs_path: format!("/{id}.zip"),
680            fs_size_bytes: 1,
681            name: name.to_string(),
682            slug: None,
683            summary: None,
684            path_cover_small: None,
685            path_cover_large: None,
686            url_cover: None,
687            is_unidentified: false,
688            is_identified: true,
689        }
690    }
691
692    #[test]
693    fn get_selected_group_clamps_stale_index_after_filter() {
694        let mut s = LibraryBrowseScreen::new(vec![], vec![]);
695        let items = vec![
696            rom(1, "alpha", "a.zip"),
697            rom(2, "alphabet", "ab.zip"),
698            rom(3, "beta", "b.zip"),
699        ];
700        s.rom_groups = Some(utils::group_roms_by_name(&items));
701        s.view_mode = LibraryViewMode::Roms;
702        s.enter_rom_search(LibrarySearchMode::Filter);
703        for c in "alp".chars() {
704            s.add_rom_search_char(c);
705        }
706        s.rom_search.mode = None;
707        s.rom_search.filter_browsing = true;
708        s.rom_selected = 99;
709        let (primary, _) = s
710            .get_selected_group()
711            .expect("clamped index should yield a group");
712        assert_eq!(primary.name, "alpha");
713    }
714
715    #[test]
716    fn rom_next_wraps_within_filtered_list_when_filter_browsing() {
717        let mut s = LibraryBrowseScreen::new(vec![], vec![]);
718        let items = vec![
719            rom(1, "alpha", "a.zip"),
720            rom(2, "alphabet", "ab.zip"),
721            rom(3, "beta", "b.zip"),
722        ];
723        s.rom_groups = Some(utils::group_roms_by_name(&items));
724        s.view_mode = LibraryViewMode::Roms;
725        s.enter_rom_search(LibrarySearchMode::Filter);
726        for c in "alp".chars() {
727            s.add_rom_search_char(c);
728        }
729        s.rom_search.mode = None;
730        s.rom_search.filter_browsing = true;
731        assert_eq!(s.rom_selected, 0);
732        s.rom_next();
733        assert_eq!(s.rom_selected, 1);
734        s.rom_next();
735        assert_eq!(s.rom_selected, 0);
736    }
737}