use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
use ratatui::Frame;
use crate::core::utils::{self, RomGroup};
use crate::types::{Rom, RomList};
pub struct SearchScreen {
pub query: String,
pub cursor_pos: usize,
pub results: Option<RomList>,
pub result_groups: Option<Vec<RomGroup>>,
pub last_searched_query: Option<String>,
pub selected: usize,
pub scroll_offset: usize,
visible_rows: usize,
pub loading: bool,
}
impl Default for SearchScreen {
fn default() -> Self {
Self::new()
}
}
impl SearchScreen {
pub fn new() -> Self {
Self {
query: String::new(),
cursor_pos: 0,
results: None,
result_groups: None,
last_searched_query: None,
selected: 0,
scroll_offset: 0,
visible_rows: 15,
loading: false,
}
}
pub fn add_char(&mut self, c: char) {
let pos = self.cursor_pos.min(self.query.len());
self.query.insert(pos, c);
self.cursor_pos = pos + 1;
}
pub fn delete_char(&mut self) {
if self.cursor_pos > 0 && self.cursor_pos <= self.query.len() {
self.query.remove(self.cursor_pos - 1);
self.cursor_pos -= 1;
}
}
pub fn cursor_left(&mut self) {
if self.cursor_pos > 0 {
self.cursor_pos -= 1;
}
}
pub fn cursor_right(&mut self) {
if self.cursor_pos < self.query.len() {
self.cursor_pos += 1;
}
}
pub fn set_results(&mut self, results: RomList) {
self.results = Some(results.clone());
self.result_groups = Some(utils::group_roms_by_name(&results.items));
self.last_searched_query = Some(self.query.clone());
self.selected = 0;
self.scroll_offset = 0;
}
pub fn clear_results(&mut self) {
self.results = None;
self.result_groups = None;
self.last_searched_query = None;
}
pub fn results_match_current_query(&self) -> bool {
self.last_searched_query.as_deref() == Some(self.query.as_str())
}
pub fn next(&mut self) {
if let Some(ref g) = self.result_groups {
if !g.is_empty() {
self.selected = (self.selected + 1) % g.len();
self.update_scroll(self.visible_rows);
}
}
}
pub fn previous(&mut self) {
if let Some(ref g) = self.result_groups {
if !g.is_empty() {
self.selected = if self.selected == 0 {
g.len() - 1
} else {
self.selected - 1
};
self.update_scroll(self.visible_rows);
}
}
}
fn update_scroll(&mut self, visible: usize) {
if let Some(ref g) = self.result_groups {
let visible = visible.max(1);
let max_scroll = g.len().saturating_sub(visible);
if self.selected >= self.scroll_offset + visible {
self.scroll_offset = (self.selected + 1).saturating_sub(visible);
} else if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
self.scroll_offset = self.scroll_offset.min(max_scroll);
}
}
pub fn get_selected_group(&self) -> Option<(Rom, Vec<Rom>)> {
self.result_groups
.as_ref()
.and_then(|g| g.get(self.selected))
.map(|g| (g.primary.clone(), g.others.clone()))
}
pub fn render(&mut self, f: &mut Frame, area: Rect) {
let chunks = Layout::default()
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(3),
])
.direction(ratatui::layout::Direction::Vertical)
.split(area);
let input_line = format!("Search: {}", self.query);
let input = Paragraph::new(input_line)
.block(Block::default().title("Search games").borders(Borders::ALL));
f.render_widget(input, chunks[0]);
if self.result_groups.is_some() {
let visible = (chunks[1].height as usize).saturating_sub(3).max(1);
self.visible_rows = visible;
self.update_scroll(visible);
let Some(groups) = self.result_groups.as_ref() else {
return;
};
let start = self.scroll_offset.min(groups.len().saturating_sub(visible));
let end = (start + visible).min(groups.len());
let visible_groups = &groups[start..end];
let header = Row::new(vec![
Cell::from("Name").style(Style::default().fg(Color::Cyan)),
Cell::from("Platform").style(Style::default().fg(Color::Cyan)),
]);
let rows: Vec<Row> = visible_groups
.iter()
.enumerate()
.map(|(i, g)| {
let global_idx = start + i;
let platform = g
.primary
.platform_display_name
.as_deref()
.or(g.primary.platform_custom_name.as_deref())
.unwrap_or("—");
let style = if global_idx == self.selected {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
Row::new(vec![
Cell::from(g.name.as_str()).style(style),
Cell::from(platform).style(style),
])
.height(1)
})
.collect();
let total_files = self.results.as_ref().map(|r| r.items.len()).unwrap_or(0);
let widths = [Constraint::Percentage(60), Constraint::Percentage(40)];
let title = if self.loading {
format!(
"Results ({}) — {} files [Loading...]",
groups.len(),
total_files
)
} else {
format!("Results ({}) — {} files", groups.len(), total_files)
};
let table = Table::new(rows, widths)
.header(header)
.block(Block::default().title(title).borders(Borders::ALL));
f.render_widget(table, chunks[1]);
} else {
let msg = if self.loading {
"Searching..."
} else {
"Type a search term and press Enter to search"
};
let p =
Paragraph::new(msg).block(Block::default().title("Results").borders(Borders::ALL));
f.render_widget(p, chunks[1]);
}
let help = "Enter: Search (or open game if query unchanged) | ↑↓: Navigate | Esc: Back";
let p = Paragraph::new(help).block(Block::default().borders(Borders::ALL));
f.render_widget(p, chunks[2]);
}
pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
let chunks = Layout::default()
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(3),
])
.direction(ratatui::layout::Direction::Vertical)
.split(area);
let offset = 9 + self.cursor_pos.min(self.query.len()) as u16;
let x = chunks[0].x + offset.min(chunks[0].width.saturating_sub(1));
let y = chunks[0].y + 1;
Some((x, y))
}
}
#[cfg(test)]
mod tests {
use super::SearchScreen;
use crate::types::RomList;
fn empty_list() -> RomList {
RomList {
items: vec![],
total: 0,
limit: 50,
offset: 0,
}
}
#[test]
fn set_results_records_last_searched_query() {
let mut s = SearchScreen::new();
s.query = "mario".to_string();
s.set_results(empty_list());
assert_eq!(s.last_searched_query.as_deref(), Some("mario"));
assert!(s.results_match_current_query());
}
#[test]
fn editing_query_after_search_marks_stale() {
let mut s = SearchScreen::new();
s.query = "mario".to_string();
s.cursor_pos = s.query.len();
s.set_results(empty_list());
assert!(s.results_match_current_query());
s.delete_char();
assert_eq!(s.query, "mari");
assert!(!s.results_match_current_query());
}
#[test]
fn clear_results_clears_last_searched_query() {
let mut s = SearchScreen::new();
s.query = "a".to_string();
s.set_results(empty_list());
s.clear_results();
assert!(s.last_searched_query.is_none());
}
}