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 selected: usize,
17 pub scroll_offset: usize,
18 visible_rows: usize,
19}
20
21impl Default for SearchScreen {
22 fn default() -> Self {
23 Self::new()
24 }
25}
26
27impl SearchScreen {
28 pub fn new() -> Self {
29 Self {
30 query: String::new(),
31 cursor_pos: 0,
32 results: None,
33 result_groups: None,
34 selected: 0,
35 scroll_offset: 0,
36 visible_rows: 15,
37 }
38 }
39
40 pub fn add_char(&mut self, c: char) {
41 let pos = self.cursor_pos.min(self.query.len());
42 self.query.insert(pos, c);
43 self.cursor_pos = pos + 1;
44 }
45
46 pub fn delete_char(&mut self) {
47 if self.cursor_pos > 0 && self.cursor_pos <= self.query.len() {
48 self.query.remove(self.cursor_pos - 1);
49 self.cursor_pos -= 1;
50 }
51 }
52
53 pub fn cursor_left(&mut self) {
54 if self.cursor_pos > 0 {
55 self.cursor_pos -= 1;
56 }
57 }
58
59 pub fn cursor_right(&mut self) {
60 if self.cursor_pos < self.query.len() {
61 self.cursor_pos += 1;
62 }
63 }
64
65 pub fn set_results(&mut self, results: RomList) {
66 self.results = Some(results.clone());
67 self.result_groups = Some(utils::group_roms_by_name(&results.items));
68 self.selected = 0;
69 self.scroll_offset = 0;
70 }
71
72 pub fn clear_results(&mut self) {
73 self.results = None;
74 self.result_groups = None;
75 }
76
77 pub fn next(&mut self) {
78 if let Some(ref g) = self.result_groups {
79 if !g.is_empty() {
80 self.selected = (self.selected + 1) % g.len();
81 self.update_scroll(self.visible_rows);
82 }
83 }
84 }
85
86 pub fn previous(&mut self) {
87 if let Some(ref g) = self.result_groups {
88 if !g.is_empty() {
89 self.selected = if self.selected == 0 {
90 g.len() - 1
91 } else {
92 self.selected - 1
93 };
94 self.update_scroll(self.visible_rows);
95 }
96 }
97 }
98
99 fn update_scroll(&mut self, visible: usize) {
100 if let Some(ref g) = self.result_groups {
101 let visible = visible.max(1);
102 let max_scroll = g.len().saturating_sub(visible);
103 if self.selected >= self.scroll_offset + visible {
104 self.scroll_offset = (self.selected + 1).saturating_sub(visible);
105 } else if self.selected < self.scroll_offset {
106 self.scroll_offset = self.selected;
107 }
108 self.scroll_offset = self.scroll_offset.min(max_scroll);
109 }
110 }
111
112 pub fn get_selected_group(&self) -> Option<(Rom, Vec<Rom>)> {
114 self.result_groups
115 .as_ref()
116 .and_then(|g| g.get(self.selected))
117 .map(|g| (g.primary.clone(), g.others.clone()))
118 }
119
120 pub fn render(&mut self, f: &mut Frame, area: Rect) {
121 let chunks = Layout::default()
122 .constraints([
123 Constraint::Length(3),
124 Constraint::Min(5),
125 Constraint::Length(3),
126 ])
127 .direction(ratatui::layout::Direction::Vertical)
128 .split(area);
129
130 let input_line = format!("Search: {}", self.query);
131 let input = Paragraph::new(input_line)
132 .block(Block::default().title("Search games").borders(Borders::ALL));
133 f.render_widget(input, chunks[0]);
134
135 if self.result_groups.is_some() {
136 let visible = (chunks[1].height as usize).saturating_sub(3).max(1);
137 self.visible_rows = visible;
139 self.update_scroll(visible);
140 let Some(groups) = self.result_groups.as_ref() else {
141 return;
142 };
143 let start = self.scroll_offset.min(groups.len().saturating_sub(visible));
144 let end = (start + visible).min(groups.len());
145 let visible_groups = &groups[start..end];
146
147 let header = Row::new(vec![
148 Cell::from("Name").style(Style::default().fg(Color::Cyan)),
149 Cell::from("Platform").style(Style::default().fg(Color::Cyan)),
150 ]);
151 let rows: Vec<Row> = visible_groups
152 .iter()
153 .enumerate()
154 .map(|(i, g)| {
155 let global_idx = start + i;
156 let platform = g
157 .primary
158 .platform_display_name
159 .as_deref()
160 .or(g.primary.platform_custom_name.as_deref())
161 .unwrap_or("—");
162 let style = if global_idx == self.selected {
163 Style::default().fg(Color::Yellow)
164 } else {
165 Style::default()
166 };
167 Row::new(vec![
168 Cell::from(g.name.as_str()).style(style),
169 Cell::from(platform).style(style),
170 ])
171 .height(1)
172 })
173 .collect();
174
175 let total_files = self.results.as_ref().map(|r| r.items.len()).unwrap_or(0);
176 let widths = [Constraint::Percentage(60), Constraint::Percentage(40)];
177 let table = Table::new(rows, widths).header(header).block(
178 Block::default()
179 .title(format!(
180 "Results ({}) — {} files",
181 groups.len(),
182 total_files
183 ))
184 .borders(Borders::ALL),
185 );
186 f.render_widget(table, chunks[1]);
187 } else {
188 let msg = "Type a search term and press Enter to search";
189 let p =
190 Paragraph::new(msg).block(Block::default().title("Results").borders(Borders::ALL));
191 f.render_widget(p, chunks[1]);
192 }
193
194 let help = "Enter: Search / Open game | ↑↓: Navigate results | Esc: Back";
195 let p = Paragraph::new(help).block(Block::default().borders(Borders::ALL));
196 f.render_widget(p, chunks[2]);
197 }
198
199 pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
200 let chunks = Layout::default()
201 .constraints([
202 Constraint::Length(3),
203 Constraint::Min(5),
204 Constraint::Length(3),
205 ])
206 .direction(ratatui::layout::Direction::Vertical)
207 .split(area);
208 let offset = 9 + self.cursor_pos.min(self.query.len()) as u16;
209 let x = chunks[0].x + offset.min(chunks[0].width.saturating_sub(1));
210 let y = chunks[0].y + 1;
211 Some((x, y))
212 }
213}