1use ratatui::layout::{Constraint, Layout, Rect};
2use ratatui::style::{Color, Style};
3use ratatui::widgets::{Block, Borders, Cell, List, ListItem, ListState, Row, Table};
4use ratatui::Frame;
5
6use crate::core::cache::RomCacheKey;
7use crate::core::utils::{self, RomGroup};
8use crate::endpoints::roms::GetRoms;
9use crate::types::{Collection, Platform, Rom, RomList};
10
11#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum LibrarySubsection {
14 ByConsole,
15 ByCollection,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum LibrarySearchMode {
21 Filter,
23 Jump,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum LibraryViewMode {
30 List,
32 Roms,
34}
35
36pub struct LibraryBrowseScreen {
38 pub platforms: Vec<Platform>,
39 pub collections: Vec<Collection>,
40 pub subsection: LibrarySubsection,
41 pub list_index: usize,
42 pub view_mode: LibraryViewMode,
43 pub roms: Option<RomList>,
44 pub rom_groups: Option<Vec<RomGroup>>,
46 pub rom_selected: usize,
47 pub scroll_offset: usize,
48 visible_rows: usize,
50 pub search_query: String,
52 pub search_mode: Option<LibrarySearchMode>,
53 normalized_query: String,
55}
56
57impl LibraryBrowseScreen {
58 pub fn new(platforms: Vec<Platform>, collections: Vec<Collection>) -> Self {
59 Self {
60 platforms,
61 collections,
62 subsection: LibrarySubsection::ByConsole,
63 list_index: 0,
64 view_mode: LibraryViewMode::List,
65 roms: None,
66 rom_groups: None,
67 rom_selected: 0,
68 scroll_offset: 0,
69 visible_rows: 20, search_query: String::new(),
71 search_mode: None,
72 normalized_query: String::new(),
73 }
74 }
75
76 pub fn list_len(&self) -> usize {
77 match self.subsection {
78 LibrarySubsection::ByConsole => self.platforms.len(),
79 LibrarySubsection::ByCollection => self.collections.len(),
80 }
81 }
82
83 pub fn list_next(&mut self) {
84 let len = self.list_len();
85 if len > 0 {
86 self.list_index = (self.list_index + 1) % len;
87 }
88 }
89
90 pub fn list_previous(&mut self) {
91 let len = self.list_len();
92 if len > 0 {
93 self.list_index = if self.list_index == 0 {
94 len - 1
95 } else {
96 self.list_index - 1
97 };
98 }
99 }
100
101 pub fn rom_next(&mut self) {
102 if let Some(ref groups) = self.rom_groups {
103 if !groups.is_empty() {
104 self.rom_selected = (self.rom_selected + 1) % groups.len();
105 self.update_rom_scroll(self.visible_rows);
106 }
107 }
108 }
109
110 pub fn rom_previous(&mut self) {
111 if let Some(ref groups) = self.rom_groups {
112 if !groups.is_empty() {
113 self.rom_selected = if self.rom_selected == 0 {
114 groups.len() - 1
115 } else {
116 self.rom_selected - 1
117 };
118 self.update_rom_scroll(self.visible_rows);
119 }
120 }
121 }
122
123 fn update_rom_scroll(&mut self, visible: usize) {
129 if let Some(ref groups) = self.rom_groups {
130 self.update_rom_scroll_with_len(groups.len(), visible);
131 }
132 }
133
134 fn update_rom_scroll_with_len(&mut self, list_len: usize, visible: usize) {
135 let visible = visible.max(1);
136 let max_scroll = list_len.saturating_sub(visible);
137 if self.rom_selected >= self.scroll_offset + visible {
138 self.scroll_offset = (self.rom_selected + 1).saturating_sub(visible);
139 } else if self.rom_selected < self.scroll_offset {
140 self.scroll_offset = self.rom_selected;
141 }
142 self.scroll_offset = self.scroll_offset.min(max_scroll);
143 }
144
145 pub fn switch_subsection(&mut self) {
146 self.subsection = match self.subsection {
147 LibrarySubsection::ByConsole => LibrarySubsection::ByCollection,
148 LibrarySubsection::ByCollection => LibrarySubsection::ByConsole,
149 };
150 self.list_index = 0;
151 self.roms = None;
152 self.view_mode = LibraryViewMode::List;
153 }
154
155 pub fn switch_view(&mut self) {
156 self.view_mode = match self.view_mode {
157 LibraryViewMode::List => LibraryViewMode::Roms,
158 LibraryViewMode::Roms => LibraryViewMode::List,
159 };
160 self.rom_selected = 0;
161 self.scroll_offset = 0;
162 }
163
164 pub fn back_to_list(&mut self) {
165 self.view_mode = LibraryViewMode::List;
166 self.roms = None;
167 }
168
169 pub fn clear_roms(&mut self) {
172 self.roms = None;
173 self.rom_groups = None;
174 }
175
176 pub fn set_roms(&mut self, roms: RomList) {
178 self.roms = Some(roms.clone());
179 self.rom_groups = Some(utils::group_roms_by_name(&roms.items));
180 self.rom_selected = 0;
181 self.scroll_offset = 0;
182 self.clear_search(); }
184
185 pub fn enter_search(&mut self, mode: LibrarySearchMode) {
188 self.search_mode = Some(mode);
189 self.search_query.clear();
190 self.normalized_query.clear();
191 self.rom_selected = 0;
192 self.scroll_offset = 0;
193 }
194
195 pub fn clear_search(&mut self) {
196 self.search_mode = None;
197 self.search_query.clear();
198 self.normalized_query.clear();
199 }
200
201 pub fn add_search_char(&mut self, c: char) {
202 self.search_query.push(c);
203 self.normalized_query = self.normalize(&self.search_query);
204 if self.search_mode == Some(LibrarySearchMode::Filter) {
205 self.rom_selected = 0;
206 self.scroll_offset = 0;
207 } else if self.search_mode == Some(LibrarySearchMode::Jump) {
208 self.jump_to_match(false);
209 }
210 }
211
212 pub fn delete_search_char(&mut self) {
213 self.search_query.pop();
214 self.normalized_query = self.normalize(&self.search_query);
215 if self.search_mode == Some(LibrarySearchMode::Filter) {
216 self.rom_selected = 0;
217 self.scroll_offset = 0;
218 }
219 }
220
221 fn normalize(&self, s: &str) -> String {
223 use unicode_normalization::UnicodeNormalization;
224 s.nfd()
225 .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
226 .collect::<String>()
227 .to_lowercase()
228 }
229
230 pub fn jump_to_match(&mut self, next: bool) {
231 if self.normalized_query.is_empty() {
232 return;
233 }
234 let Some(ref groups) = self.rom_groups else {
235 return;
236 };
237 let start_idx = if next {
238 (self.rom_selected + 1) % groups.len()
239 } else {
240 self.rom_selected
241 };
242
243 for i in 0..groups.len() {
244 let idx = (start_idx + i) % groups.len();
245 if self
246 .normalize(&groups[idx].name)
247 .contains(&self.normalized_query)
248 {
249 self.rom_selected = idx;
250 self.update_rom_scroll(self.visible_rows);
251 return;
252 }
253 }
254 }
255
256 pub fn get_selected_group(&self) -> Option<(Rom, Vec<Rom>)> {
258 self.visible_rom_groups()
259 .get(self.rom_selected)
260 .map(|g| (g.primary.clone(), g.others.clone()))
261 }
262
263 fn visible_rom_groups(&self) -> Vec<RomGroup> {
265 let Some(ref groups) = self.rom_groups else {
266 return Vec::new();
267 };
268 if self.search_mode == Some(LibrarySearchMode::Filter) && !self.normalized_query.is_empty()
269 {
270 groups
271 .iter()
272 .filter(|g| self.normalize(&g.name).contains(&self.normalized_query))
273 .cloned()
274 .collect()
275 } else {
276 groups.clone()
277 }
278 }
279
280 fn list_title(&self) -> &str {
281 match self.subsection {
282 LibrarySubsection::ByConsole => "Consoles",
283 LibrarySubsection::ByCollection => "Collections",
284 }
285 }
286
287 fn selected_platform_id(&self) -> Option<u64> {
288 match self.subsection {
289 LibrarySubsection::ByConsole => self.platforms.get(self.list_index).map(|p| p.id),
290 LibrarySubsection::ByCollection => None,
291 }
292 }
293
294 fn selected_collection_id(&self) -> Option<u64> {
295 match self.subsection {
296 LibrarySubsection::ByCollection => self.collections.get(self.list_index).map(|c| c.id),
297 LibrarySubsection::ByConsole => None,
298 }
299 }
300
301 pub fn cache_key(&self) -> Option<RomCacheKey> {
303 self.selected_platform_id()
304 .map(RomCacheKey::Platform)
305 .or_else(|| self.selected_collection_id().map(RomCacheKey::Collection))
306 }
307
308 pub fn expected_rom_count(&self) -> u64 {
311 match self.subsection {
312 LibrarySubsection::ByConsole => self
313 .platforms
314 .get(self.list_index)
315 .map(|p| p.rom_count)
316 .unwrap_or(0),
317 LibrarySubsection::ByCollection => self
318 .collections
319 .get(self.list_index)
320 .and_then(|c| c.rom_count)
321 .unwrap_or(0),
322 }
323 }
324
325 pub fn get_roms_request_platform(&self) -> Option<GetRoms> {
326 let count = self.expected_rom_count().min(20000);
327 self.selected_platform_id().map(|id| GetRoms {
328 platform_id: Some(id),
329 limit: Some(count as u32),
330 ..Default::default()
331 })
332 }
333
334 pub fn get_roms_request_collection(&self) -> Option<GetRoms> {
335 let count = self.expected_rom_count().min(20000);
336 self.selected_collection_id().map(|id| GetRoms {
337 collection_id: Some(id),
338 limit: Some(count as u32),
339 ..Default::default()
340 })
341 }
342
343 pub fn render(&mut self, f: &mut Frame, area: Rect) {
344 let chunks = Layout::default()
345 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
346 .direction(ratatui::layout::Direction::Horizontal)
347 .split(area);
348
349 self.render_list(f, chunks[0]);
350
351 let right_chunks = if self.search_mode.is_some() {
352 Layout::default()
353 .constraints([
354 Constraint::Length(3),
355 Constraint::Min(5),
356 Constraint::Length(3),
357 ])
358 .direction(ratatui::layout::Direction::Vertical)
359 .split(chunks[1])
360 } else {
361 Layout::default()
362 .constraints([Constraint::Min(5), Constraint::Length(3)])
363 .direction(ratatui::layout::Direction::Vertical)
364 .split(chunks[1])
365 };
366
367 if let Some(mode) = self.search_mode {
368 let title = match mode {
369 LibrarySearchMode::Filter => "Filter Search",
370 LibrarySearchMode::Jump => "Jump Search (Tab to next)",
371 };
372 let p = ratatui::widgets::Paragraph::new(format!("Search: {}", self.search_query))
373 .block(Block::default().title(title).borders(Borders::ALL));
374 f.render_widget(p, right_chunks[0]);
375 self.render_roms(f, right_chunks[1]);
376 self.render_help(f, right_chunks[2]);
377 } else {
378 self.render_roms(f, right_chunks[0]);
379 self.render_help(f, right_chunks[1]);
380 }
381 }
382
383 fn render_list(&self, f: &mut Frame, area: Rect) {
384 let items: Vec<ListItem> = match self.subsection {
385 LibrarySubsection::ByConsole => self
386 .platforms
387 .iter()
388 .enumerate()
389 .map(|(idx, p)| {
390 let name = p.display_name.as_deref().unwrap_or(&p.name);
391 let count = p.rom_count;
392 let prefix =
393 if idx == self.list_index && self.view_mode == LibraryViewMode::List {
394 "▶ "
395 } else {
396 " "
397 };
398 ListItem::new(format!("{}{} ({} roms)", prefix, name, count))
399 })
400 .collect(),
401 LibrarySubsection::ByCollection => self
402 .collections
403 .iter()
404 .enumerate()
405 .map(|(idx, c)| {
406 let count = c.rom_count.unwrap_or(0);
407 let prefix =
408 if idx == self.list_index && self.view_mode == LibraryViewMode::List {
409 "▶ "
410 } else {
411 " "
412 };
413 ListItem::new(format!("{}{} ({} roms)", prefix, c.name, count))
414 })
415 .collect(),
416 };
417
418 let list = List::new(items)
419 .block(
420 Block::default()
421 .title(self.list_title())
422 .borders(Borders::ALL),
423 )
424 .highlight_symbol(if self.view_mode == LibraryViewMode::List {
425 ">> "
426 } else {
427 " "
428 });
429
430 let mut state = ListState::default();
431 if self.view_mode == LibraryViewMode::List {
432 state.select(Some(self.list_index));
433 }
434
435 f.render_stateful_widget(list, area, &mut state);
436 }
437
438 fn render_roms(&mut self, f: &mut Frame, area: Rect) {
439 let visible = (area.height as usize).saturating_sub(3).max(1);
440 self.visible_rows = visible;
441
442 let groups = self.visible_rom_groups();
443 if groups.is_empty() {
444 let msg = if self.search_mode.is_some() {
445 "No games match your search".to_string()
446 } else if self.roms.is_none() && self.expected_rom_count() > 0 {
447 format!("Loading {} games... please wait", self.expected_rom_count())
448 } else {
449 "Select a console or collection and press Enter to load ROMs".to_string()
450 };
451 let p = ratatui::widgets::Paragraph::new(msg)
452 .block(Block::default().title("Games").borders(Borders::ALL));
453 f.render_widget(p, area);
454 return;
455 }
456
457 if self.rom_selected >= groups.len() {
459 self.rom_selected = 0;
460 self.scroll_offset = 0;
461 }
462
463 self.update_rom_scroll_with_len(groups.len(), visible);
464
465 let start = self.scroll_offset.min(groups.len().saturating_sub(visible));
466 let end = (start + visible).min(groups.len());
467 let visible_groups = &groups[start..end];
468
469 let header = Row::new(vec![
470 Cell::from("Name").style(Style::default().fg(Color::Cyan))
471 ]);
472 let rows: Vec<Row> = visible_groups
473 .iter()
474 .enumerate()
475 .map(|(i, g)| {
476 let global_idx = start + i;
477 let style = if global_idx == self.rom_selected {
478 Style::default().fg(Color::Yellow)
479 } else {
480 Style::default()
481 };
482 Row::new(vec![Cell::from(g.name.as_str()).style(style)]).height(1)
483 })
484 .collect();
485
486 let total_files = self.roms.as_ref().map(|r| r.items.len()).unwrap_or(0);
487 let total_roms = self.roms.as_ref().map(|r| r.total).unwrap_or(0);
488 let title = if total_roms > 0 && (groups.len() as u64) < total_roms {
489 format!(
490 "Games ({} of {}) — {} files",
491 groups.len(),
492 total_roms,
493 total_files
494 )
495 } else {
496 format!("Games ({}) — {} files", groups.len(), total_files)
497 };
498 let widths = [Constraint::Percentage(100)];
499 let table = Table::new(rows, widths)
500 .header(header)
501 .block(Block::default().title(title).borders(Borders::ALL));
502
503 f.render_widget(table, area);
504 }
505
506 fn render_help(&self, f: &mut Frame, area: Rect) {
507 let help = match self.view_mode {
508 LibraryViewMode::List => "t: Switch Console/Collection | ↑↓: Select (games load) | Enter: Focus games | Esc: Back",
509 LibraryViewMode::Roms => "←: Back to list | ↑↓: Navigate | Enter: Game detail | Esc: Back",
510 };
511 let p =
512 ratatui::widgets::Paragraph::new(help).block(Block::default().borders(Borders::ALL));
513 f.render_widget(p, area);
514 }
515}