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