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::types::{Collection, Platform, Rom, RomList};
10
11/// Which high-level grouping is currently shown in the left pane.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum LibrarySubsection {
14    ByConsole,
15    ByCollection,
16}
17
18/// Active search mode in the Games list.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum LibrarySearchMode {
21    /// Filter results to only show matches.
22    Filter,
23    /// Jump to the next match in the full list.
24    Jump,
25}
26
27/// Which side of the library view currently has focus.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum LibraryViewMode {
30    /// Left panel: list of consoles or collections
31    List,
32    /// Right panel: list of ROMs for selected console/collection
33    Roms,
34}
35
36/// Main library browser: consoles/collections on the left, games on the right.
37pub struct LibraryBrowseScreen {
38    pub platforms: Vec<Platform>,
39    pub collections: Vec<Collection>,
40    pub subsection: LibrarySubsection,
41    pub list_index: usize,
42    pub view_mode: LibraryViewMode,
43    pub roms: Option<RomList>,
44    /// One row per game name (base + updates/DLC grouped).
45    pub rom_groups: Option<Vec<RomGroup>>,
46    pub rom_selected: usize,
47    pub scroll_offset: usize,
48    /// Visible data rows in the ROM pane (updated at render time).
49    visible_rows: usize,
50    // Search state
51    pub search_query: String,
52    pub search_mode: Option<LibrarySearchMode>,
53    /// Normalized search query (de-accented, lowercase).
54    normalized_query: String,
55}
56
57impl LibraryBrowseScreen {
58    pub fn new(platforms: Vec<Platform>, collections: Vec<Collection>) -> Self {
59        Self {
60            platforms,
61            collections,
62            subsection: LibrarySubsection::ByConsole,
63            list_index: 0,
64            view_mode: LibraryViewMode::List,
65            roms: None,
66            rom_groups: None,
67            rom_selected: 0,
68            scroll_offset: 0,
69            visible_rows: 20, // reasonable default until first render
70            search_query: String::new(),
71            search_mode: None,
72            normalized_query: String::new(),
73        }
74    }
75
76    pub fn list_len(&self) -> usize {
77        match self.subsection {
78            LibrarySubsection::ByConsole => self.platforms.len(),
79            LibrarySubsection::ByCollection => self.collections.len(),
80        }
81    }
82
83    pub fn list_next(&mut self) {
84        let len = self.list_len();
85        if len > 0 {
86            self.list_index = (self.list_index + 1) % len;
87        }
88    }
89
90    pub fn list_previous(&mut self) {
91        let len = self.list_len();
92        if len > 0 {
93            self.list_index = if self.list_index == 0 {
94                len - 1
95            } else {
96                self.list_index - 1
97            };
98        }
99    }
100
101    pub fn rom_next(&mut self) {
102        if let Some(ref groups) = self.rom_groups {
103            if !groups.is_empty() {
104                self.rom_selected = (self.rom_selected + 1) % groups.len();
105                self.update_rom_scroll(self.visible_rows);
106            }
107        }
108    }
109
110    pub fn rom_previous(&mut self) {
111        if let Some(ref groups) = self.rom_groups {
112            if !groups.is_empty() {
113                self.rom_selected = if self.rom_selected == 0 {
114                    groups.len() - 1
115                } else {
116                    self.rom_selected - 1
117                };
118                self.update_rom_scroll(self.visible_rows);
119            }
120        }
121    }
122
123    /// Keep `rom_selected` within the visible window.
124    ///
125    /// `visible` is the number of data rows that fit on screen (set at
126    /// render time). This manual bookkeeping gives us fine-grained control
127    /// over scrolling behavior without storing a separate viewport object.
128    fn update_rom_scroll(&mut self, visible: usize) {
129        if let Some(ref groups) = self.rom_groups {
130            self.update_rom_scroll_with_len(groups.len(), visible);
131        }
132    }
133
134    fn update_rom_scroll_with_len(&mut self, list_len: usize, visible: usize) {
135        let visible = visible.max(1);
136        let max_scroll = list_len.saturating_sub(visible);
137        if self.rom_selected >= self.scroll_offset + visible {
138            self.scroll_offset = (self.rom_selected + 1).saturating_sub(visible);
139        } else if self.rom_selected < self.scroll_offset {
140            self.scroll_offset = self.rom_selected;
141        }
142        self.scroll_offset = self.scroll_offset.min(max_scroll);
143    }
144
145    pub fn switch_subsection(&mut self) {
146        self.subsection = match self.subsection {
147            LibrarySubsection::ByConsole => LibrarySubsection::ByCollection,
148            LibrarySubsection::ByCollection => LibrarySubsection::ByConsole,
149        };
150        self.list_index = 0;
151        self.roms = None;
152        self.view_mode = LibraryViewMode::List;
153    }
154
155    pub fn switch_view(&mut self) {
156        self.view_mode = match self.view_mode {
157            LibraryViewMode::List => LibraryViewMode::Roms,
158            LibraryViewMode::Roms => LibraryViewMode::List,
159        };
160        self.rom_selected = 0;
161        self.scroll_offset = 0;
162    }
163
164    pub fn back_to_list(&mut self) {
165        self.view_mode = LibraryViewMode::List;
166        self.roms = None;
167    }
168
169    /// Clear the ROM list (and groups) so the right panel does not show
170    /// another console/collection's games while loading the new selection.
171    pub fn clear_roms(&mut self) {
172        self.roms = None;
173        self.rom_groups = None;
174    }
175
176    /// Select a console or collection and load its ROMs.
177    pub fn set_roms(&mut self, roms: RomList) {
178        self.roms = Some(roms.clone());
179        self.rom_groups = Some(utils::group_roms_by_name(&roms.items));
180        self.rom_selected = 0;
181        self.scroll_offset = 0;
182        self.clear_search(); // reset search when changing consoles
183    }
184
185    // -- Search logic -------------------------------------------------------
186
187    pub fn enter_search(&mut self, mode: LibrarySearchMode) {
188        self.search_mode = Some(mode);
189        self.search_query.clear();
190        self.normalized_query.clear();
191        self.rom_selected = 0;
192        self.scroll_offset = 0;
193    }
194
195    pub fn clear_search(&mut self) {
196        self.search_mode = None;
197        self.search_query.clear();
198        self.normalized_query.clear();
199    }
200
201    pub fn add_search_char(&mut self, c: char) {
202        self.search_query.push(c);
203        self.normalized_query = self.normalize(&self.search_query);
204        if self.search_mode == Some(LibrarySearchMode::Filter) {
205            self.rom_selected = 0;
206            self.scroll_offset = 0;
207        } else if self.search_mode == Some(LibrarySearchMode::Jump) {
208            self.jump_to_match(false);
209        }
210    }
211
212    pub fn delete_search_char(&mut self) {
213        self.search_query.pop();
214        self.normalized_query = self.normalize(&self.search_query);
215        if self.search_mode == Some(LibrarySearchMode::Filter) {
216            self.rom_selected = 0;
217            self.scroll_offset = 0;
218        }
219    }
220
221    /// Helper to strip diacritics and convert to lowercase for searching.
222    fn normalize(&self, s: &str) -> String {
223        use unicode_normalization::UnicodeNormalization;
224        s.nfd()
225            .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
226            .collect::<String>()
227            .to_lowercase()
228    }
229
230    pub fn jump_to_match(&mut self, next: bool) {
231        if self.normalized_query.is_empty() {
232            return;
233        }
234        let Some(ref groups) = self.rom_groups else {
235            return;
236        };
237        let start_idx = if next {
238            (self.rom_selected + 1) % groups.len()
239        } else {
240            self.rom_selected
241        };
242
243        for i in 0..groups.len() {
244            let idx = (start_idx + i) % groups.len();
245            if self
246                .normalize(&groups[idx].name)
247                .contains(&self.normalized_query)
248            {
249                self.rom_selected = idx;
250                self.update_rom_scroll(self.visible_rows);
251                return;
252            }
253        }
254    }
255
256    /// Primary ROM and other files (updates/DLC) for the selected game.
257    pub fn get_selected_group(&self) -> Option<(Rom, Vec<Rom>)> {
258        self.visible_rom_groups()
259            .get(self.rom_selected)
260            .map(|g| (g.primary.clone(), g.others.clone()))
261    }
262
263    /// Actual list shown in the right pane (optionally filtered).
264    fn visible_rom_groups(&self) -> Vec<RomGroup> {
265        let Some(ref groups) = self.rom_groups else {
266            return Vec::new();
267        };
268        if self.search_mode == Some(LibrarySearchMode::Filter) && !self.normalized_query.is_empty()
269        {
270            groups
271                .iter()
272                .filter(|g| self.normalize(&g.name).contains(&self.normalized_query))
273                .cloned()
274                .collect()
275        } else {
276            groups.clone()
277        }
278    }
279
280    fn list_title(&self) -> &str {
281        match self.subsection {
282            LibrarySubsection::ByConsole => "Consoles",
283            LibrarySubsection::ByCollection => "Collections",
284        }
285    }
286
287    fn selected_platform_id(&self) -> Option<u64> {
288        match self.subsection {
289            LibrarySubsection::ByConsole => self.platforms.get(self.list_index).map(|p| p.id),
290            LibrarySubsection::ByCollection => None,
291        }
292    }
293
294    fn selected_collection_id(&self) -> Option<u64> {
295        match self.subsection {
296            LibrarySubsection::ByCollection => self.collections.get(self.list_index).map(|c| c.id),
297            LibrarySubsection::ByConsole => None,
298        }
299    }
300
301    /// Cache key for the currently selected console or collection.
302    pub fn cache_key(&self) -> Option<RomCacheKey> {
303        self.selected_platform_id()
304            .map(RomCacheKey::Platform)
305            .or_else(|| self.selected_collection_id().map(RomCacheKey::Collection))
306    }
307
308    /// Expected ROM count from the live platform/collection metadata.
309    /// Used to validate whether the disk cache is still fresh.
310    pub fn expected_rom_count(&self) -> u64 {
311        match self.subsection {
312            LibrarySubsection::ByConsole => self
313                .platforms
314                .get(self.list_index)
315                .map(|p| p.rom_count)
316                .unwrap_or(0),
317            LibrarySubsection::ByCollection => self
318                .collections
319                .get(self.list_index)
320                .and_then(|c| c.rom_count)
321                .unwrap_or(0),
322        }
323    }
324
325    pub fn get_roms_request_platform(&self) -> Option<GetRoms> {
326        let count = self.expected_rom_count().min(20000);
327        self.selected_platform_id().map(|id| GetRoms {
328            platform_id: Some(id),
329            limit: Some(count as u32),
330            ..Default::default()
331        })
332    }
333
334    pub fn get_roms_request_collection(&self) -> Option<GetRoms> {
335        let count = self.expected_rom_count().min(20000);
336        self.selected_collection_id().map(|id| GetRoms {
337            collection_id: Some(id),
338            limit: Some(count as u32),
339            ..Default::default()
340        })
341    }
342
343    pub fn render(&mut self, f: &mut Frame, area: Rect) {
344        let chunks = Layout::default()
345            .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
346            .direction(ratatui::layout::Direction::Horizontal)
347            .split(area);
348
349        self.render_list(f, chunks[0]);
350
351        let right_chunks = if self.search_mode.is_some() {
352            Layout::default()
353                .constraints([
354                    Constraint::Length(3),
355                    Constraint::Min(5),
356                    Constraint::Length(3),
357                ])
358                .direction(ratatui::layout::Direction::Vertical)
359                .split(chunks[1])
360        } else {
361            Layout::default()
362                .constraints([Constraint::Min(5), Constraint::Length(3)])
363                .direction(ratatui::layout::Direction::Vertical)
364                .split(chunks[1])
365        };
366
367        if let Some(mode) = self.search_mode {
368            let title = match mode {
369                LibrarySearchMode::Filter => "Filter Search",
370                LibrarySearchMode::Jump => "Jump Search (Tab to next)",
371            };
372            let p = ratatui::widgets::Paragraph::new(format!("Search: {}", self.search_query))
373                .block(Block::default().title(title).borders(Borders::ALL));
374            f.render_widget(p, right_chunks[0]);
375            self.render_roms(f, right_chunks[1]);
376            self.render_help(f, right_chunks[2]);
377        } else {
378            self.render_roms(f, right_chunks[0]);
379            self.render_help(f, right_chunks[1]);
380        }
381    }
382
383    fn render_list(&self, f: &mut Frame, area: Rect) {
384        let items: Vec<ListItem> = match self.subsection {
385            LibrarySubsection::ByConsole => self
386                .platforms
387                .iter()
388                .enumerate()
389                .map(|(idx, p)| {
390                    let name = p.display_name.as_deref().unwrap_or(&p.name);
391                    let count = p.rom_count;
392                    let prefix =
393                        if idx == self.list_index && self.view_mode == LibraryViewMode::List {
394                            "▶ "
395                        } else {
396                            "  "
397                        };
398                    ListItem::new(format!("{}{} ({} roms)", prefix, name, count))
399                })
400                .collect(),
401            LibrarySubsection::ByCollection => self
402                .collections
403                .iter()
404                .enumerate()
405                .map(|(idx, c)| {
406                    let count = c.rom_count.unwrap_or(0);
407                    let prefix =
408                        if idx == self.list_index && self.view_mode == LibraryViewMode::List {
409                            "▶ "
410                        } else {
411                            "  "
412                        };
413                    ListItem::new(format!("{}{} ({} roms)", prefix, c.name, count))
414                })
415                .collect(),
416        };
417
418        let list = List::new(items)
419            .block(
420                Block::default()
421                    .title(self.list_title())
422                    .borders(Borders::ALL),
423            )
424            .highlight_symbol(if self.view_mode == LibraryViewMode::List {
425                ">> "
426            } else {
427                "   "
428            });
429
430        let mut state = ListState::default();
431        if self.view_mode == LibraryViewMode::List {
432            state.select(Some(self.list_index));
433        }
434
435        f.render_stateful_widget(list, area, &mut state);
436    }
437
438    fn render_roms(&mut self, f: &mut Frame, area: Rect) {
439        let visible = (area.height as usize).saturating_sub(3).max(1);
440        self.visible_rows = visible;
441
442        let groups = self.visible_rom_groups();
443        if groups.is_empty() {
444            let msg = if self.search_mode.is_some() {
445                "No games match your search".to_string()
446            } else if self.roms.is_none() && self.expected_rom_count() > 0 {
447                format!("Loading {} games... please wait", self.expected_rom_count())
448            } else {
449                "Select a console or collection and press Enter to load ROMs".to_string()
450            };
451            let p = ratatui::widgets::Paragraph::new(msg)
452                .block(Block::default().title("Games").borders(Borders::ALL));
453            f.render_widget(p, area);
454            return;
455        }
456
457        // Keep selection in bounds if filtering just reduced the count.
458        if self.rom_selected >= groups.len() {
459            self.rom_selected = 0;
460            self.scroll_offset = 0;
461        }
462
463        self.update_rom_scroll_with_len(groups.len(), visible);
464
465        let start = self.scroll_offset.min(groups.len().saturating_sub(visible));
466        let end = (start + visible).min(groups.len());
467        let visible_groups = &groups[start..end];
468
469        let header = Row::new(vec![
470            Cell::from("Name").style(Style::default().fg(Color::Cyan))
471        ]);
472        let rows: Vec<Row> = visible_groups
473            .iter()
474            .enumerate()
475            .map(|(i, g)| {
476                let global_idx = start + i;
477                let style = if global_idx == self.rom_selected {
478                    Style::default().fg(Color::Yellow)
479                } else {
480                    Style::default()
481                };
482                Row::new(vec![Cell::from(g.name.as_str()).style(style)]).height(1)
483            })
484            .collect();
485
486        let total_files = self.roms.as_ref().map(|r| r.items.len()).unwrap_or(0);
487        let total_roms = self.roms.as_ref().map(|r| r.total).unwrap_or(0);
488        let title = if total_roms > 0 && (groups.len() as u64) < total_roms {
489            format!(
490                "Games ({} of {}) — {} files",
491                groups.len(),
492                total_roms,
493                total_files
494            )
495        } else {
496            format!("Games ({}) — {} files", groups.len(), total_files)
497        };
498        let widths = [Constraint::Percentage(100)];
499        let table = Table::new(rows, widths)
500            .header(header)
501            .block(Block::default().title(title).borders(Borders::ALL));
502
503        f.render_widget(table, area);
504    }
505
506    fn render_help(&self, f: &mut Frame, area: Rect) {
507        let help = match self.view_mode {
508            LibraryViewMode::List => "t: Switch Console/Collection | ↑↓: Select (games load) | Enter: Focus games | Esc: Back",
509            LibraryViewMode::Roms => "←: Back to list | ↑↓: Navigate | Enter: Game detail | Esc: Back",
510        };
511        let p =
512            ratatui::widgets::Paragraph::new(help).block(Block::default().borders(Borders::ALL));
513        f.render_widget(p, area);
514    }
515}