Skip to main content

romm_cli/tui/screens/
search.rs

1use ratatui::layout::{Constraint, Layout, Rect};
2use ratatui::style::{Color, Style};
3use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
4use ratatui::Frame;
5
6use crate::core::utils::{self, RomGroup};
7use crate::types::{Rom, RomList};
8
9/// Full-text search screen over ROMs, with grouped results.
10pub struct SearchScreen {
11    pub query: String,
12    pub cursor_pos: usize,
13    pub results: Option<RomList>,
14    /// One row per game name (base + updates/DLC grouped).
15    pub result_groups: Option<Vec<RomGroup>>,
16    pub selected: usize,
17    pub scroll_offset: usize,
18    visible_rows: usize,
19}
20
21impl Default for SearchScreen {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl SearchScreen {
28    pub fn new() -> Self {
29        Self {
30            query: String::new(),
31            cursor_pos: 0,
32            results: None,
33            result_groups: None,
34            selected: 0,
35            scroll_offset: 0,
36            visible_rows: 15,
37        }
38    }
39
40    pub fn add_char(&mut self, c: char) {
41        let pos = self.cursor_pos.min(self.query.len());
42        self.query.insert(pos, c);
43        self.cursor_pos = pos + 1;
44    }
45
46    pub fn delete_char(&mut self) {
47        if self.cursor_pos > 0 && self.cursor_pos <= self.query.len() {
48            self.query.remove(self.cursor_pos - 1);
49            self.cursor_pos -= 1;
50        }
51    }
52
53    pub fn cursor_left(&mut self) {
54        if self.cursor_pos > 0 {
55            self.cursor_pos -= 1;
56        }
57    }
58
59    pub fn cursor_right(&mut self) {
60        if self.cursor_pos < self.query.len() {
61            self.cursor_pos += 1;
62        }
63    }
64
65    pub fn set_results(&mut self, results: RomList) {
66        self.results = Some(results.clone());
67        self.result_groups = Some(utils::group_roms_by_name(&results.items));
68        self.selected = 0;
69        self.scroll_offset = 0;
70    }
71
72    pub fn clear_results(&mut self) {
73        self.results = None;
74        self.result_groups = None;
75    }
76
77    pub fn next(&mut self) {
78        if let Some(ref g) = self.result_groups {
79            if !g.is_empty() {
80                self.selected = (self.selected + 1) % g.len();
81                self.update_scroll(self.visible_rows);
82            }
83        }
84    }
85
86    pub fn previous(&mut self) {
87        if let Some(ref g) = self.result_groups {
88            if !g.is_empty() {
89                self.selected = if self.selected == 0 {
90                    g.len() - 1
91                } else {
92                    self.selected - 1
93                };
94                self.update_scroll(self.visible_rows);
95            }
96        }
97    }
98
99    fn update_scroll(&mut self, visible: usize) {
100        if let Some(ref g) = self.result_groups {
101            let visible = visible.max(1);
102            let max_scroll = g.len().saturating_sub(visible);
103            if self.selected >= self.scroll_offset + visible {
104                self.scroll_offset = (self.selected + 1).saturating_sub(visible);
105            } else if self.selected < self.scroll_offset {
106                self.scroll_offset = self.selected;
107            }
108            self.scroll_offset = self.scroll_offset.min(max_scroll);
109        }
110    }
111
112    /// Primary ROM and other files (updates/DLC) for the selected game.
113    pub fn get_selected_group(&self) -> Option<(Rom, Vec<Rom>)> {
114        self.result_groups
115            .as_ref()
116            .and_then(|g| g.get(self.selected))
117            .map(|g| (g.primary.clone(), g.others.clone()))
118    }
119
120    pub fn render(&mut self, f: &mut Frame, area: Rect) {
121        let chunks = Layout::default()
122            .constraints([
123                Constraint::Length(3),
124                Constraint::Min(5),
125                Constraint::Length(3),
126            ])
127            .direction(ratatui::layout::Direction::Vertical)
128            .split(area);
129
130        let input_line = format!("Search: {}", self.query);
131        let input = Paragraph::new(input_line)
132            .block(Block::default().title("Search games").borders(Borders::ALL));
133        f.render_widget(input, chunks[0]);
134
135        if self.result_groups.is_some() {
136            let visible = (chunks[1].height as usize).saturating_sub(3).max(1);
137            // Keep selection and viewport aligned with the current terminal size.
138            self.visible_rows = visible;
139            self.update_scroll(visible);
140            let Some(groups) = self.result_groups.as_ref() else {
141                return;
142            };
143            let start = self.scroll_offset.min(groups.len().saturating_sub(visible));
144            let end = (start + visible).min(groups.len());
145            let visible_groups = &groups[start..end];
146
147            let header = Row::new(vec![
148                Cell::from("Name").style(Style::default().fg(Color::Cyan)),
149                Cell::from("Platform").style(Style::default().fg(Color::Cyan)),
150            ]);
151            let rows: Vec<Row> = visible_groups
152                .iter()
153                .enumerate()
154                .map(|(i, g)| {
155                    let global_idx = start + i;
156                    let platform = g
157                        .primary
158                        .platform_display_name
159                        .as_deref()
160                        .or(g.primary.platform_custom_name.as_deref())
161                        .unwrap_or("—");
162                    let style = if global_idx == self.selected {
163                        Style::default().fg(Color::Yellow)
164                    } else {
165                        Style::default()
166                    };
167                    Row::new(vec![
168                        Cell::from(g.name.as_str()).style(style),
169                        Cell::from(platform).style(style),
170                    ])
171                    .height(1)
172                })
173                .collect();
174
175            let total_files = self.results.as_ref().map(|r| r.items.len()).unwrap_or(0);
176            let widths = [Constraint::Percentage(60), Constraint::Percentage(40)];
177            let table = Table::new(rows, widths).header(header).block(
178                Block::default()
179                    .title(format!(
180                        "Results ({}) — {} files",
181                        groups.len(),
182                        total_files
183                    ))
184                    .borders(Borders::ALL),
185            );
186            f.render_widget(table, chunks[1]);
187        } else {
188            let msg = "Type a search term and press Enter to search";
189            let p =
190                Paragraph::new(msg).block(Block::default().title("Results").borders(Borders::ALL));
191            f.render_widget(p, chunks[1]);
192        }
193
194        let help = "Enter: Search / Open game | ↑↓: Navigate results | Esc: Back";
195        let p = Paragraph::new(help).block(Block::default().borders(Borders::ALL));
196        f.render_widget(p, chunks[2]);
197    }
198
199    pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
200        let chunks = Layout::default()
201            .constraints([
202                Constraint::Length(3),
203                Constraint::Min(5),
204                Constraint::Length(3),
205            ])
206            .direction(ratatui::layout::Direction::Vertical)
207            .split(area);
208        let offset = 9 + self.cursor_pos.min(self.query.len()) as u16;
209        let x = chunks[0].x + offset.min(chunks[0].width.saturating_sub(1));
210        let y = chunks[0].y + 1;
211        Some((x, y))
212    }
213}