romm_cli/tui/screens/
search.rs1use 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
9pub struct SearchScreen {
11 pub query: String,
12 pub cursor_pos: usize,
13 pub results: Option<RomList>,
14 pub result_groups: Option<Vec<RomGroup>>,
16 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 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 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 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}