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::tui::text_search::{
10 filter_source_indices, jump_next_index, normalize_label, SearchState,
11};
12use crate::types::{Collection, Platform, Rom, RomList};
13
14pub use crate::tui::text_search::LibrarySearchMode;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum LibrarySubsection {
19 ByConsole,
20 ByCollection,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
25pub enum LibraryViewMode {
26 List,
28 Roms,
30}
31
32pub struct LibraryBrowseScreen {
34 pub platforms: Vec<Platform>,
35 pub collections: Vec<Collection>,
36 pub subsection: LibrarySubsection,
37 pub list_index: usize,
38 pub view_mode: LibraryViewMode,
39 pub roms: Option<RomList>,
40 pub rom_groups: Option<Vec<RomGroup>>,
42 pub rom_selected: usize,
43 pub scroll_offset: usize,
44 visible_rows: usize,
46 pub list_search: SearchState,
48 pub rom_search: SearchState,
50}
51
52impl LibraryBrowseScreen {
53 pub fn new(platforms: Vec<Platform>, collections: Vec<Collection>) -> Self {
54 Self {
55 platforms,
56 collections,
57 subsection: LibrarySubsection::ByConsole,
58 list_index: 0,
59 view_mode: LibraryViewMode::List,
60 roms: None,
61 rom_groups: None,
62 rom_selected: 0,
63 scroll_offset: 0,
64 visible_rows: 20,
65 list_search: SearchState::new(),
66 rom_search: SearchState::new(),
67 }
68 }
69
70 pub fn any_search_bar_open(&self) -> bool {
72 self.list_search.mode.is_some() || self.rom_search.mode.is_some()
73 }
74
75 fn list_row_labels(&self) -> Vec<String> {
77 match self.subsection {
78 LibrarySubsection::ByConsole => self
79 .platforms
80 .iter()
81 .map(|p| {
82 let name = p.display_name.as_deref().unwrap_or(&p.name);
83 format!("{} ({} roms)", name, p.rom_count)
84 })
85 .collect(),
86 LibrarySubsection::ByCollection => self
87 .collections
88 .iter()
89 .map(|c| {
90 let title = if c.is_virtual {
91 format!("{} [auto]", c.name)
92 } else if c.is_smart {
93 format!("{} [smart]", c.name)
94 } else {
95 c.name.clone()
96 };
97 format!("{} ({} roms)", title, c.rom_count.unwrap_or(0))
98 })
99 .collect(),
100 }
101 }
102
103 fn visible_list_source_indices(&self) -> Vec<usize> {
104 let labels = self.list_row_labels();
105 if self.list_search.filter_active() {
106 filter_source_indices(&labels, &self.list_search.normalized_query)
107 } else {
108 (0..labels.len()).collect()
109 }
110 }
111
112 fn clamp_list_index(&mut self) {
113 let v = self.visible_list_source_indices();
114 if v.is_empty() || self.list_index >= v.len() {
115 self.list_index = 0;
116 }
117 }
118
119 fn selected_list_source_index(&self) -> Option<usize> {
121 let v = self.visible_list_source_indices();
122 v.get(self.list_index).copied()
123 }
124
125 pub fn list_len(&self) -> usize {
126 self.visible_list_source_indices().len()
127 }
128
129 pub fn list_next(&mut self) {
130 let len = self.list_len();
131 if len > 0 {
132 self.list_index = (self.list_index + 1) % len;
133 }
134 }
135
136 pub fn list_previous(&mut self) {
137 let len = self.list_len();
138 if len > 0 {
139 self.list_index = if self.list_index == 0 {
140 len - 1
141 } else {
142 self.list_index - 1
143 };
144 }
145 }
146
147 pub fn rom_next(&mut self) {
148 let groups = self.visible_rom_groups();
149 let len = groups.len();
150 if len > 0 {
151 self.rom_selected = (self.rom_selected + 1) % len;
152 self.update_rom_scroll(self.visible_rows);
153 }
154 }
155
156 pub fn rom_previous(&mut self) {
157 let groups = self.visible_rom_groups();
158 let len = groups.len();
159 if len > 0 {
160 self.rom_selected = if self.rom_selected == 0 {
161 len - 1
162 } else {
163 self.rom_selected - 1
164 };
165 self.update_rom_scroll(self.visible_rows);
166 }
167 }
168
169 fn update_rom_scroll(&mut self, visible: usize) {
170 if self.rom_groups.is_none() {
171 return;
172 }
173 let list_len = self.visible_rom_groups().len();
174 self.update_rom_scroll_with_len(list_len, visible);
175 }
176
177 fn update_rom_scroll_with_len(&mut self, list_len: usize, visible: usize) {
178 let visible = visible.max(1);
179 let max_scroll = list_len.saturating_sub(visible);
180 if self.rom_selected >= self.scroll_offset + visible {
181 self.scroll_offset = (self.rom_selected + 1).saturating_sub(visible);
182 } else if self.rom_selected < self.scroll_offset {
183 self.scroll_offset = self.rom_selected;
184 }
185 self.scroll_offset = self.scroll_offset.min(max_scroll);
186 }
187
188 pub fn switch_subsection(&mut self) {
189 self.subsection = match self.subsection {
190 LibrarySubsection::ByConsole => LibrarySubsection::ByCollection,
191 LibrarySubsection::ByCollection => LibrarySubsection::ByConsole,
192 };
193 self.list_index = 0;
194 self.roms = None;
195 self.view_mode = LibraryViewMode::List;
196 self.list_search.clear();
197 }
198
199 pub fn switch_view(&mut self) {
200 match self.view_mode {
201 LibraryViewMode::List => {
202 self.list_search.clear();
203 self.view_mode = LibraryViewMode::Roms;
204 }
205 LibraryViewMode::Roms => {
206 self.rom_search.clear();
207 self.view_mode = LibraryViewMode::List;
208 }
209 }
210 self.rom_selected = 0;
211 self.scroll_offset = 0;
212 }
213
214 pub fn back_to_list(&mut self) {
215 self.rom_search.clear();
216 self.view_mode = LibraryViewMode::List;
217 self.clear_roms();
218 }
219
220 pub fn clear_roms(&mut self) {
221 self.roms = None;
222 self.rom_groups = None;
223 }
224
225 pub fn set_roms(&mut self, roms: RomList) {
226 self.roms = Some(roms.clone());
227 self.rom_groups = Some(utils::group_roms_by_name(&roms.items));
228 self.rom_selected = 0;
229 self.scroll_offset = 0;
230 self.rom_search.clear();
231 }
232
233 pub fn enter_list_search(&mut self, mode: LibrarySearchMode) {
236 self.list_search.enter(mode);
237 self.list_index = 0;
238 }
239
240 pub fn clear_list_search(&mut self) {
241 self.list_search.clear();
242 self.clamp_list_index();
243 }
244
245 pub fn add_list_search_char(&mut self, c: char) {
246 self.list_search.add_char(c);
247 if self.list_search.mode == Some(LibrarySearchMode::Filter) {
248 self.list_index = 0;
249 } else if self.list_search.mode == Some(LibrarySearchMode::Jump) {
250 self.list_jump_match(false);
251 }
252 self.clamp_list_index();
253 }
254
255 pub fn delete_list_search_char(&mut self) {
256 self.list_search.delete_char();
257 if self.list_search.mode == Some(LibrarySearchMode::Filter) {
258 self.list_index = 0;
259 }
260 self.clamp_list_index();
261 }
262
263 pub fn commit_list_filter_bar(&mut self) {
264 self.list_search.commit_filter_bar();
265 self.clamp_list_index();
266 }
267
268 pub fn commit_rom_filter_bar(&mut self) {
269 self.rom_search.commit_filter_bar();
270 }
271
272 pub fn list_jump_match(&mut self, next: bool) {
273 if self.list_search.normalized_query.is_empty() {
274 return;
275 }
276 let labels = self.list_row_labels();
277 if labels.is_empty() {
278 return;
279 }
280 let source = self
281 .selected_list_source_index()
282 .unwrap_or(0)
283 .min(labels.len().saturating_sub(1));
284 if let Some(new_src) =
285 jump_next_index(&labels, source, &self.list_search.normalized_query, next)
286 {
287 let visible = self.visible_list_source_indices();
288 if let Some(pos) = visible.iter().position(|&i| i == new_src) {
289 self.list_index = pos;
290 }
291 }
292 }
293
294 pub fn enter_rom_search(&mut self, mode: LibrarySearchMode) {
297 self.rom_search.enter(mode);
298 self.rom_selected = 0;
299 self.scroll_offset = 0;
300 }
301
302 pub fn clear_rom_search(&mut self) {
303 self.rom_search.clear();
304 }
305
306 pub fn add_rom_search_char(&mut self, c: char) {
307 self.rom_search.add_char(c);
308 if self.rom_search.mode == Some(LibrarySearchMode::Filter) {
309 self.rom_selected = 0;
310 self.scroll_offset = 0;
311 } else if self.rom_search.mode == Some(LibrarySearchMode::Jump) {
312 self.jump_rom_match(false);
313 }
314 }
315
316 pub fn delete_rom_search_char(&mut self) {
317 self.rom_search.delete_char();
318 if self.rom_search.mode == Some(LibrarySearchMode::Filter) {
319 self.rom_selected = 0;
320 self.scroll_offset = 0;
321 }
322 }
323
324 pub fn jump_rom_match(&mut self, next: bool) {
325 if self.rom_search.normalized_query.is_empty() {
326 return;
327 }
328 let Some(ref groups) = self.rom_groups else {
329 return;
330 };
331 let labels: Vec<String> = groups.iter().map(|g| g.name.clone()).collect();
332 if labels.is_empty() {
333 return;
334 }
335 let source = self.rom_selected.min(labels.len().saturating_sub(1));
336 if let Some(idx) = jump_next_index(&labels, source, &self.rom_search.normalized_query, next)
337 {
338 self.rom_selected = idx;
339 self.update_rom_scroll(self.visible_rows);
340 }
341 }
342
343 pub fn get_selected_group(&self) -> Option<(Rom, Vec<Rom>)> {
344 let visible = self.visible_rom_groups();
345 if visible.is_empty() {
346 return None;
347 }
348 let idx = if self.rom_selected >= visible.len() {
349 0
350 } else {
351 self.rom_selected
352 };
353 visible
354 .get(idx)
355 .map(|g| (g.primary.clone(), g.others.clone()))
356 }
357
358 fn visible_rom_groups(&self) -> Vec<RomGroup> {
359 let Some(ref groups) = self.rom_groups else {
360 return Vec::new();
361 };
362 if self.rom_search.filter_active() {
363 groups
364 .iter()
365 .filter(|g| normalize_label(&g.name).contains(&self.rom_search.normalized_query))
366 .cloned()
367 .collect()
368 } else {
369 groups.clone()
370 }
371 }
372
373 fn list_title(&self) -> &str {
374 match self.subsection {
375 LibrarySubsection::ByConsole => "Consoles",
376 LibrarySubsection::ByCollection => "Collections",
377 }
378 }
379
380 fn selected_platform_id(&self) -> Option<u64> {
381 match self.subsection {
382 LibrarySubsection::ByConsole => self
383 .selected_list_source_index()
384 .and_then(|i| self.platforms.get(i).map(|p| p.id)),
385 LibrarySubsection::ByCollection => None,
386 }
387 }
388
389 pub fn cache_key(&self) -> Option<RomCacheKey> {
390 match self.subsection {
391 LibrarySubsection::ByConsole => self.selected_platform_id().map(RomCacheKey::Platform),
392 LibrarySubsection::ByCollection => self
393 .selected_list_source_index()
394 .and_then(|i| self.collections.get(i))
395 .map(|c| {
396 if c.is_virtual {
397 RomCacheKey::VirtualCollection(c.virtual_id.clone().unwrap_or_default())
398 } else if c.is_smart {
399 RomCacheKey::SmartCollection(c.id)
400 } else {
401 RomCacheKey::Collection(c.id)
402 }
403 }),
404 }
405 }
406
407 pub fn expected_rom_count(&self) -> u64 {
408 match self.subsection {
409 LibrarySubsection::ByConsole => self
410 .selected_list_source_index()
411 .and_then(|i| self.platforms.get(i).map(|p| p.rom_count))
412 .unwrap_or(0),
413 LibrarySubsection::ByCollection => self
414 .selected_list_source_index()
415 .and_then(|i| self.collections.get(i))
416 .and_then(|c| c.rom_count)
417 .unwrap_or(0),
418 }
419 }
420
421 pub fn get_roms_request_platform(&self) -> Option<GetRoms> {
422 let count = self.expected_rom_count().min(20000);
423 self.selected_platform_id().map(|id| GetRoms {
424 platform_id: Some(id),
425 limit: Some(count as u32),
426 ..Default::default()
427 })
428 }
429
430 pub fn get_roms_request_collection(&self) -> Option<GetRoms> {
431 if self.subsection != LibrarySubsection::ByCollection {
432 return None;
433 }
434 let count = self.expected_rom_count().min(20000);
435 self.selected_list_source_index()
436 .and_then(|i| self.collections.get(i))
437 .map(|c| {
438 if c.is_virtual {
439 GetRoms {
440 virtual_collection_id: c.virtual_id.clone(),
441 limit: Some(count as u32),
442 ..Default::default()
443 }
444 } else if c.is_smart {
445 GetRoms {
446 smart_collection_id: Some(c.id),
447 limit: Some(count as u32),
448 ..Default::default()
449 }
450 } else {
451 GetRoms {
452 collection_id: Some(c.id),
453 limit: Some(count as u32),
454 ..Default::default()
455 }
456 }
457 })
458 }
459
460 pub fn render(&mut self, f: &mut Frame, area: Rect) {
461 let chunks = Layout::default()
462 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
463 .direction(ratatui::layout::Direction::Horizontal)
464 .split(area);
465
466 let left_area = chunks[0];
467 if self.list_search.mode.is_some() {
468 let left_chunks = Layout::default()
469 .constraints([Constraint::Length(3), Constraint::Min(3)])
470 .direction(ratatui::layout::Direction::Vertical)
471 .split(left_area);
472 if let Some(mode) = self.list_search.mode {
473 let title = match mode {
474 LibrarySearchMode::Filter => "Filter Search (list)",
475 LibrarySearchMode::Jump => "Jump Search (list, Tab next)",
476 };
477 let p =
478 ratatui::widgets::Paragraph::new(format!("Search: {}", self.list_search.query))
479 .block(Block::default().title(title).borders(Borders::ALL));
480 f.render_widget(p, left_chunks[0]);
481 }
482 self.render_list(f, left_chunks[1]);
483 } else {
484 self.render_list(f, left_area);
485 }
486
487 let right_chunks = if self.rom_search.mode.is_some() {
488 Layout::default()
489 .constraints([
490 Constraint::Length(3),
491 Constraint::Min(5),
492 Constraint::Length(3),
493 ])
494 .direction(ratatui::layout::Direction::Vertical)
495 .split(chunks[1])
496 } else {
497 Layout::default()
498 .constraints([Constraint::Min(5), Constraint::Length(3)])
499 .direction(ratatui::layout::Direction::Vertical)
500 .split(chunks[1])
501 };
502
503 if let Some(mode) = self.rom_search.mode {
504 let title = match mode {
505 LibrarySearchMode::Filter => "Filter Search",
506 LibrarySearchMode::Jump => "Jump Search (Tab to next)",
507 };
508 let p = ratatui::widgets::Paragraph::new(format!("Search: {}", self.rom_search.query))
509 .block(Block::default().title(title).borders(Borders::ALL));
510 f.render_widget(p, right_chunks[0]);
511 self.render_roms(f, right_chunks[1]);
512 self.render_help(f, right_chunks[2]);
513 } else {
514 self.render_roms(f, right_chunks[0]);
515 self.render_help(f, right_chunks[1]);
516 }
517 }
518
519 fn render_list(&self, f: &mut Frame, area: Rect) {
520 let visible = self.visible_list_source_indices();
521 let labels = self.list_row_labels();
522
523 let items: Vec<ListItem> = visible
524 .iter()
525 .enumerate()
526 .map(|(pos, &source_idx)| {
527 let line = labels
528 .get(source_idx)
529 .cloned()
530 .unwrap_or_else(|| "?".to_string());
531 let prefix = if pos == self.list_index && self.view_mode == LibraryViewMode::List {
532 "▶ "
533 } else {
534 " "
535 };
536 ListItem::new(format!("{}{}", prefix, line))
537 })
538 .collect();
539
540 let list = List::new(items)
541 .block(
542 Block::default()
543 .title(self.list_title())
544 .borders(Borders::ALL),
545 )
546 .highlight_symbol(if self.view_mode == LibraryViewMode::List {
547 ">> "
548 } else {
549 " "
550 });
551
552 let mut state = ListState::default();
553 if self.view_mode == LibraryViewMode::List {
554 state.select(Some(self.list_index));
555 }
556
557 f.render_stateful_widget(list, area, &mut state);
558 }
559
560 fn render_roms(&mut self, f: &mut Frame, area: Rect) {
561 let visible = (area.height as usize).saturating_sub(3).max(1);
562 self.visible_rows = visible;
563
564 let groups = self.visible_rom_groups();
565 if groups.is_empty() {
566 let msg = if self.rom_search.mode.is_some() {
567 "No games match your search".to_string()
568 } else if self.roms.is_none() && self.expected_rom_count() > 0 {
569 format!("Loading {} games... please wait", self.expected_rom_count())
570 } else {
571 "Select a console or collection and press Enter to load ROMs".to_string()
572 };
573 let p = ratatui::widgets::Paragraph::new(msg)
574 .block(Block::default().title("Games").borders(Borders::ALL));
575 f.render_widget(p, area);
576 return;
577 }
578
579 if self.rom_selected >= groups.len() {
580 self.rom_selected = 0;
581 self.scroll_offset = 0;
582 }
583
584 self.update_rom_scroll_with_len(groups.len(), visible);
585
586 let start = self.scroll_offset.min(groups.len().saturating_sub(visible));
587 let end = (start + visible).min(groups.len());
588 let visible_groups = &groups[start..end];
589
590 let header = Row::new(vec![
591 Cell::from("Name").style(Style::default().fg(Color::Cyan))
592 ]);
593 let rows: Vec<Row> = visible_groups
594 .iter()
595 .enumerate()
596 .map(|(i, g)| {
597 let global_idx = start + i;
598 let style = if global_idx == self.rom_selected {
599 Style::default().fg(Color::Yellow)
600 } else {
601 Style::default()
602 };
603 Row::new(vec![Cell::from(g.name.as_str()).style(style)]).height(1)
604 })
605 .collect();
606
607 let total_files = self.roms.as_ref().map(|r| r.items.len()).unwrap_or(0);
608 let total_roms = self.roms.as_ref().map(|r| r.total).unwrap_or(0);
609 let title = if self.rom_search.filter_browsing && !self.rom_search.query.is_empty() {
610 format!(
611 "Games (filtered: \"{}\") — {} — {} files",
612 self.rom_search.query,
613 groups.len(),
614 total_files
615 )
616 } else if total_roms > 0 && (groups.len() as u64) < total_roms {
617 format!(
618 "Games ({} of {}) — {} files",
619 groups.len(),
620 total_roms,
621 total_files
622 )
623 } else {
624 format!("Games ({}) — {} files", groups.len(), total_files)
625 };
626 let widths = [Constraint::Percentage(100)];
627 let table = Table::new(rows, widths)
628 .header(header)
629 .block(Block::default().title(title).borders(Borders::ALL));
630
631 f.render_widget(table, area);
632 }
633
634 fn render_help(&self, f: &mut Frame, area: Rect) {
635 let help = match self.view_mode {
636 LibraryViewMode::List => {
637 if self.list_search.mode.is_some() {
638 "Type filter | Enter: browse matches | Esc: clear"
639 } else if self.list_search.filter_browsing {
640 "↑↓: Navigate | Enter: Load games | Esc: clear filter"
641 } else {
642 "t: Switch | ↑↓: Select | / f: Filter/Jump list | Enter: Games | Esc: Menu"
643 }
644 }
645 LibraryViewMode::Roms => {
646 if self.rom_search.mode.is_some() {
647 "Type filter | Enter: browse matches | Esc: clear filter"
648 } else if self.rom_search.filter_browsing {
649 "←: Back to list | ↑↓: Navigate | Enter: Game detail | Esc: clear filter"
650 } else {
651 "←: Back to list | ↑↓: Navigate | / f: Filter/Jump games | Enter: Game detail | Esc: Back"
652 }
653 }
654 };
655 let p =
656 ratatui::widgets::Paragraph::new(help).block(Block::default().borders(Borders::ALL));
657 f.render_widget(p, area);
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use crate::core::utils;
665 use crate::types::Rom;
666
667 fn rom(id: u64, name: &str, fs_name: &str) -> Rom {
668 Rom {
669 id,
670 platform_id: 1,
671 platform_slug: None,
672 platform_fs_slug: None,
673 platform_custom_name: None,
674 platform_display_name: None,
675 fs_name: fs_name.to_string(),
676 fs_name_no_tags: name.to_string(),
677 fs_name_no_ext: name.to_string(),
678 fs_extension: "zip".to_string(),
679 fs_path: format!("/{id}.zip"),
680 fs_size_bytes: 1,
681 name: name.to_string(),
682 slug: None,
683 summary: None,
684 path_cover_small: None,
685 path_cover_large: None,
686 url_cover: None,
687 is_unidentified: false,
688 is_identified: true,
689 }
690 }
691
692 #[test]
693 fn get_selected_group_clamps_stale_index_after_filter() {
694 let mut s = LibraryBrowseScreen::new(vec![], vec![]);
695 let items = vec![
696 rom(1, "alpha", "a.zip"),
697 rom(2, "alphabet", "ab.zip"),
698 rom(3, "beta", "b.zip"),
699 ];
700 s.rom_groups = Some(utils::group_roms_by_name(&items));
701 s.view_mode = LibraryViewMode::Roms;
702 s.enter_rom_search(LibrarySearchMode::Filter);
703 for c in "alp".chars() {
704 s.add_rom_search_char(c);
705 }
706 s.rom_search.mode = None;
707 s.rom_search.filter_browsing = true;
708 s.rom_selected = 99;
709 let (primary, _) = s
710 .get_selected_group()
711 .expect("clamped index should yield a group");
712 assert_eq!(primary.name, "alpha");
713 }
714
715 #[test]
716 fn rom_next_wraps_within_filtered_list_when_filter_browsing() {
717 let mut s = LibraryBrowseScreen::new(vec![], vec![]);
718 let items = vec![
719 rom(1, "alpha", "a.zip"),
720 rom(2, "alphabet", "ab.zip"),
721 rom(3, "beta", "b.zip"),
722 ];
723 s.rom_groups = Some(utils::group_roms_by_name(&items));
724 s.view_mode = LibraryViewMode::Roms;
725 s.enter_rom_search(LibrarySearchMode::Filter);
726 for c in "alp".chars() {
727 s.add_rom_search_char(c);
728 }
729 s.rom_search.mode = None;
730 s.rom_search.filter_browsing = true;
731 assert_eq!(s.rom_selected, 0);
732 s.rom_next();
733 assert_eq!(s.rom_selected, 1);
734 s.rom_next();
735 assert_eq!(s.rom_selected, 0);
736 }
737}