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 self.results = Some(results.clone());
76 self.result_groups = Some(utils::group_roms_by_name(&results.items));
77 self.last_searched_query = Some(query);
78 self.selected = 0;
79 self.scroll_offset = 0;
80 }
81
82 pub fn clear_results(&mut self) {
83 self.results = None;
84 self.result_groups = None;
85 self.last_searched_query = None;
86 }
87
88 pub fn results_match_current_query(&self) -> bool {
90 self.last_searched_query.as_deref() == Some(self.query.as_str())
91 }
92
93 pub fn next(&mut self) {
94 if let Some(ref g) = self.result_groups {
95 if !g.is_empty() {
96 self.selected = (self.selected + 1) % g.len();
97 self.update_scroll(self.visible_rows);
98 }
99 }
100 }
101
102 pub fn previous(&mut self) {
103 if let Some(ref g) = self.result_groups {
104 if !g.is_empty() {
105 self.selected = if self.selected == 0 {
106 g.len() - 1
107 } else {
108 self.selected - 1
109 };
110 self.update_scroll(self.visible_rows);
111 }
112 }
113 }
114
115 fn update_scroll(&mut self, visible: usize) {
116 if let Some(ref g) = self.result_groups {
117 let visible = visible.max(1);
118 let max_scroll = g.len().saturating_sub(visible);
119 if self.selected >= self.scroll_offset + visible {
120 self.scroll_offset = (self.selected + 1).saturating_sub(visible);
121 } else if self.selected < self.scroll_offset {
122 self.scroll_offset = self.selected;
123 }
124 self.scroll_offset = self.scroll_offset.min(max_scroll);
125 }
126 }
127
128 pub fn get_selected_group(&self) -> Option<(Rom, Vec<Rom>)> {
130 self.result_groups
131 .as_ref()
132 .and_then(|g| g.get(self.selected))
133 .map(|g| (g.primary.clone(), g.others.clone()))
134 }
135
136 pub fn render(&mut self, f: &mut Frame, area: Rect) {
137 let chunks = Layout::default()
138 .constraints([
139 Constraint::Length(3),
140 Constraint::Min(5),
141 Constraint::Length(3),
142 ])
143 .direction(ratatui::layout::Direction::Vertical)
144 .split(area);
145
146 let input_line = format!("Search: {}", self.query);
147 let input = Paragraph::new(input_line)
148 .block(Block::default().title("Search games").borders(Borders::ALL));
149 f.render_widget(input, chunks[0]);
150
151 if self.result_groups.is_some() {
152 let visible = (chunks[1].height as usize).saturating_sub(3).max(1);
153 self.visible_rows = visible;
155 self.update_scroll(visible);
156 let Some(groups) = self.result_groups.as_ref() else {
157 return;
158 };
159 let start = self.scroll_offset.min(groups.len().saturating_sub(visible));
160 let end = (start + visible).min(groups.len());
161 let visible_groups = &groups[start..end];
162
163 let header = Row::new(vec![
164 Cell::from("Name").style(Style::default().fg(Color::Cyan)),
165 Cell::from("Platform").style(Style::default().fg(Color::Cyan)),
166 ]);
167 let rows: Vec<Row> = visible_groups
168 .iter()
169 .enumerate()
170 .map(|(i, g)| {
171 let global_idx = start + i;
172 let platform = g
173 .primary
174 .platform_display_name
175 .as_deref()
176 .or(g.primary.platform_custom_name.as_deref())
177 .unwrap_or("—");
178 let style = if global_idx == self.selected {
179 Style::default().fg(Color::Yellow)
180 } else {
181 Style::default()
182 };
183 Row::new(vec![
184 Cell::from(g.name.as_str()).style(style),
185 Cell::from(platform).style(style),
186 ])
187 .height(1)
188 })
189 .collect();
190
191 let total_files = self.results.as_ref().map(|r| r.items.len()).unwrap_or(0);
192 let widths = [Constraint::Percentage(60), Constraint::Percentage(40)];
193 let title = if self.loading {
194 format!(
195 "Results ({}) — {} files [Loading...]",
196 groups.len(),
197 total_files
198 )
199 } else {
200 format!("Results ({}) — {} files", groups.len(), total_files)
201 };
202 let table = Table::new(rows, widths)
203 .header(header)
204 .block(Block::default().title(title).borders(Borders::ALL));
205 f.render_widget(table, chunks[1]);
206 } else {
207 let msg = if self.loading {
208 "Searching..."
209 } else {
210 "Type a search term and press Enter to search"
211 };
212 let p =
213 Paragraph::new(msg).block(Block::default().title("Results").borders(Borders::ALL));
214 f.render_widget(p, chunks[1]);
215 }
216
217 let help = "Enter: Search (or open game if query unchanged) | ↑↓: Navigate | Esc: Back";
218 let p = Paragraph::new(help).block(Block::default().borders(Borders::ALL));
219 f.render_widget(p, chunks[2]);
220 }
221
222 pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
223 let chunks = Layout::default()
224 .constraints([
225 Constraint::Length(3),
226 Constraint::Min(5),
227 Constraint::Length(3),
228 ])
229 .direction(ratatui::layout::Direction::Vertical)
230 .split(area);
231 let offset = 9 + self.cursor_pos.min(self.query.len()) as u16;
232 let x = chunks[0].x + offset.min(chunks[0].width.saturating_sub(1));
233 let y = chunks[0].y + 1;
234 Some((x, y))
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::SearchScreen;
241 use crate::types::RomList;
242
243 fn empty_list() -> RomList {
244 RomList {
245 items: vec![],
246 total: 0,
247 limit: 50,
248 offset: 0,
249 }
250 }
251
252 #[test]
253 fn set_results_records_last_searched_query() {
254 let mut s = SearchScreen::new();
255 s.query = "mario".to_string();
256 s.set_results(empty_list());
257 assert_eq!(s.last_searched_query.as_deref(), Some("mario"));
258 assert!(s.results_match_current_query());
259 }
260
261 #[test]
262 fn editing_query_after_search_marks_stale() {
263 let mut s = SearchScreen::new();
264 s.query = "mario".to_string();
265 s.cursor_pos = s.query.len();
266 s.set_results(empty_list());
267 assert!(s.results_match_current_query());
268 s.delete_char();
269 assert_eq!(s.query, "mari");
270 assert!(!s.results_match_current_query());
271 }
272
273 #[test]
274 fn clear_results_clears_last_searched_query() {
275 let mut s = SearchScreen::new();
276 s.query = "a".to_string();
277 s.set_results(empty_list());
278 s.clear_results();
279 assert!(s.last_searched_query.is_none());
280 }
281
282 #[test]
283 fn set_results_for_query_tracks_origin_query_not_live_input() {
284 let mut s = SearchScreen::new();
285 s.query = "mario".to_string();
286 s.set_results_for_query("zelda".to_string(), empty_list());
287 assert_eq!(s.last_searched_query.as_deref(), Some("zelda"));
288 assert!(!s.results_match_current_query());
289 }
290}