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    /// Query string used for the API call that produced [`Self::result_groups`], if any.
17    pub last_searched_query: Option<String>,
18    pub selected: usize,
19    pub scroll_offset: usize,
20    visible_rows: usize,
21    pub loading: bool,
22}
23
24impl Default for SearchScreen {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl SearchScreen {
31    pub fn new() -> Self {
32        Self {
33            query: String::new(),
34            cursor_pos: 0,
35            results: None,
36            result_groups: None,
37            last_searched_query: None,
38            selected: 0,
39            scroll_offset: 0,
40            visible_rows: 15,
41            loading: false,
42        }
43    }
44
45    pub fn add_char(&mut self, c: char) {
46        let pos = self.cursor_pos.min(self.query.len());
47        self.query.insert(pos, c);
48        self.cursor_pos = pos + 1;
49    }
50
51    pub fn delete_char(&mut self) {
52        if self.cursor_pos > 0 && self.cursor_pos <= self.query.len() {
53            self.query.remove(self.cursor_pos - 1);
54            self.cursor_pos -= 1;
55        }
56    }
57
58    pub fn cursor_left(&mut self) {
59        if self.cursor_pos > 0 {
60            self.cursor_pos -= 1;
61        }
62    }
63
64    pub fn cursor_right(&mut self) {
65        if self.cursor_pos < self.query.len() {
66            self.cursor_pos += 1;
67        }
68    }
69
70    pub fn set_results(&mut self, results: RomList) {
71        self.set_results_for_query(self.query.clone(), results);
72    }
73
74    pub fn set_results_for_query(&mut self, query: String, results: RomList) {
75        let query_changed = self.last_searched_query.as_ref() != Some(&query);
76        self.results = Some(results.clone());
77        self.result_groups = Some(utils::group_roms_by_name(&results.items));
78        self.last_searched_query = Some(query);
79        if query_changed {
80            self.selected = 0;
81            self.scroll_offset = 0;
82        }
83    }
84
85    pub fn clear_results(&mut self) {
86        self.results = None;
87        self.result_groups = None;
88        self.last_searched_query = None;
89        self.selected = 0;
90        self.scroll_offset = 0;
91    }
92
93    /// True when the on-screen results were fetched for the current `query` string.
94    pub fn results_match_current_query(&self) -> bool {
95        self.last_searched_query.as_deref() == Some(self.query.as_str())
96    }
97
98    pub fn next(&mut self) {
99        if let Some(ref g) = self.result_groups {
100            if !g.is_empty() {
101                self.selected = (self.selected + 1) % g.len();
102                self.update_scroll(self.visible_rows);
103            }
104        }
105    }
106
107    pub fn previous(&mut self) {
108        if let Some(ref g) = self.result_groups {
109            if !g.is_empty() {
110                self.selected = if self.selected == 0 {
111                    g.len() - 1
112                } else {
113                    self.selected - 1
114                };
115                self.update_scroll(self.visible_rows);
116            }
117        }
118    }
119
120    fn update_scroll(&mut self, visible: usize) {
121        if let Some(ref g) = self.result_groups {
122            let visible = visible.max(1);
123            let max_scroll = g.len().saturating_sub(visible);
124            if self.selected >= self.scroll_offset + visible {
125                self.scroll_offset = (self.selected + 1).saturating_sub(visible);
126            } else if self.selected < self.scroll_offset {
127                self.scroll_offset = self.selected;
128            }
129            self.scroll_offset = self.scroll_offset.min(max_scroll);
130        }
131    }
132
133    /// Primary ROM and other files (updates/DLC) for the selected game.
134    pub fn get_selected_group(&self) -> Option<(Rom, Vec<Rom>)> {
135        self.result_groups
136            .as_ref()
137            .and_then(|g| g.get(self.selected))
138            .map(|g| (g.primary.clone(), g.others.clone()))
139    }
140
141    pub fn render(&mut self, f: &mut Frame, area: Rect) {
142        let chunks = Layout::default()
143            .constraints([
144                Constraint::Length(3),
145                Constraint::Min(5),
146                Constraint::Length(3),
147            ])
148            .direction(ratatui::layout::Direction::Vertical)
149            .split(area);
150
151        let input_line = format!("Search: {}", self.query);
152        let input = Paragraph::new(input_line)
153            .block(Block::default().title("Search games").borders(Borders::ALL));
154        f.render_widget(input, chunks[0]);
155
156        if self.result_groups.is_some() {
157            let visible = (chunks[1].height as usize).saturating_sub(3).max(1);
158            // Keep selection and viewport aligned with the current terminal size.
159            self.visible_rows = visible;
160            self.update_scroll(visible);
161            let Some(groups) = self.result_groups.as_ref() else {
162                return;
163            };
164            let start = self.scroll_offset.min(groups.len().saturating_sub(visible));
165            let end = (start + visible).min(groups.len());
166            let visible_groups = &groups[start..end];
167
168            let header = Row::new(vec![
169                Cell::from("Name").style(Style::default().fg(Color::Cyan)),
170                Cell::from("Platform").style(Style::default().fg(Color::Cyan)),
171            ]);
172            let rows: Vec<Row> = visible_groups
173                .iter()
174                .enumerate()
175                .map(|(i, g)| {
176                    let global_idx = start + i;
177                    let platform = g
178                        .primary
179                        .platform_display_name
180                        .as_deref()
181                        .or(g.primary.platform_custom_name.as_deref())
182                        .unwrap_or("—");
183                    let style = if global_idx == self.selected {
184                        Style::default().fg(Color::Yellow)
185                    } else {
186                        Style::default()
187                    };
188                    Row::new(vec![
189                        Cell::from(g.name.as_str()).style(style),
190                        Cell::from(platform).style(style),
191                    ])
192                    .height(1)
193                })
194                .collect();
195
196            let total_files = self.results.as_ref().map(|r| r.items.len()).unwrap_or(0);
197            let widths = [Constraint::Percentage(60), Constraint::Percentage(40)];
198            let title = if self.loading {
199                format!(
200                    "Results ({}) — {} files [Loading...]",
201                    groups.len(),
202                    total_files
203                )
204            } else {
205                format!("Results ({}) — {} files", groups.len(), total_files)
206            };
207            let table = Table::new(rows, widths)
208                .header(header)
209                .block(Block::default().title(title).borders(Borders::ALL));
210            f.render_widget(table, chunks[1]);
211        } else {
212            let msg = if self.loading {
213                "Searching..."
214            } else {
215                "Type a search term and press Enter to search"
216            };
217            let p =
218                Paragraph::new(msg).block(Block::default().title("Results").borders(Borders::ALL));
219            f.render_widget(p, chunks[1]);
220        }
221
222        let help = "Enter: Search (or open game if query unchanged) | ↑↓: Navigate | Esc: Back";
223        let p = Paragraph::new(help).block(Block::default().borders(Borders::ALL));
224        f.render_widget(p, chunks[2]);
225    }
226
227    pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
228        let chunks = Layout::default()
229            .constraints([
230                Constraint::Length(3),
231                Constraint::Min(5),
232                Constraint::Length(3),
233            ])
234            .direction(ratatui::layout::Direction::Vertical)
235            .split(area);
236        let offset = 9 + self.cursor_pos.min(self.query.len()) as u16;
237        let x = chunks[0].x + offset.min(chunks[0].width.saturating_sub(1));
238        let y = chunks[0].y + 1;
239        Some((x, y))
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::SearchScreen;
246    use crate::types::RomList;
247
248    fn empty_list() -> RomList {
249        RomList {
250            items: vec![],
251            total: 0,
252            limit: 50,
253            offset: 0,
254        }
255    }
256
257    #[test]
258    fn set_results_records_last_searched_query() {
259        let mut s = SearchScreen::new();
260        s.query = "mario".to_string();
261        s.set_results(empty_list());
262        assert_eq!(s.last_searched_query.as_deref(), Some("mario"));
263        assert!(s.results_match_current_query());
264    }
265
266    #[test]
267    fn editing_query_after_search_marks_stale() {
268        let mut s = SearchScreen::new();
269        s.query = "mario".to_string();
270        s.cursor_pos = s.query.len();
271        s.set_results(empty_list());
272        assert!(s.results_match_current_query());
273        s.delete_char();
274        assert_eq!(s.query, "mari");
275        assert!(!s.results_match_current_query());
276    }
277
278    #[test]
279    fn clear_results_clears_last_searched_query() {
280        let mut s = SearchScreen::new();
281        s.query = "a".to_string();
282        s.set_results(empty_list());
283        s.clear_results();
284        assert!(s.last_searched_query.is_none());
285    }
286
287    #[test]
288    fn set_results_for_query_tracks_origin_query_not_live_input() {
289        let mut s = SearchScreen::new();
290        s.query = "mario".to_string();
291        s.set_results_for_query("zelda".to_string(), empty_list());
292        assert_eq!(s.last_searched_query.as_deref(), Some("zelda"));
293        assert!(!s.results_match_current_query());
294    }
295}