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}
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 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 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 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}