use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Instant;
use gilrs::{Axis, EventType, Gilrs, GilrsBuilder};
use winit::application::ApplicationHandler;
use winit::event::{ElementState, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::WindowId;
use super::renderer::{BrowserGl, TextureKey};
use super::theme;
use crate::platform::app_context::SharedAppContext;
use crate::platform::catalog::Platform;
use crate::platform::catalog::RomEntry;
use crate::platform::catalog::favorites::Favorites;
use crate::platform::catalog::EnrichmentPhase;
use crate::platform::catalog::EnrichmentProgress;
enum CatalogMessage {
Progress(EnrichmentProgress),
Done(Vec<RomEntry>),
}
enum CatalogState {
Idle,
Loading {
receiver: mpsc::Receiver<CatalogMessage>,
progress: Option<EnrichmentProgress>,
},
Ready,
}
#[derive(Debug, Clone)]
pub enum BrowserResult {
RomSelected(PathBuf),
Closed,
}
const AXIS_DEAD_ZONE: f32 = 0.5;
const REPEAT_DELAY_MS: u128 = 400;
const REPEAT_INTERVAL_MS: u128 = 80;
#[derive(Default)]
struct GamepadAxisState {
up: bool,
down: bool,
left: bool,
right: bool,
}
#[derive(Hash, Eq, PartialEq, Clone, Copy)]
enum RepeatDirection {
Up,
Down,
Left,
Right,
}
#[derive(Default)]
struct GamepadRepeatState {
held: HashMap<RepeatDirection, Instant>,
last_repeat: HashMap<RepeatDirection, Instant>,
}
enum BrowserAction {
Up,
Down,
Left,
Right,
Confirm,
Back,
Search,
Favorite,
Detail,
GenreFilter,
}
pub struct RomBrowserApp {
app_context: SharedAppContext,
gl: Option<BrowserGl>,
result: BrowserResult,
default_width: u32,
default_height: u32,
fullscreen: bool,
last_render_instant: Instant,
catalog: Vec<RomEntry>,
filtered_indices: Vec<usize>,
selected_index: usize,
scroll_offset: f32,
scroll_target: f32,
search_active: bool,
search_query: String,
search_anim: f32,
search_kb_row: usize,
search_kb_col: usize,
genre_filter_active: bool,
available_genres: Vec<String>,
active_genres: Vec<String>,
genre_cursor: usize,
detail_view_active: bool,
detail_screenshot_index: usize,
detail_scroll_last_advance: Instant,
detail_scroll_forward: bool,
filter_panel_active: bool,
filter_panel_anim: f32,
filter_panel_cursor: usize,
filter_panel_column: usize,
active_platform: Option<crate::platform::catalog::Platform>,
min_players_filter: Option<u32>,
favorites: Favorites,
show_favorites_only: bool,
catalog_state: CatalogState,
modifiers: winit::keyboard::ModifiersState,
gilrs: Option<Gilrs>,
gamepad_axis: GamepadAxisState,
gamepad_repeat: GamepadRepeatState,
texture_request_tx: mpsc::Sender<(i64, PathBuf)>,
texture_result_rx: mpsc::Receiver<(i64, u32, u32, Vec<u8>)>,
texture_pending: Vec<i64>,
boxart_by_game_id: std::collections::HashMap<i64, PathBuf>,
}
impl RomBrowserApp {
pub fn new(app_context: SharedAppContext) -> Self {
let (default_height, fullscreen, favorites_path) = {
let ctx = app_context.borrow();
let config = ctx.config();
(
config.frontend.window_height,
config.frontend.fullscreen,
config.frontend.resolved_favorites_path(),
)
};
let default_width = (default_height as f64 * 16.0 / 9.0) as u32;
let (request_tx, request_rx) = mpsc::channel::<(i64, PathBuf)>();
let (result_tx, result_rx) = mpsc::channel::<(i64, u32, u32, Vec<u8>)>();
std::thread::Builder::new()
.name("texture-decoder".into())
.spawn(move || {
while let Ok((game_id, path)) = request_rx.recv() {
match image::open(&path) {
Ok(img) => {
let rgba = img.into_rgba8();
let (w, h) = rgba.dimensions();
let pixels = rgba.into_raw();
if result_tx.send((game_id, w, h, pixels)).is_err() {
break;
}
}
Err(_) => {
if result_tx.send((game_id, 0, 0, Vec::new())).is_err() {
break;
}
}
}
}
})
.expect("failed to spawn texture decoder thread");
Self {
app_context,
gl: None,
result: BrowserResult::Closed,
default_width,
default_height,
fullscreen,
last_render_instant: Instant::now(),
catalog: Vec::new(),
filtered_indices: Vec::new(),
selected_index: 0,
scroll_offset: 0.0,
scroll_target: 0.0,
search_active: false,
search_query: String::new(),
search_anim: 0.0,
search_kb_row: 1,
search_kb_col: 0,
genre_filter_active: false,
available_genres: Vec::new(),
active_genres: Vec::new(),
genre_cursor: 0,
detail_view_active: false,
detail_screenshot_index: 0,
detail_scroll_last_advance: Instant::now(),
detail_scroll_forward: true,
filter_panel_active: false,
filter_panel_anim: 0.0,
filter_panel_cursor: 0,
filter_panel_column: 0,
active_platform: Some(crate::platform::catalog::Platform::Nes),
min_players_filter: None,
favorites: Favorites::load(&favorites_path),
show_favorites_only: false,
catalog_state: CatalogState::Idle,
modifiers: winit::keyboard::ModifiersState::empty(),
gilrs: {
let mut builder = GilrsBuilder::new()
.with_default_filters(true)
.add_env_mappings(true);
if let Ok(mappings) = std::fs::read_to_string("gamecontrollerdb.txt") {
builder = builder.add_mappings(&mappings);
}
builder.build().ok()
},
gamepad_axis: GamepadAxisState::default(),
gamepad_repeat: GamepadRepeatState::default(),
texture_request_tx: request_tx,
texture_result_rx: result_rx,
texture_pending: Vec::new(),
boxart_by_game_id: std::collections::HashMap::new(),
}
}
pub fn set_catalog(&mut self, mut catalog: Vec<RomEntry>) {
for entry in &mut catalog {
entry.is_favorite = self.favorites.contains(&entry.path);
}
let mut genres: Vec<String> = catalog
.iter()
.flat_map(|e| e.genres.iter().cloned())
.collect();
genres.sort();
genres.dedup();
self.available_genres = genres;
self.catalog = catalog;
self.boxart_by_game_id.clear();
for entry in &self.catalog {
if let (Some(gid), Some(path)) = (entry.metadata_game_id, &entry.boxart_path) {
self.boxart_by_game_id.insert(gid, path.clone());
}
}
self.rebuild_filtered();
}
fn rebuild_filtered(&mut self) {
let query = self.search_query.to_lowercase();
self.filtered_indices = self
.catalog
.iter()
.enumerate()
.filter(|(_, e)| {
if matches!(self.active_platform, Some(plat) if e.platform != plat) {
return false;
}
if self.show_favorites_only && !e.is_favorite {
return false;
}
if !query.is_empty()
&& !e.search_key.contains(&query)
&& !e.display_name.to_lowercase().contains(&query)
{
return false;
}
if !self.active_genres.is_empty()
&& !e.genres.iter().any(|g| self.active_genres.contains(g))
{
return false;
}
if let Some(min) = self.min_players_filter
&& e.players.unwrap_or(1) < min
{
return false;
}
true
})
.map(|(i, _)| i)
.collect();
if self.selected_index >= self.filtered_indices.len() {
self.selected_index = self.filtered_indices.len().saturating_sub(1);
}
self.scroll_offset = 0.0;
self.scroll_target = 0.0;
}
fn selected_entry(&self) -> Option<&RomEntry> {
let &catalog_idx = self.filtered_indices.get(self.selected_index)?;
self.catalog.get(catalog_idx)
}
fn toggle_favorite(&mut self) {
if let Some(&catalog_idx) = self.filtered_indices.get(self.selected_index)
&& let Some(entry) = self.catalog.get_mut(catalog_idx)
{
let new_status = self.favorites.toggle(&entry.path);
entry.is_favorite = new_status;
if let Err(e) = self.favorites.save() {
crate::platform::debugging::log_info(format!("Failed to save favorites: {e}"));
}
if self.show_favorites_only && !new_status {
self.rebuild_filtered();
}
}
}
pub fn run(&mut self, event_loop: &mut EventLoop<()>) -> Result<BrowserResult, String> {
use winit::platform::run_on_demand::EventLoopExtRunOnDemand;
self.result = BrowserResult::Closed;
self.gl = None;
event_loop
.run_app_on_demand(self)
.map_err(|e| format!("Browser event loop error: {e}"))?;
Ok(self.result.clone())
}
fn poll_catalog_loading(&mut self) {
loop {
let msg = if let CatalogState::Loading { ref receiver, .. } = self.catalog_state {
receiver.try_recv().ok()
} else {
break;
};
match msg {
Some(CatalogMessage::Progress(p)) => {
if let CatalogState::Loading {
ref mut progress, ..
} = self.catalog_state
{
*progress = Some(p);
}
}
Some(CatalogMessage::Done(catalog)) => {
self.set_catalog(catalog);
self.catalog_state = CatalogState::Ready;
break;
}
None => break,
}
}
}
fn render_frame(&mut self) {
self.poll_catalog_loading();
if matches!(self.catalog_state, CatalogState::Ready) {
self.lazy_load_visible_textures();
}
let Some(ref mut gl) = self.gl else { return };
if let CatalogState::Loading { ref progress, .. } = self.catalog_state {
let progress_snapshot = progress.clone();
let (display_w, display_h) = gl.logical_size();
gl.run_frame(|ui| {
ui.ctx().set_visuals(egui::Visuals {
dark_mode: true,
panel_fill: theme::BG_COLOR,
window_fill: theme::BG_COLOR,
..egui::Visuals::dark()
});
egui::CentralPanel::default()
.frame(egui::Frame::new().fill(theme::BG_COLOR))
.show_inside(ui, |ui| {
Self::render_loading_screen(
ui,
display_w,
display_h,
progress_snapshot.as_ref(),
);
});
});
return;
}
let (display_w, display_h) = gl.logical_size();
let dt = self.last_render_instant.elapsed().as_secs_f32().min(0.1);
self.last_render_instant = Instant::now();
let diff = self.scroll_target - self.scroll_offset;
if diff.abs() < 0.5 {
self.scroll_offset = self.scroll_target;
} else {
self.scroll_offset += diff * (theme::SCROLL_SPEED * dt).min(1.0);
}
let scroll_offset = self.scroll_offset;
let sidebar_w = theme::sidebar_width(display_w);
let grid_area_w = display_w - sidebar_w;
let (cols, cover_w) = theme::grid_layout(grid_area_w);
let cell_h = theme::cell_height(cover_w);
let cover_h = cover_w / theme::COVER_ASPECT;
let filtered_count = self.filtered_indices.len();
let total_count = self.catalog.len();
let selected = self.selected_index;
let tex_map: HashMap<i64, (egui::TextureId, u32, u32)> = self
.catalog
.iter()
.filter_map(|e| {
let game_id = e.metadata_game_id?;
let tex = gl.get_texture(&TextureKey::CoverArt(game_id))?;
let [width, height] = tex.size();
Some((game_id, (tex.egui_id, width, height)))
})
.collect();
let display_entries: Vec<RomEntry> = self
.filtered_indices
.iter()
.map(|&idx| self.catalog[idx].clone())
.collect();
let search_active = self.search_active;
let search_query = self.search_query.clone();
let genre_filter_active = self.genre_filter_active;
let filter_panel_active = self.filter_panel_active;
let filter_panel_cursor = self.filter_panel_cursor;
let filter_panel_column = self.filter_panel_column;
let active_platform = self.active_platform;
let min_players_filter = self.min_players_filter;
let anim_target = if self.filter_panel_active { 1.0 } else { 0.0 };
let anim_step = dt / 0.30; if self.filter_panel_anim < anim_target {
self.filter_panel_anim = (self.filter_panel_anim + anim_step).min(anim_target);
} else if self.filter_panel_anim > anim_target {
self.filter_panel_anim = (self.filter_panel_anim - anim_step).max(anim_target);
}
let filter_panel_anim = self.filter_panel_anim;
let search_anim_target = if self.search_active { 1.0 } else { 0.0 };
if self.search_anim < search_anim_target {
self.search_anim = (self.search_anim + anim_step).min(search_anim_target);
} else if self.search_anim > search_anim_target {
self.search_anim = (self.search_anim - anim_step).max(search_anim_target);
}
let search_anim = self.search_anim;
let search_kb_row = self.search_kb_row;
let search_kb_col = self.search_kb_col;
let available_genres = self.available_genres.clone();
let active_genres = self.active_genres.clone();
let genre_cursor = self.genre_cursor;
let detail_view_active = self.detail_view_active;
if detail_view_active {
self.advance_screenshot_auto_scroll();
}
let detail_screenshot_index = self.detail_screenshot_index;
let show_favorites_only = self.show_favorites_only;
let selected_entry: Option<RomEntry> = self
.filtered_indices
.get(selected)
.and_then(|&idx| self.catalog.get(idx))
.cloned();
let mut screenshot_textures: Vec<(egui::TextureId, u32, u32)> = Vec::new();
if detail_view_active
&& let Some(ref entry) = selected_entry
&& let Some(game_id) = entry.metadata_game_id
{
let gl = self.gl.as_mut().unwrap();
for (i, path) in entry.screenshot_paths.iter().enumerate() {
let key = TextureKey::Screenshot(game_id, i);
if let Some(tex) = gl.get_texture(&key) {
let [width, height] = tex.size();
screenshot_textures.push((tex.egui_id, width, height));
} else if let Some(tex) = gl.load_texture_from_file(key, path) {
let [width, height] = tex.size();
screenshot_textures.push((tex.egui_id, width, height));
}
}
}
let gl = self.gl.as_mut().unwrap();
let genre_suffix = if active_genres.is_empty() {
String::new()
} else {
format!(" [{}]", active_genres.join(", "))
};
let fav_suffix = if show_favorites_only {
" \u{2665}"
} else {
""
};
let header_text = if search_query.is_empty() {
format!(
"NESER ROM Browser \u{2014} {filtered_count}/{total_count} games{genre_suffix}{fav_suffix}"
)
} else {
format!(
"NESER ROM Browser \u{2014} {filtered_count}/{total_count} (search: \"{search_query}\"){genre_suffix}{fav_suffix}"
)
};
gl.run_frame(|ui| {
let mut visuals = egui::Visuals::dark();
visuals.dark_mode = true;
visuals.panel_fill = egui::Color32::TRANSPARENT;
visuals.window_fill = egui::Color32::TRANSPARENT;
visuals.extreme_bg_color = egui::Color32::from_rgb(10, 10, 15);
visuals.faint_bg_color = egui::Color32::from_rgb(20, 20, 30);
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::NONE;
visuals.widgets.inactive.bg_stroke =
egui::Stroke::new(1.0, egui::Color32::from_rgb(60, 60, 75));
visuals.widgets.inactive.fg_stroke =
egui::Stroke::new(1.0, egui::Color32::from_rgb(180, 180, 195));
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.5, theme::SELECTION_COLOR);
visuals.widgets.active.bg_stroke = egui::Stroke::new(1.5, theme::SELECTION_COLOR);
visuals.widgets.active.fg_stroke = egui::Stroke::new(2.0, theme::SELECTION_COLOR);
visuals.selection.bg_fill = theme::SELECTION_COLOR;
visuals.selection.stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
visuals.widgets.inactive.corner_radius = egui::CornerRadius::same(4);
visuals.widgets.hovered.corner_radius = egui::CornerRadius::same(4);
visuals.widgets.active.corner_radius = egui::CornerRadius::same(4);
ui.ctx().set_visuals(visuals);
ui.ctx().global_style_mut(|s| {
s.text_styles.insert(
egui::TextStyle::Body,
egui::FontId::new(14.0, egui::FontFamily::Proportional),
);
s.text_styles.insert(
egui::TextStyle::Small,
egui::FontId::new(11.0, egui::FontFamily::Proportional),
);
s.text_styles.insert(
egui::TextStyle::Heading,
egui::FontId::new(20.0, egui::FontFamily::Monospace),
);
});
let full_rect = ui.max_rect();
let painter = ui.painter();
painter.add(egui::Shape::gradient_rect(
full_rect,
egui::epaint::Direction::TopDown,
[theme::BG_COLOR, theme::BG_COLOR_LIGHT],
));
let sidebar_panel_frame = egui::Frame::new()
.fill(egui::Color32::TRANSPARENT)
.inner_margin(egui::Margin::same(0))
.stroke(egui::Stroke::NONE);
egui::Panel::right("sidebar")
.exact_size(sidebar_w)
.resizable(false)
.frame(sidebar_panel_frame)
.show_inside(ui, |ui| {
egui::Frame::new()
.fill(theme::SIDEBAR_BG)
.inner_margin(egui::Margin::same(12))
.outer_margin(egui::Margin {
left: 16,
right: 16,
top: 16,
bottom: 16,
})
.corner_radius(egui::CornerRadius::same(theme::CORNER_RADIUS as u8))
.stroke(egui::Stroke::NONE)
.show(ui, |ui| {
if let Some(ref entry) = selected_entry {
Self::render_sidebar_egui(ui, entry, &tex_map);
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
let legend_items: &[(&str, &str)] = if search_active {
&[("Tab", "Close"), ("Enter", "Launch")]
} else if genre_filter_active {
&[("↑↓", "Navigate"), ("Enter", "Toggle"), ("Esc", "Close")]
} else if filter_panel_active {
&[("↑↓", "Navigate"), ("A", "Toggle"), ("B", "Close")]
} else if detail_view_active {
&[("A", "Launch"), ("Y", "Fav"), ("B", "Back")]
} else {
&[
("A", "Details"),
("B", "Filter"),
("Select", "Favorite"),
("Tab", "Search"),
]
};
Self::render_button_legend(ui, legend_items);
});
});
});
let bar_frame = egui::Frame::new()
.fill(egui::Color32::TRANSPARENT)
.inner_margin(egui::Margin::same(8))
.stroke(egui::Stroke::NONE);
egui::Panel::top("header")
.exact_size(theme::HEADER_HEIGHT)
.frame(bar_frame)
.show_inside(ui, |ui| {
ui.label(
egui::RichText::new(&header_text)
.color(theme::HEADER_TEXT)
.size(20.0),
);
});
egui::CentralPanel::default()
.frame(
egui::Frame::new()
.fill(egui::Color32::TRANSPARENT)
.stroke(egui::Stroke::NONE),
)
.show_inside(ui, |ui| {
Self::render_grid_egui(
ui,
&display_entries,
&tex_map,
cols,
cover_w,
cover_h,
cell_h,
selected,
scroll_offset,
&search_query,
);
});
if search_active || search_anim > 0.0 {
Self::render_search_panel_egui(
ui.ctx(),
&search_query,
filtered_count,
search_kb_row,
search_kb_col,
search_anim,
display_w,
display_h,
);
}
if genre_filter_active && !available_genres.is_empty() {
Self::render_genre_filter_egui(
ui.ctx(),
&available_genres,
&active_genres,
genre_cursor,
display_w,
display_h,
);
}
if filter_panel_active || filter_panel_anim > 0.0 {
Self::render_filter_panel_egui(
ui.ctx(),
&available_genres,
&active_genres,
active_platform,
min_players_filter,
filter_panel_cursor,
filter_panel_column,
filter_panel_anim,
display_w,
display_h,
);
}
if detail_view_active && let Some(ref entry) = selected_entry {
Self::render_detail_view_egui(
ui.ctx(),
entry,
&tex_map,
&screenshot_textures,
detail_screenshot_index,
display_w,
display_h,
);
}
});
}
fn render_loading_screen(
ui: &mut egui::Ui,
display_w: f32,
display_h: f32,
progress: Option<&EnrichmentProgress>,
) {
let bar_w = (display_w * 0.55).max(320.0);
ui.vertical_centered(|ui| {
ui.add_space(display_h / 2.0 - 42.0);
ui.label(
egui::RichText::new("NESER ROM Browser")
.color(theme::HEADER_TEXT)
.size(20.0),
);
ui.add_space(8.0);
if let Some(p) = progress {
let fraction = if p.total > 0 {
p.current as f32 / p.total as f32
} else {
0.0
};
let phase_label = match p.phase {
EnrichmentPhase::MatchingMetadata => "Matching metadata",
EnrichmentPhase::DownloadingImages => "Downloading cover art",
};
ui.label(
egui::RichText::new(format!("{phase_label}: {} / {}", p.current, p.total))
.color(theme::DIM_TEXT)
.size(13.0),
);
ui.add_space(4.0);
ui.add(
egui::ProgressBar::new(fraction)
.desired_width(bar_w)
.fill(egui::Color32::from_rgb(100, 140, 220)),
);
ui.add_space(4.0);
let max_chars = (bar_w / 8.0) as usize;
let title = Self::truncate_label(&p.game_title, max_chars);
ui.label(
egui::RichText::new(&title)
.color(theme::DIM_TEXT)
.size(12.0),
);
} else {
ui.label(
egui::RichText::new("Scanning ROM library...")
.color(theme::DIM_TEXT)
.size(13.0),
);
ui.add_space(4.0);
ui.add(
egui::ProgressBar::new(0.0)
.desired_width(bar_w)
.fill(egui::Color32::from_rgb(60, 80, 120)),
);
}
});
}
#[allow(clippy::too_many_arguments)]
fn render_grid_egui(
ui: &mut egui::Ui,
entries: &[RomEntry],
tex_map: &HashMap<i64, (egui::TextureId, u32, u32)>,
cols: usize,
cover_w: f32,
cover_h: f32,
cell_h: f32,
selected: usize,
scroll_offset: f32,
search_query: &str,
) {
if entries.is_empty() {
ui.vertical_centered(|ui| {
ui.add_space(40.0);
if search_query.is_empty() {
ui.label(
egui::RichText::new("No ROMs found. Add ROM files to ~/.neser/roms/")
.color(theme::DIM_TEXT)
.size(13.0),
);
} else {
ui.label(
egui::RichText::new(format!("No games match \"{search_query}\""))
.color(theme::DIM_TEXT)
.size(13.0),
);
}
});
return;
}
let panel_rect = ui.available_rect_before_wrap();
ui.allocate_rect(panel_rect, egui::Sense::hover());
let painter = ui.painter_at(panel_rect);
let origin = panel_rect.min;
let rounding = egui::CornerRadius::same(theme::CORNER_RADIUS as u8);
for (i, entry) in entries.iter().enumerate() {
let row = (i / cols) as f32;
let col = (i % cols) as f32;
let x = origin.x + theme::GRID_PADDING + col * (cover_w + theme::GRID_SPACING);
let y = origin.y + theme::GRID_PADDING + row * (cell_h + theme::GRID_SPACING)
- scroll_offset;
if y + cell_h < panel_rect.top() || y > panel_rect.bottom() {
continue;
}
let cover_rect =
egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(cover_w, cover_h));
if i == selected {
let glow_rect = cover_rect.expand(4.0);
painter.add(
egui::epaint::RectShape::filled(glow_rect, rounding, theme::SELECTION_COLOR)
.with_blur_width(theme::SELECTION_GLOW),
);
}
if let Some(game_id) = entry.metadata_game_id {
if let Some(&(tex_id, tex_w, tex_h)) = tex_map.get(&game_id) {
let img_aspect = tex_w as f32 / tex_h.max(1) as f32;
let (draw_w, draw_h) = if img_aspect > cover_w / cover_h {
(cover_w, cover_w / img_aspect)
} else {
(cover_h * img_aspect, cover_h)
};
let draw_x = x + (cover_w - draw_w) / 2.0;
let draw_y = y + (cover_h - draw_h) / 2.0;
let img_rect = egui::Rect::from_min_size(
egui::pos2(draw_x, draw_y),
egui::vec2(draw_w, draw_h),
);
let uv = egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0));
painter.add(
egui::epaint::RectShape::filled(img_rect, rounding, egui::Color32::WHITE)
.with_texture(tex_id, uv),
);
} else {
painter.rect_filled(cover_rect, rounding, theme::PLACEHOLDER_BG);
let max_chars = (cover_w / 8.0) as usize;
let short = Self::truncate_label(&entry.display_name, max_chars);
painter.text(
cover_rect.center(),
egui::Align2::CENTER_CENTER,
&short,
egui::FontId::new(10.0, egui::FontFamily::Proportional),
theme::DIM_TEXT,
);
}
} else {
painter.rect_filled(cover_rect, rounding, theme::PLACEHOLDER_BG);
let max_chars = (cover_w / 8.0) as usize;
let short = Self::truncate_label(&entry.display_name, max_chars);
painter.text(
cover_rect.center(),
egui::Align2::CENTER_CENTER,
&short,
egui::FontId::new(10.0, egui::FontFamily::Proportional),
theme::DIM_TEXT,
);
}
if entry.is_favorite {
painter.text(
egui::pos2(x + cover_w - 16.0, y + 6.0),
egui::Align2::LEFT_TOP,
"\u{2665}",
egui::FontId::new(14.0, egui::FontFamily::Proportional),
theme::FAVORITE_COLOR,
);
}
}
}
fn render_sidebar_egui(
ui: &mut egui::Ui,
entry: &RomEntry,
tex_map: &HashMap<i64, (egui::TextureId, u32, u32)>,
) {
let avail_w = ui.available_width();
let art_area_h = theme::SIDEBAR_ART_HEIGHT;
let (art_rect, _) =
ui.allocate_exact_size(egui::vec2(avail_w, art_area_h), egui::Sense::hover());
if let Some(game_id) = entry.metadata_game_id
&& let Some(&(tex_id, tex_w, tex_h)) = tex_map.get(&game_id)
{
let img_aspect = tex_w as f32 / tex_h.max(1) as f32;
let (draw_w, draw_h) = if img_aspect > avail_w / art_area_h {
(avail_w, avail_w / img_aspect)
} else {
(art_area_h * img_aspect, art_area_h)
};
let cx = art_rect.center().x;
let cy = art_rect.center().y;
let img_rect =
egui::Rect::from_center_size(egui::pos2(cx, cy), egui::vec2(draw_w, draw_h));
let rounding = egui::CornerRadius::same(theme::CORNER_RADIUS as u8);
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
ui.painter().add(
egui::epaint::RectShape::filled(img_rect, rounding, egui::Color32::WHITE)
.with_texture(tex_id, uv),
);
}
ui.add_space(8.0);
ui.label(
egui::RichText::new(&entry.display_name)
.color(theme::HEADER_TEXT)
.size(20.0),
);
ui.add_space(6.0);
egui::Frame::new()
.fill(egui::Color32::from_rgb(22, 22, 28))
.corner_radius(egui::CornerRadius::same(theme::CORNER_RADIUS as u8))
.inner_margin(egui::Margin::same(10))
.show(ui, |ui| {
if !entry.genres.is_empty() {
ui.label(
egui::RichText::new(format!("Genre: {}", entry.genres.join(", ")))
.color(theme::DIM_TEXT)
.size(15.0),
);
}
if let Some(ref date) = entry.release_date {
ui.label(
egui::RichText::new(format!("Released: {date}"))
.color(theme::DIM_TEXT)
.size(15.0),
);
}
if let Some(players) = entry.players {
ui.label(
egui::RichText::new(format!("Players: {players}"))
.color(theme::DIM_TEXT)
.size(15.0),
);
}
if let Some(ref rating) = entry.rating {
ui.label(
egui::RichText::new(format!("Rating: {rating}"))
.color(theme::DIM_TEXT)
.size(15.0),
);
}
ui.label(
egui::RichText::new(format!("Mapper: {}", entry.mapper_label))
.color(theme::DIM_TEXT)
.size(15.0),
);
if let Some(ref crc) = entry.crc {
ui.label(
egui::RichText::new(format!("CRC: {crc}"))
.color(theme::DIM_TEXT)
.size(15.0),
);
}
if let Some(ref hw) = entry.hardware {
ui.label(
egui::RichText::new(format!("Hardware: {hw}"))
.color(theme::DIM_TEXT)
.size(15.0),
);
}
if let Some(file_name) = entry.path.file_name() {
ui.label(
egui::RichText::new(format!("File: {}", file_name.to_string_lossy()))
.color(theme::DIM_TEXT)
.size(15.0),
);
}
if entry.is_favorite {
ui.label(
egui::RichText::new("\u{2665} Favourite")
.color(theme::FAVORITE_COLOR)
.size(16.0),
);
}
});
if let Some(ref overview) = entry.overview {
ui.add_space(6.0);
let legend_reserve = 80.0;
let max_desc_h = (ui.available_height() - legend_reserve).max(0.0);
if max_desc_h > 30.0 {
egui::Frame::new()
.fill(egui::Color32::from_rgb(22, 22, 28))
.corner_radius(egui::CornerRadius::same(theme::CORNER_RADIUS as u8))
.inner_margin(egui::Margin::same(10))
.show(ui, |ui| {
let font = egui::FontId::proportional(15.0);
let avail_w = ui.available_width();
let text_w = avail_w.max(1.0);
let truncated = Self::truncate_text_to_height(
ui,
overview,
&font,
text_w,
max_desc_h - 20.0,
);
ui.label(
egui::RichText::new(truncated)
.color(theme::TEXT_COLOR)
.size(15.0),
);
});
}
}
}
fn truncate_text_to_height(
ui: &egui::Ui,
text: &str,
font: &egui::FontId,
wrap_width: f32,
max_height: f32,
) -> String {
let full_galley =
ui.painter()
.layout(text.to_owned(), font.clone(), theme::TEXT_COLOR, wrap_width);
if full_galley.size().y <= max_height {
return text.to_owned();
}
let chars: Vec<char> = text.chars().collect();
let mut lo = 0_usize;
let mut hi = chars.len();
while lo < hi {
let mid = (lo + hi).div_ceil(2);
let candidate: String = chars[..mid].iter().collect();
let candidate_text = format!("{candidate}...");
let galley =
ui.painter()
.layout(candidate_text, font.clone(), theme::TEXT_COLOR, wrap_width);
if galley.size().y <= max_height {
lo = mid;
} else {
hi = mid - 1;
}
}
if lo == 0 {
"...".to_owned()
} else {
let prefix: String = chars[..lo].iter().collect();
format!("{prefix}...")
}
}
fn render_button_legend(ui: &mut egui::Ui, items: &[(&str, &str)]) {
let pill_font = egui::FontId::new(14.0, egui::FontFamily::Monospace);
let label_font = egui::FontId::new(17.0, egui::FontFamily::Monospace);
let pill_h = 24.0_f32;
let outer_h = 30.0_f32;
let pill_pad_x = 7.0_f32;
let item_gap = 6.0_f32;
let label_gap = 8.0_f32;
let outer_pad_x = 8.0_f32;
let pill_rounding = egui::CornerRadius::same(12);
let outer_rounding = egui::CornerRadius::same(15);
let avail_w = ui.available_width();
let mut cursor_x = 0.0_f32;
let mut rows: Vec<Vec<(f32, f32, &str, &str)>> = vec![Vec::new()];
for &(btn, label) in items {
let btn_galley = ui.painter().layout_no_wrap(
btn.to_owned(),
pill_font.clone(),
egui::Color32::WHITE,
);
let label_galley = ui.painter().layout_no_wrap(
label.to_owned(),
label_font.clone(),
egui::Color32::WHITE,
);
let pill_w = btn_galley.size().x + pill_pad_x * 2.0;
let label_w = label_galley.size().x;
let outer_w = outer_pad_x + pill_w + label_gap + label_w + outer_pad_x;
if !rows.last().unwrap().is_empty() && cursor_x + outer_w > avail_w {
rows.push(Vec::new());
cursor_x = 0.0;
}
rows.last_mut().unwrap().push((pill_w, outer_w, btn, label));
cursor_x += outer_w + item_gap;
}
for row in rows.iter().rev() {
let (_, row_rect) = ui.allocate_space(egui::vec2(avail_w, outer_h + 4.0));
let mut x = row_rect.left();
let cy = row_rect.center().y;
for &(pill_w, outer_w, btn, label) in row {
let outer_rect = egui::Rect::from_min_size(
egui::pos2(x, cy - outer_h / 2.0),
egui::vec2(outer_w, outer_h),
);
ui.painter()
.rect_filled(outer_rect, outer_rounding, theme::LEGEND_ITEM_BG);
let pill_x = x + outer_pad_x;
let pill_rect = egui::Rect::from_min_size(
egui::pos2(pill_x, cy - pill_h / 2.0),
egui::vec2(pill_w, pill_h),
);
let pill_color = Self::button_pill_color(btn);
ui.painter().rect_stroke(
pill_rect,
pill_rounding,
egui::Stroke::new(1.5, pill_color),
egui::StrokeKind::Outside,
);
ui.painter().text(
pill_rect.center(),
egui::Align2::CENTER_CENTER,
btn,
pill_font.clone(),
pill_color,
);
let label_x = pill_x + pill_w + label_gap;
ui.painter().text(
egui::pos2(label_x, cy),
egui::Align2::LEFT_CENTER,
label,
label_font.clone(),
theme::BUTTON_PILL_LABEL,
);
x += outer_w + item_gap;
}
}
}
fn button_pill_color(btn: &str) -> egui::Color32 {
match btn {
"A" => theme::BUTTON_COLOR_A,
"B" => theme::BUTTON_COLOR_B,
"X" => theme::BUTTON_COLOR_X,
"Y" => theme::BUTTON_COLOR_Y,
"Select" | "Start" => egui::Color32::from_rgb(160, 160, 175),
_ => theme::BUTTON_PILL_BG,
}
}
#[allow(clippy::too_many_arguments)]
fn render_search_panel_egui(
ctx: &egui::Context,
query: &str,
count: usize,
kb_row: usize,
kb_col: usize,
anim: f32,
display_w: f32,
display_h: f32,
) {
let dim_alpha = (180.0 * anim) as u8;
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Background,
egui::Id::new("search_dim"),
));
painter.rect_filled(
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(display_w * 2.0, 10000.0)),
egui::CornerRadius::ZERO,
egui::Color32::from_black_alpha(dim_alpha),
);
let panel_w = 560.0_f32.min(display_w * 0.55);
let panel_x = -panel_w + panel_w * anim;
let panel_bg = egui::Color32::from_rgba_premultiplied(28, 28, 38, 140);
let accent = theme::SELECTION_COLOR;
let corner_r = egui::CornerRadius::same(12);
let panel_rect =
egui::Rect::from_min_size(egui::pos2(panel_x, 0.0), egui::vec2(panel_w, display_h));
let bg_painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Middle,
egui::Id::new("search_panel_bg"),
));
bg_painter.rect_filled(
panel_rect.expand(6.0),
corner_r,
egui::Color32::from_black_alpha(60),
);
bg_painter.rect_filled(panel_rect, corner_r, panel_bg);
egui::Area::new(egui::Id::new("search_panel_content"))
.order(egui::Order::Foreground)
.fixed_pos(egui::pos2(panel_x + 20.0, 30.0))
.constrain(false)
.show(ctx, |ui| {
ui.set_clip_rect(panel_rect);
let content_w = panel_w - 40.0;
ui.set_max_width(content_w);
ui.label(
egui::RichText::new("Search")
.color(egui::Color32::WHITE)
.size(27.0),
);
ui.add_space(12.0);
let query_display = if query.is_empty() {
"Type to search...".to_string()
} else {
format!("{query}|")
};
let query_color = if query.is_empty() {
egui::Color32::from_rgb(120, 120, 140)
} else {
egui::Color32::WHITE
};
let bar_rect = ui.available_rect_before_wrap();
let bar_rect = egui::Rect::from_min_size(bar_rect.min, egui::vec2(content_w, 44.0));
ui.painter().rect_filled(
bar_rect,
egui::CornerRadius::same(8),
egui::Color32::from_rgb(36, 36, 48),
);
ui.painter().rect_stroke(
bar_rect,
egui::CornerRadius::same(8),
egui::Stroke::new(1.5, accent),
egui::StrokeKind::Outside,
);
let text_pos = bar_rect.min + egui::vec2(14.0, 12.0);
ui.painter().text(
text_pos,
egui::Align2::LEFT_TOP,
&query_display,
egui::FontId::proportional(20.0),
query_color,
);
ui.advance_cursor_after_rect(bar_rect);
ui.add_space(6.0);
ui.label(
egui::RichText::new(format!("{count} matches"))
.color(egui::Color32::from_rgb(160, 160, 175))
.size(14.0),
);
ui.add_space(16.0);
let key_size = ((content_w - 9.0 * 6.0) / 10.0).min(44.0);
let key_spacing = 6.0;
let keyboard_w = 10.0 * key_size + 9.0 * key_spacing;
for (row_idx, row) in Self::SEARCH_KB_ROWS.iter().enumerate() {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let row_w =
row.len() as f32 * key_size + (row.len() as f32 - 1.0) * key_spacing;
let indent = (keyboard_w - row_w) / 2.0;
ui.add_space(indent);
for (col_idx, &ch) in row.iter().enumerate() {
let is_selected = row_idx == kb_row && col_idx == kb_col;
let key_rect = ui.available_rect_before_wrap();
let key_rect = egui::Rect::from_min_size(
key_rect.min,
egui::vec2(key_size, key_size),
);
let (bg, fg, stroke) = if is_selected {
(accent, egui::Color32::WHITE, egui::Stroke::new(2.0, accent))
} else {
(
egui::Color32::from_rgb(44, 44, 58),
egui::Color32::from_rgb(200, 200, 215),
egui::Stroke::new(1.0, egui::Color32::from_rgb(60, 60, 75)),
)
};
ui.painter()
.rect_filled(key_rect, egui::CornerRadius::same(6), bg);
ui.painter().rect_stroke(
key_rect,
egui::CornerRadius::same(6),
stroke,
egui::StrokeKind::Outside,
);
let label = match ch {
' ' => "SPC".to_string(),
'\u{232B}' => "DEL".to_string(),
'\u{21B5}' => "OK".to_string(),
_ => ch.to_string(),
};
let font_size = match ch {
' ' | '\u{232B}' | '\u{21B5}' => 12.0,
_ => 18.0,
};
ui.painter().text(
key_rect.center(),
egui::Align2::CENTER_CENTER,
&label,
egui::FontId::proportional(font_size),
fg,
);
ui.advance_cursor_after_rect(key_rect);
if col_idx + 1 < row.len() {
ui.add_space(key_spacing);
}
}
});
ui.add_space(key_spacing);
}
ui.add_space(12.0);
Self::render_button_legend(
ui,
&[("Tab", "Close"), ("Enter", "Select"), ("Type", "Search")],
);
});
}
fn render_genre_filter_egui(
ctx: &egui::Context,
available_genres: &[String],
active_genres: &[String],
genre_cursor: usize,
display_w: f32,
display_h: f32,
) {
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Background,
egui::Id::new("genre_dim"),
));
painter.rect_filled(
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(display_w * 2.0, 10000.0)),
egui::CornerRadius::ZERO,
egui::Color32::from_black_alpha(120),
);
let panel_w = 300.0_f32.min(display_w * 0.4);
let panel_h = (available_genres.len() as f32 * 26.0 + 60.0).min(display_h * 0.8);
let panel_x = (display_w - panel_w) / 2.0;
let panel_y = (display_h - panel_h) / 2.0;
egui::Window::new("genre_filter")
.id(egui::Id::new("genre_overlay"))
.fixed_pos(egui::pos2(panel_x, panel_y))
.fixed_size(egui::vec2(panel_w, panel_h))
.title_bar(false)
.resizable(false)
.movable(false)
.show(ctx, |ui| {
ui.label(
egui::RichText::new("Filter by Genre")
.color(theme::HEADER_TEXT)
.size(14.0),
);
ui.separator();
for (i, genre) in available_genres.iter().enumerate() {
let is_active = active_genres.contains(genre);
let marker = if is_active { "[x] " } else { "[ ] " };
let is_cursor = i == genre_cursor;
let color = if is_cursor {
theme::SELECTION_COLOR
} else if is_active {
theme::SELECTED_TEXT
} else {
theme::TEXT_COLOR
};
let label = if is_cursor {
format!("> {marker}{genre}")
} else {
format!(" {marker}{genre}")
};
ui.label(egui::RichText::new(&label).color(color).size(12.0));
}
});
}
#[allow(clippy::too_many_arguments)]
fn render_filter_panel_egui(
ctx: &egui::Context,
available_genres: &[String],
active_genres: &[String],
active_platform: Option<Platform>,
min_players_filter: Option<u32>,
cursor: usize,
column: usize,
anim: f32,
display_w: f32,
display_h: f32,
) {
let dim_alpha = (180.0 * anim) as u8;
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Background,
egui::Id::new("filter_panel_dim"),
));
painter.rect_filled(
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(display_w * 2.0, 10000.0)),
egui::CornerRadius::ZERO,
egui::Color32::from_black_alpha(dim_alpha),
);
let panel_w = 600.0_f32.min(display_w * 0.6);
let panel_x = -panel_w + panel_w * anim;
let panel_bg = egui::Color32::from_rgba_premultiplied(28, 28, 38, 140);
let section_bg = egui::Color32::from_rgba_premultiplied(36, 36, 48, 140);
let accent = theme::SELECTION_COLOR;
let corner_r = egui::CornerRadius::same(12);
let panel_rect =
egui::Rect::from_min_size(egui::pos2(panel_x, 0.0), egui::vec2(panel_w, display_h));
let panel_painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Middle,
egui::Id::new("filter_panel_bg"),
));
panel_painter.rect_filled(
panel_rect.expand(4.0),
egui::CornerRadius {
nw: 0,
ne: 16,
se: 16,
sw: 0,
},
egui::Color32::from_black_alpha(80),
);
panel_painter.rect_filled(
panel_rect,
egui::CornerRadius {
nw: 0,
ne: 12,
se: 12,
sw: 0,
},
panel_bg,
);
let content_rect = egui::Rect::from_min_size(
egui::pos2(panel_x + 24.0, 32.0),
egui::vec2(panel_w - 48.0, display_h - 64.0),
);
egui::Area::new(egui::Id::new("filter_panel_area"))
.order(egui::Order::Foreground)
.fixed_pos(content_rect.min)
.constrain(false)
.show(ctx, |ui| {
ui.set_width(content_rect.width());
ui.set_height(content_rect.height());
ui.set_clip_rect(panel_rect);
ui.add_space(8.0);
ui.label(
egui::RichText::new("Filters")
.color(theme::HEADER_TEXT)
.size(27.0)
.strong(),
);
ui.add_space(12.0);
let line_rect = ui.available_rect_before_wrap();
let line_y = line_rect.min.y;
ui.painter().line_segment(
[
egui::pos2(line_rect.min.x, line_y),
egui::pos2(line_rect.min.x + panel_w - 48.0, line_y),
],
egui::Stroke::new(1.0, accent.linear_multiply(0.5)),
);
ui.add_space(16.0);
let total_w = ui.available_width();
let spacing = ui.spacing().item_spacing.x;
let usable = total_w - spacing * 2.0;
let col0_w = usable * 0.25;
let col1_w = usable * 0.20;
let col2_w = usable * 0.55;
let top = ui.cursor().min;
let col_h = ui.available_height();
let col0_rect = egui::Rect::from_min_size(top, egui::vec2(col0_w, col_h));
let col1_rect = egui::Rect::from_min_size(
egui::pos2(top.x + col0_w + spacing, top.y),
egui::vec2(col1_w, col_h),
);
let col2_rect = egui::Rect::from_min_size(
egui::pos2(top.x + col0_w + spacing + col1_w + spacing, top.y),
egui::vec2(col2_w, col_h),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(col0_rect), |ui| {
Self::render_filter_section_header(ui, "PLATFORM", section_bg);
ui.add_space(8.0);
for (i, plat) in Self::PLATFORMS.iter().enumerate() {
let is_active = active_platform == Some(*plat);
let is_cursor = column == 0 && i == cursor;
let item_rect = ui
.horizontal(|ui| {
Self::paint_cursor_arrow(ui, is_cursor, accent);
let _ = ui
.radio(is_active, egui::RichText::new(plat.label()).size(20.0));
})
.response
.rect;
if is_cursor {
ui.painter().rect_filled(
item_rect.expand2(egui::vec2(4.0, 1.0)),
corner_r,
accent.linear_multiply(0.12),
);
}
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
ui.add_space(8.0);
Self::render_button_legend(ui, &[("A", "Select"), ("B", "Close")]);
});
});
ui.scope_builder(egui::UiBuilder::new().max_rect(col1_rect), |ui| {
Self::render_filter_section_header(ui, "PLAYERS", section_bg);
ui.add_space(8.0);
for (i, (value, label)) in Self::PLAYER_OPTIONS.iter().enumerate() {
let is_active = min_players_filter == *value;
let is_cursor = column == 1 && i == cursor;
let item_rect = ui
.horizontal(|ui| {
Self::paint_cursor_arrow(ui, is_cursor, accent);
let _ = ui.radio(is_active, egui::RichText::new(*label).size(20.0));
})
.response
.rect;
if is_cursor {
ui.painter().rect_filled(
item_rect.expand2(egui::vec2(4.0, 1.0)),
corner_r,
accent.linear_multiply(0.12),
);
}
}
});
ui.scope_builder(egui::UiBuilder::new().max_rect(col2_rect), |ui| {
Self::render_filter_section_header(ui, "GENRE", section_bg);
ui.add_space(8.0);
let col_w = ui.available_width();
let max_chars = ((col_w - 60.0) / 11.0).max(5.0) as usize;
egui::ScrollArea::vertical()
.id_salt("genre_scroll")
.max_height(ui.available_height() - 8.0)
.show(ui, |ui| {
for (i, genre) in available_genres.iter().enumerate() {
let is_active = active_genres.contains(genre);
let is_cursor = column == 2 && i == cursor;
let display_name = Self::truncate_label(genre, max_chars);
let mut checked = is_active;
let item_rect = ui
.horizontal(|ui| {
Self::paint_cursor_arrow(ui, is_cursor, accent);
ui.checkbox(
&mut checked,
egui::RichText::new(&display_name).size(20.0),
);
})
.response
.rect;
if is_cursor {
ui.painter().rect_filled(
item_rect.expand2(egui::vec2(4.0, 1.0)),
corner_r,
accent.linear_multiply(0.12),
);
ui.scroll_to_rect(item_rect, Some(egui::Align::Center));
}
}
});
});
});
}
fn render_filter_section_header(ui: &mut egui::Ui, text: &str, bg: egui::Color32) {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), 28.0), egui::Sense::hover());
ui.painter()
.rect_filled(rect, egui::CornerRadius::same(6), bg);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(16.0),
egui::Color32::from_rgb(200, 200, 215),
);
}
fn paint_cursor_arrow(ui: &mut egui::Ui, is_cursor: bool, color: egui::Color32) {
let size = 14.0;
let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size + 4.0), egui::Sense::hover());
if is_cursor {
let center = rect.center();
let half = size * 0.35;
let points = vec![
egui::pos2(center.x - half * 0.5, center.y - half),
egui::pos2(center.x + half, center.y),
egui::pos2(center.x - half * 0.5, center.y + half),
];
ui.painter().add(egui::Shape::convex_polygon(
points,
color,
egui::Stroke::NONE,
));
}
}
fn truncate_label(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
text.to_string()
} else {
let truncated: String = text.chars().take(max_chars.saturating_sub(1)).collect();
format!("{truncated}…")
}
}
fn render_detail_view_egui(
ctx: &egui::Context,
entry: &RomEntry,
tex_map: &HashMap<i64, (egui::TextureId, u32, u32)>,
screenshot_textures: &[(egui::TextureId, u32, u32)],
screenshot_index: usize,
display_w: f32,
display_h: f32,
) {
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Background,
egui::Id::new("detail_dim"),
));
painter.rect_filled(
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(display_w * 2.0, 10000.0)),
egui::CornerRadius::ZERO,
egui::Color32::from_black_alpha(210),
);
let margin = 40.0;
egui::Window::new("detail_view")
.id(egui::Id::new("detail_overlay"))
.fixed_pos(egui::pos2(margin, margin))
.fixed_size(egui::vec2(
display_w - margin * 2.0,
display_h - margin * 2.0,
))
.title_bar(false)
.resizable(false)
.movable(false)
.show(ctx, |ui| {
let frame = egui::Frame::new()
.fill(theme::SIDEBAR_BG)
.inner_margin(egui::Margin::same(24))
.corner_radius(egui::CornerRadius::same(theme::CORNER_RADIUS as u8));
frame.show(ui, |ui| {
ui.label(
egui::RichText::new(&entry.display_name)
.color(theme::HEADER_TEXT)
.size(30.0)
.family(egui::FontFamily::Monospace),
);
ui.add_space(4.0);
ui.separator();
ui.add_space(8.0);
let avail = ui.available_size();
let col_gap = 16.0;
let left_frac = 0.30;
let mid_frac = 0.35;
let left_w = avail.x * left_frac;
let mid_w = avail.x * mid_frac;
let right_w = avail.x - left_w - mid_w - col_gap * 2.0;
let panel_h = avail.y;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(left_w);
ui.set_max_height(panel_h);
let art_max_h = panel_h * 0.65;
if let Some(game_id) = entry.metadata_game_id
&& let Some(&(tex_id, tex_w, tex_h)) = tex_map.get(&game_id)
{
let img_aspect = tex_w as f32 / tex_h.max(1) as f32;
let art_h = (left_w / img_aspect).min(art_max_h);
let actual_w = art_h * img_aspect;
ui.add(
egui::Image::from_texture(egui::load::SizedTexture::new(
tex_id,
egui::vec2(actual_w, art_h),
))
.corner_radius(theme::CORNER_RADIUS),
);
} else {
let (rect, _) = ui.allocate_exact_size(
egui::vec2(left_w, art_max_h * 0.7),
egui::Sense::hover(),
);
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(theme::CORNER_RADIUS as u8),
theme::PLACEHOLDER_BG,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
&entry.display_name,
egui::FontId::proportional(17.0),
theme::DIM_TEXT,
);
}
ui.add_space(12.0);
let meta_font = egui::FontId::proportional(17.0);
if !entry.genres.is_empty() {
ui.label(
egui::RichText::new(format!(
"Genre: {}",
entry.genres.join(", ")
))
.color(theme::DIM_TEXT)
.font(meta_font.clone()),
);
}
if let Some(ref date) = entry.release_date {
ui.label(
egui::RichText::new(format!("Released: {date}"))
.color(theme::DIM_TEXT)
.font(meta_font.clone()),
);
}
if let Some(players) = entry.players {
ui.label(
egui::RichText::new(format!("Players: {players}"))
.color(theme::DIM_TEXT)
.font(meta_font.clone()),
);
}
if let Some(ref rating) = entry.rating {
ui.label(
egui::RichText::new(format!("Rating: {rating}"))
.color(theme::DIM_TEXT)
.font(meta_font.clone()),
);
}
ui.label(
egui::RichText::new(format!("Mapper: {}", entry.mapper_label))
.color(theme::DIM_TEXT)
.font(meta_font.clone()),
);
if let Some(ref crc) = entry.crc {
ui.label(
egui::RichText::new(format!("CRC: {crc}"))
.color(theme::DIM_TEXT)
.font(meta_font.clone()),
);
}
if let Some(ref hw) = entry.hardware {
ui.label(
egui::RichText::new(format!("Hardware: {hw}"))
.color(theme::DIM_TEXT)
.font(meta_font.clone()),
);
}
if let Some(file_name) = entry.path.file_name() {
ui.label(
egui::RichText::new(format!(
"File: {}",
file_name.to_string_lossy()
))
.color(theme::DIM_TEXT)
.font(meta_font),
);
}
if entry.is_favorite {
ui.label(
egui::RichText::new("\u{2665} Favourite")
.color(theme::FAVORITE_COLOR)
.size(18.0),
);
}
});
ui.add_space(col_gap);
ui.vertical(|ui| {
ui.set_width(mid_w);
ui.set_max_height(panel_h);
if screenshot_textures.is_empty() {
let (rect, _) = ui.allocate_exact_size(
egui::vec2(mid_w, panel_h * 0.6),
egui::Sense::hover(),
);
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(theme::CORNER_RADIUS as u8),
theme::PLACEHOLDER_BG,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
"No screenshots",
egui::FontId::proportional(18.0),
theme::DIM_TEXT,
);
} else {
egui::ScrollArea::vertical()
.id_salt("detail_screenshots")
.max_height(panel_h)
.show(ui, |ui| {
for (i, &(tex_id, tex_w, tex_h)) in
screenshot_textures.iter().enumerate()
{
let ss_aspect = tex_w as f32 / tex_h.max(1) as f32;
let img_w = mid_w;
let img_h = img_w / ss_aspect;
let (rect, _) = ui.allocate_exact_size(
egui::vec2(img_w, img_h),
egui::Sense::hover(),
);
ui.painter().image(
tex_id,
rect,
egui::Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(1.0, 1.0),
),
egui::Color32::WHITE,
);
let idx =
screenshot_index.min(screenshot_textures.len() - 1);
if i == idx {
ui.painter().rect_stroke(
rect.expand(2.0),
egui::CornerRadius::same(
theme::CORNER_RADIUS as u8,
),
egui::Stroke::new(2.5, theme::SELECTION_COLOR),
egui::StrokeKind::Outside,
);
ui.scroll_to_rect(rect, Some(egui::Align::Center));
}
ui.add_space(8.0);
}
});
}
});
ui.add_space(col_gap);
ui.vertical(|ui| {
ui.set_width(right_w);
ui.set_max_height(panel_h);
if let Some(ref overview) = entry.overview {
egui::ScrollArea::vertical()
.id_salt("detail_description")
.max_height(panel_h - 60.0)
.show(ui, |ui| {
ui.label(
egui::RichText::new(overview)
.color(theme::TEXT_COLOR)
.size(18.0),
);
});
} else {
ui.label(
egui::RichText::new("No description available.")
.color(theme::DIM_TEXT)
.size(18.0),
);
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
Self::render_button_legend(
ui,
&[("A", "Launch"), ("Y", "Fav"), ("B", "Back")],
);
});
});
});
});
});
}
const MAX_REQUESTS_PER_FRAME: usize = 8;
const MAX_UPLOADS_PER_FRAME: usize = 4;
const PRELOAD_ROW_BUFFER: usize = 2;
const MAX_CACHED_TEXTURES: usize = 200;
fn lazy_load_visible_textures(&mut self) {
let mut decoded: Vec<(i64, u32, u32, Vec<u8>)> =
Vec::with_capacity(Self::MAX_UPLOADS_PER_FRAME);
while decoded.len() < Self::MAX_UPLOADS_PER_FRAME {
match self.texture_result_rx.try_recv() {
Ok(result) => decoded.push(result),
Err(_) => break,
}
}
if !decoded.is_empty() {
if let Some(ref mut gl) = self.gl {
for (game_id, w, h, pixels) in &decoded {
if *w > 0 && *h > 0 {
let key = TextureKey::CoverArt(*game_id);
gl.load_texture_from_rgba(key, *w, *h, pixels);
}
}
}
for (game_id, _, _, _) in &decoded {
self.texture_pending.retain(|&id| id != *game_id);
}
}
let Some(ref gl) = self.gl else { return };
let (display_w, display_h) = gl.logical_size();
let sidebar_w = theme::sidebar_width(display_w);
let grid_area_w = display_w - sidebar_w;
let (cols, cover_w) = theme::grid_layout(grid_area_w);
let cell_h = theme::cell_height(cover_w);
let grid_height = display_h - theme::HEADER_HEIGHT;
let first_visible_row =
(self.scroll_offset / (cell_h + theme::GRID_SPACING)).floor() as usize;
let rows_on_screen = (grid_height / (cell_h + theme::GRID_SPACING)).ceil() as usize + 1;
let first_row = first_visible_row.saturating_sub(Self::PRELOAD_ROW_BUFFER);
let last_row = first_visible_row + rows_on_screen + Self::PRELOAD_ROW_BUFFER;
let first_idx = first_row * cols;
let last_idx = ((last_row + 1) * cols).min(self.filtered_indices.len());
let range_start = first_idx.min(self.filtered_indices.len());
let range_end = last_idx.min(self.filtered_indices.len());
let mut visible_game_ids: Vec<i64> = Vec::with_capacity(range_end - range_start);
for &fi in &self.filtered_indices[range_start..range_end] {
if let Some(gid) = self.catalog[fi].metadata_game_id {
visible_game_ids.push(gid);
}
}
if let Some(&catalog_idx) = self.filtered_indices.get(self.selected_index)
&& let Some(gid) = self.catalog[catalog_idx].metadata_game_id
&& !visible_game_ids.contains(&gid)
{
visible_game_ids.push(gid);
}
let mut requests_sent = 0;
for &game_id in &visible_game_ids {
if requests_sent >= Self::MAX_REQUESTS_PER_FRAME {
break;
}
let key = TextureKey::CoverArt(game_id);
if gl.get_texture(&key).is_some() || self.texture_pending.contains(&game_id) {
continue;
}
if let Some(path) = self.boxart_by_game_id.get(&game_id)
&& path.exists()
&& self
.texture_request_tx
.send((game_id, path.clone()))
.is_ok()
{
self.texture_pending.push(game_id);
requests_sent += 1;
}
}
if gl.texture_count() > Self::MAX_CACHED_TEXTURES {
let loaded_keys = gl.texture_keys();
let mut to_evict: Vec<TextureKey> = Vec::new();
for key in loaded_keys {
if gl.texture_count().saturating_sub(to_evict.len()) <= Self::MAX_CACHED_TEXTURES {
break;
}
if let TextureKey::CoverArt(gid) = key
&& !visible_game_ids.contains(&gid)
{
to_evict.push(TextureKey::CoverArt(gid));
}
}
let gl = self.gl.as_mut().unwrap();
for key in to_evict {
gl.remove_texture(&key);
}
}
}
fn detail_screenshot_count(&self) -> usize {
self.selected_entry()
.map(|e| e.screenshot_paths.len())
.unwrap_or(0)
}
fn advance_screenshot_auto_scroll(&mut self) {
let count = self.detail_screenshot_count();
if count <= 1 {
return;
}
let elapsed = self.detail_scroll_last_advance.elapsed().as_secs_f64();
let pause =
if self.detail_screenshot_index == 0 || self.detail_screenshot_index == count - 1 {
2.0
} else {
1.5
};
if elapsed >= pause {
self.detail_scroll_last_advance = Instant::now();
if self.detail_scroll_forward {
if self.detail_screenshot_index + 1 < count {
self.detail_screenshot_index += 1;
} else {
self.detail_scroll_forward = false;
self.detail_screenshot_index -= 1;
}
} else if self.detail_screenshot_index > 0 {
self.detail_screenshot_index -= 1;
} else {
self.detail_scroll_forward = true;
self.detail_screenshot_index += 1;
}
}
}
fn open_detail_view(&mut self) {
if self.selected_entry().is_some() {
self.detail_view_active = true;
self.detail_screenshot_index = 0;
self.detail_scroll_last_advance = Instant::now();
self.detail_scroll_forward = true;
}
}
fn open_search_panel(&mut self) {
self.filter_panel_active = false;
self.genre_filter_active = false;
self.detail_view_active = false;
self.search_active = true;
self.search_kb_row = 1;
self.search_kb_col = 0;
}
fn close_search_panel(&mut self) {
self.search_active = false;
}
fn search_kb_confirm(&mut self) {
let row = &Self::SEARCH_KB_ROWS[self.search_kb_row];
if self.search_kb_col < row.len() {
let ch = row[self.search_kb_col];
match ch {
'\u{232B}' => {
self.search_query.pop();
}
'\u{21B5}' => {
self.close_search_panel();
return;
}
_ => {
self.search_query.push(ch.to_ascii_lowercase());
}
}
self.rebuild_filtered();
}
}
fn search_kb_move_up(&mut self) {
if self.search_kb_row > 0 {
self.search_kb_row -= 1;
let row_len = Self::SEARCH_KB_ROWS[self.search_kb_row].len();
if self.search_kb_col >= row_len {
self.search_kb_col = row_len - 1;
}
}
}
fn search_kb_move_down(&mut self) {
if self.search_kb_row + 1 < Self::SEARCH_KB_ROWS.len() {
self.search_kb_row += 1;
let row_len = Self::SEARCH_KB_ROWS[self.search_kb_row].len();
if self.search_kb_col >= row_len {
self.search_kb_col = row_len - 1;
}
}
}
fn search_kb_move_left(&mut self) {
if self.search_kb_col > 0 {
self.search_kb_col -= 1;
}
}
fn search_kb_move_right(&mut self) {
let row_len = Self::SEARCH_KB_ROWS[self.search_kb_row].len();
if self.search_kb_col + 1 < row_len {
self.search_kb_col += 1;
}
}
fn open_filter_panel(&mut self) {
self.search_active = false;
self.genre_filter_active = false;
self.detail_view_active = false;
self.filter_panel_active = true;
self.filter_panel_cursor = 0;
self.filter_panel_column = 0;
}
fn close_filter_panel(&mut self) {
self.filter_panel_active = false;
}
const PLATFORMS: [Platform; 3] = [Platform::Nes, Platform::Gb, Platform::Gbc];
const PLAYER_OPTIONS: [(Option<u32>, &'static str); 3] =
[(None, "Any"), (Some(2), "2+"), (Some(4), "4+")];
const SEARCH_KB_ROWS: [&'static [char]; 4] = [
&['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
&['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'],
&['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', '-'],
&[
'Z', 'X', 'C', 'V', 'B', 'N', 'M', ' ', '\u{232B}', '\u{21B5}',
],
];
fn filter_panel_confirm(&mut self) {
let cursor = self.filter_panel_cursor;
match self.filter_panel_column {
0 => {
if cursor < Self::PLATFORMS.len() {
let selected = Self::PLATFORMS[cursor];
if self.active_platform == Some(selected) {
self.active_platform = None;
} else {
self.active_platform = Some(selected);
}
}
}
1 => {
if cursor < Self::PLAYER_OPTIONS.len() {
let (value, _) = Self::PLAYER_OPTIONS[cursor];
if self.min_players_filter == value && value.is_some() {
self.min_players_filter = None;
} else {
self.min_players_filter = value;
}
}
}
2 => {
if let Some(genre) = self.available_genres.get(cursor).cloned() {
if let Some(pos) = self.active_genres.iter().position(|g| *g == genre) {
self.active_genres.remove(pos);
} else {
self.active_genres.push(genre);
}
}
}
_ => {}
}
self.rebuild_filtered();
}
fn filter_panel_move_cursor_up(&mut self) {
if self.filter_panel_cursor > 0 {
self.filter_panel_cursor -= 1;
}
}
fn filter_panel_move_cursor_down(&mut self) {
let max = self
.filter_panel_column_len(self.filter_panel_column)
.saturating_sub(1);
if self.filter_panel_cursor < max {
self.filter_panel_cursor += 1;
}
}
fn filter_panel_column_len(&self, col: usize) -> usize {
match col {
0 => Self::PLATFORMS.len(),
1 => Self::PLAYER_OPTIONS.len(),
2 => self.available_genres.len(),
_ => 0,
}
}
fn filter_panel_move_left(&mut self) {
if self.filter_panel_column > 0 {
self.filter_panel_column -= 1;
let max = self
.filter_panel_column_len(self.filter_panel_column)
.saturating_sub(1);
self.filter_panel_cursor = self.filter_panel_cursor.min(max);
}
}
fn filter_panel_move_right(&mut self) {
let next = self.filter_panel_column + 1;
let next_len = self.filter_panel_column_len(next);
if next_len > 0 {
self.filter_panel_column = next;
self.filter_panel_cursor = self.filter_panel_cursor.min(next_len.saturating_sub(1));
}
}
fn ensure_selected_visible(&mut self) {
let Some(ref gl) = self.gl else { return };
let (display_w, display_h) = gl.logical_size();
let sidebar_w = theme::sidebar_width(display_w);
let grid_area_w = display_w - sidebar_w;
let (cols, cover_w) = theme::grid_layout(grid_area_w);
let cell_h = theme::cell_height(cover_w);
let grid_height = display_h - theme::HEADER_HEIGHT;
let row = self.selected_index / cols;
let cell_top = theme::GRID_PADDING + row as f32 * (cell_h + theme::GRID_SPACING);
let cell_bottom = cell_top + cell_h + theme::GRID_SPACING;
if cell_top < self.scroll_target {
self.scroll_target = cell_top - theme::GRID_PADDING;
}
if cell_bottom > self.scroll_target + grid_height {
self.scroll_target = cell_bottom - grid_height + theme::GRID_PADDING;
}
self.scroll_target = self.scroll_target.max(0.0);
}
fn current_cols(&self) -> usize {
let Some(ref gl) = self.gl else { return 1 };
let (display_w, _) = gl.logical_size();
let sidebar_w = theme::sidebar_width(display_w);
let grid_area_w = display_w - sidebar_w;
let (cols, _) = theme::grid_layout(grid_area_w);
cols
}
fn navigate_up(&mut self) {
let cols = self.current_cols();
if self.selected_index >= cols {
self.selected_index -= cols;
} else {
self.selected_index = 0;
}
self.ensure_selected_visible();
}
fn navigate_down(&mut self) {
let cols = self.current_cols();
let count = self.filtered_indices.len();
let new_idx = self.selected_index + cols;
if new_idx < count {
self.selected_index = new_idx;
} else if self.selected_index < count.saturating_sub(1) {
self.selected_index = count - 1;
}
self.ensure_selected_visible();
}
fn poll_gamepad(&mut self) -> Vec<BrowserAction> {
let gilrs = match self.gilrs.as_mut() {
Some(g) => g,
None => return Vec::new(),
};
let mut actions = Vec::new();
let now = Instant::now();
while let Some(event) = gilrs.next_event() {
match event.event {
EventType::ButtonPressed(button, _) => {
if let Some(dir) = Self::button_to_direction(button) {
self.gamepad_repeat.held.insert(dir, now);
self.gamepad_repeat.last_repeat.remove(&dir);
}
if let Some(action) = Self::map_button(button) {
actions.push(action);
}
}
EventType::ButtonReleased(button, _) => {
if let Some(dir) = Self::button_to_direction(button) {
self.gamepad_repeat.held.remove(&dir);
self.gamepad_repeat.last_repeat.remove(&dir);
}
}
EventType::AxisChanged(axis, value, _) => {
let axis_actions = Self::update_axis(
&mut self.gamepad_axis,
axis,
value,
&mut self.gamepad_repeat,
now,
);
actions.extend(axis_actions);
}
_ => {}
}
}
let held_snapshot: Vec<(RepeatDirection, Instant)> = self
.gamepad_repeat
.held
.iter()
.map(|(&d, &t)| (d, t))
.collect();
for (dir, press_time) in held_snapshot {
let elapsed = now.duration_since(press_time).as_millis();
if elapsed < REPEAT_DELAY_MS {
continue;
}
let should_fire = match self.gamepad_repeat.last_repeat.get(&dir) {
Some(&last) => now.duration_since(last).as_millis() >= REPEAT_INTERVAL_MS,
None => true,
};
if should_fire {
actions.push(Self::direction_to_action(dir));
self.gamepad_repeat.last_repeat.insert(dir, now);
}
}
actions
}
fn button_to_direction(button: gilrs::Button) -> Option<RepeatDirection> {
match button {
gilrs::Button::DPadUp => Some(RepeatDirection::Up),
gilrs::Button::DPadDown => Some(RepeatDirection::Down),
gilrs::Button::DPadLeft => Some(RepeatDirection::Left),
gilrs::Button::DPadRight => Some(RepeatDirection::Right),
_ => None,
}
}
fn direction_to_action(dir: RepeatDirection) -> BrowserAction {
match dir {
RepeatDirection::Up => BrowserAction::Up,
RepeatDirection::Down => BrowserAction::Down,
RepeatDirection::Left => BrowserAction::Left,
RepeatDirection::Right => BrowserAction::Right,
}
}
fn map_button(button: gilrs::Button) -> Option<BrowserAction> {
match button {
gilrs::Button::DPadUp => Some(BrowserAction::Up),
gilrs::Button::DPadDown => Some(BrowserAction::Down),
gilrs::Button::DPadLeft => Some(BrowserAction::Left),
gilrs::Button::DPadRight => Some(BrowserAction::Right),
gilrs::Button::East => Some(BrowserAction::Confirm), gilrs::Button::South => Some(BrowserAction::Back), gilrs::Button::Start | gilrs::Button::RightTrigger2 => Some(BrowserAction::Search),
gilrs::Button::North => Some(BrowserAction::Detail), gilrs::Button::West => Some(BrowserAction::GenreFilter), gilrs::Button::Select | gilrs::Button::LeftTrigger2 => Some(BrowserAction::Favorite),
_ => None,
}
}
fn update_axis(
state: &mut GamepadAxisState,
axis: Axis,
value: f32,
repeat: &mut GamepadRepeatState,
now: Instant,
) -> Vec<BrowserAction> {
let mut actions = Vec::new();
match axis {
Axis::LeftStickX | Axis::RightStickX => {
let new_left = value < -AXIS_DEAD_ZONE;
let new_right = value > AXIS_DEAD_ZONE;
if new_left && !state.left {
actions.push(BrowserAction::Left);
repeat.held.insert(RepeatDirection::Left, now);
repeat.last_repeat.remove(&RepeatDirection::Left);
}
if !new_left && state.left {
repeat.held.remove(&RepeatDirection::Left);
repeat.last_repeat.remove(&RepeatDirection::Left);
}
if new_right && !state.right {
actions.push(BrowserAction::Right);
repeat.held.insert(RepeatDirection::Right, now);
repeat.last_repeat.remove(&RepeatDirection::Right);
}
if !new_right && state.right {
repeat.held.remove(&RepeatDirection::Right);
repeat.last_repeat.remove(&RepeatDirection::Right);
}
state.left = new_left;
state.right = new_right;
}
Axis::LeftStickY | Axis::RightStickY => {
let new_up = value > AXIS_DEAD_ZONE;
let new_down = value < -AXIS_DEAD_ZONE;
if new_up && !state.up {
actions.push(BrowserAction::Up);
repeat.held.insert(RepeatDirection::Up, now);
repeat.last_repeat.remove(&RepeatDirection::Up);
}
if !new_up && state.up {
repeat.held.remove(&RepeatDirection::Up);
repeat.last_repeat.remove(&RepeatDirection::Up);
}
if new_down && !state.down {
actions.push(BrowserAction::Down);
repeat.held.insert(RepeatDirection::Down, now);
repeat.last_repeat.remove(&RepeatDirection::Down);
}
if !new_down && state.down {
repeat.held.remove(&RepeatDirection::Down);
repeat.last_repeat.remove(&RepeatDirection::Down);
}
state.up = new_up;
state.down = new_down;
}
_ => {}
}
actions
}
fn apply_action(&mut self, action: BrowserAction, event_loop: &ActiveEventLoop) {
if self.search_active {
match action {
BrowserAction::Back => self.close_search_panel(),
BrowserAction::Up => self.search_kb_move_up(),
BrowserAction::Down => self.search_kb_move_down(),
BrowserAction::Left => self.search_kb_move_left(),
BrowserAction::Right => self.search_kb_move_right(),
BrowserAction::Confirm => self.search_kb_confirm(),
_ => {}
}
} else if self.genre_filter_active {
match action {
BrowserAction::Back => self.genre_filter_active = false,
BrowserAction::Up => {
if self.genre_cursor > 0 {
self.genre_cursor -= 1;
}
}
BrowserAction::Down => {
if self.genre_cursor + 1 < self.available_genres.len() {
self.genre_cursor += 1;
}
}
BrowserAction::Confirm => {
if let Some(genre) = self.available_genres.get(self.genre_cursor).cloned() {
if let Some(pos) = self.active_genres.iter().position(|g| *g == genre) {
self.active_genres.remove(pos);
} else {
self.active_genres.push(genre);
}
self.rebuild_filtered();
}
}
_ => {}
}
} else if self.filter_panel_active {
match action {
BrowserAction::Back => self.close_filter_panel(),
BrowserAction::Up => self.filter_panel_move_cursor_up(),
BrowserAction::Down => self.filter_panel_move_cursor_down(),
BrowserAction::Left => self.filter_panel_move_left(),
BrowserAction::Right => self.filter_panel_move_right(),
BrowserAction::Confirm => self.filter_panel_confirm(),
_ => {}
}
} else if self.detail_view_active {
match action {
BrowserAction::Back => self.detail_view_active = false,
BrowserAction::Confirm => {
if let Some(entry) = self.selected_entry() {
self.result = BrowserResult::RomSelected(entry.path.clone());
event_loop.exit();
}
}
BrowserAction::Favorite => self.toggle_favorite(),
_ => {}
}
} else {
match action {
BrowserAction::Up => self.navigate_up(),
BrowserAction::Down => self.navigate_down(),
BrowserAction::Left => {
if self.selected_index > 0 {
self.selected_index -= 1;
self.ensure_selected_visible();
}
}
BrowserAction::Right => {
if self.selected_index + 1 < self.filtered_indices.len() {
self.selected_index += 1;
self.ensure_selected_visible();
}
}
BrowserAction::Confirm => {
self.open_detail_view();
}
BrowserAction::Back => {
self.open_filter_panel();
}
BrowserAction::Search => {
self.open_search_panel();
}
BrowserAction::Favorite => self.toggle_favorite(),
BrowserAction::Detail => {
self.open_detail_view();
}
BrowserAction::GenreFilter => {
self.genre_filter_active = true;
self.genre_cursor = 0;
}
}
}
}
}
impl ApplicationHandler for RomBrowserApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.gl.is_some() {
return;
}
match BrowserGl::new(
event_loop,
self.default_width,
self.default_height,
self.fullscreen,
) {
Ok(gl) => {
self.gl = Some(gl);
if matches!(self.catalog_state, CatalogState::Idle) {
let (
search_paths,
rebuild,
metadata_db_path,
image_cache_path,
include_unofficial,
) = {
let ctx = self.app_context.borrow();
let config = ctx.config();
(
config.frontend.cartridge_search_paths.clone(),
config.frontend.rebuild_cartridge_catalog,
config.frontend.resolved_metadata_db_path(),
config.frontend.resolved_image_cache_path(),
config.frontend.include_unofficial_roms,
)
};
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
match crate::platform::catalog::load_catalog(
&search_paths,
rebuild,
include_unofficial,
) {
Ok(mut catalog) => {
let tx2 = tx.clone();
crate::platform::catalog::enrich_catalog(
&mut catalog,
&metadata_db_path,
&image_cache_path,
rebuild,
move |progress| {
let _ = tx2.send(CatalogMessage::Progress(progress));
},
);
catalog.sort_by(|a, b| {
a.display_name
.to_lowercase()
.cmp(&b.display_name.to_lowercase())
});
let _ = tx.send(CatalogMessage::Done(catalog));
}
Err(e) => {
eprintln!("Failed to load ROM catalog: {e}");
let _ = tx.send(CatalogMessage::Done(Vec::new()));
}
}
});
self.catalog_state = CatalogState::Loading {
receiver: rx,
progress: None,
};
}
event_loop.set_control_flow(ControlFlow::Poll);
}
Err(e) => {
eprintln!("Failed to create browser GL context: {e}");
event_loop.exit();
}
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
match event {
WindowEvent::CloseRequested => {
self.result = BrowserResult::Closed;
event_loop.exit();
}
WindowEvent::Resized(physical_size) => {
if let Some(ref mut gl) = self.gl {
gl.notify_resize(physical_size.width, physical_size.height);
}
}
WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
use winit::keyboard::{Key, NamedKey};
let ctrl = self
.modifiers
.contains(winit::keyboard::ModifiersState::CONTROL)
|| self
.modifiers
.contains(winit::keyboard::ModifiersState::SUPER);
if let Key::Character(ref ch) = event.logical_key
&& (ch.as_str() == "q" || ch.as_str() == "Q")
&& ctrl
{
self.result = BrowserResult::Closed;
event_loop.exit();
return;
}
if let Key::Character(ref ch) = event.logical_key
&& (ch.as_str() == "f" || ch.as_str() == "F")
&& ctrl
{
if let Some(ref gl) = self.gl {
let window = gl.window();
if window.fullscreen().is_some() {
window.set_fullscreen(None);
} else {
window
.set_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
}
}
return;
}
if matches!(event.logical_key, Key::Named(NamedKey::Tab)) {
if self.search_active {
self.close_search_panel();
} else {
self.filter_panel_active = false;
self.genre_filter_active = false;
self.detail_view_active = false;
self.open_search_panel();
}
return;
}
if self.search_active {
match event.logical_key {
Key::Named(NamedKey::Escape) => {
self.close_search_panel();
}
Key::Named(NamedKey::Backspace) => {
self.search_query.pop();
self.rebuild_filtered();
}
Key::Named(NamedKey::Enter) => {
self.search_kb_confirm();
}
Key::Named(NamedKey::ArrowUp) => {
self.search_kb_move_up();
}
Key::Named(NamedKey::ArrowDown) => {
self.search_kb_move_down();
}
Key::Named(NamedKey::ArrowLeft) => {
self.search_kb_move_left();
}
Key::Named(NamedKey::ArrowRight) => {
self.search_kb_move_right();
}
Key::Character(ref ch) => {
self.search_query.push_str(ch.as_str());
self.rebuild_filtered();
}
_ => {}
}
} else if self.genre_filter_active {
match event.logical_key {
Key::Named(NamedKey::Escape) => {
self.genre_filter_active = false;
}
Key::Named(NamedKey::ArrowUp) => {
if self.genre_cursor > 0 {
self.genre_cursor -= 1;
}
}
Key::Named(NamedKey::ArrowDown) => {
if self.genre_cursor + 1 < self.available_genres.len() {
self.genre_cursor += 1;
}
}
Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
if let Some(genre) =
self.available_genres.get(self.genre_cursor).cloned()
{
if let Some(pos) =
self.active_genres.iter().position(|g| *g == genre)
{
self.active_genres.remove(pos);
} else {
self.active_genres.push(genre);
}
self.rebuild_filtered();
}
}
_ => {}
}
} else if self.filter_panel_active {
match event.logical_key {
Key::Named(NamedKey::Escape) => {
self.close_filter_panel();
}
Key::Named(NamedKey::ArrowUp) => {
self.filter_panel_move_cursor_up();
}
Key::Named(NamedKey::ArrowDown) => {
self.filter_panel_move_cursor_down();
}
Key::Named(NamedKey::ArrowLeft) => {
self.filter_panel_move_left();
}
Key::Named(NamedKey::ArrowRight) => {
self.filter_panel_move_right();
}
Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
self.filter_panel_confirm();
}
_ => {}
}
} else if self.detail_view_active {
match event.logical_key {
Key::Named(NamedKey::Escape) => {
self.detail_view_active = false;
}
Key::Named(NamedKey::Enter) => {
if let Some(entry) = self.selected_entry() {
self.result = BrowserResult::RomSelected(entry.path.clone());
event_loop.exit();
}
}
Key::Named(NamedKey::ArrowUp)
| Key::Named(NamedKey::ArrowLeft)
| Key::Named(NamedKey::ArrowDown)
| Key::Named(NamedKey::ArrowRight) => {
}
_ => {}
}
} else {
match event.logical_key {
Key::Named(NamedKey::Escape) => {
self.open_filter_panel();
}
Key::Named(NamedKey::ArrowUp) => {
self.navigate_up();
}
Key::Named(NamedKey::ArrowDown) => {
self.navigate_down();
}
Key::Named(NamedKey::ArrowLeft) => {
if self.selected_index > 0 {
self.selected_index -= 1;
self.ensure_selected_visible();
}
}
Key::Named(NamedKey::ArrowRight) => {
if self.selected_index + 1 < self.filtered_indices.len() {
self.selected_index += 1;
self.ensure_selected_visible();
}
}
Key::Named(NamedKey::Enter) => {
self.open_detail_view();
}
Key::Character(ref ch) if ch.as_str() == "g" && !ctrl => {
self.genre_filter_active = true;
self.genre_cursor = 0;
}
Key::Character(ref ch) if ch.as_str() == "d" && !ctrl => {
self.open_detail_view();
}
Key::Character(ref ch) if ch.as_str() == "f" && !ctrl => {
self.toggle_favorite();
}
Key::Character(ref ch) if ch.as_str() == "F" && !ctrl => {
self.show_favorites_only = !self.show_favorites_only;
self.rebuild_filtered();
}
_ => {}
}
}
}
WindowEvent::ModifiersChanged(mods) => {
self.modifiers = mods.state();
if let Some(ref mut gl) = self.gl {
let _ = gl.on_window_event(&event);
}
}
WindowEvent::CursorMoved { .. }
| WindowEvent::MouseInput { .. }
| WindowEvent::MouseWheel { .. }
| WindowEvent::Touch { .. }
| WindowEvent::ScaleFactorChanged { .. }
| WindowEvent::Focused(_)
| WindowEvent::Ime(_) => {
if let Some(ref mut gl) = self.gl {
let _ = gl.on_window_event(&event);
}
}
WindowEvent::RedrawRequested => {
let actions = self.poll_gamepad();
for action in actions {
self.apply_action(action, event_loop);
}
self.render_frame();
if let Some(ref gl) = self.gl {
gl.window().request_redraw();
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::app_context::IntoSharedAppContext;
use crate::platform::catalog::Platform;
#[test]
fn browser_result_default_is_closed() {
let result = BrowserResult::Closed;
assert!(matches!(result, BrowserResult::Closed));
}
#[test]
fn browser_result_rom_selected_holds_path() {
let path = PathBuf::from("/roms/game.nes");
let result = BrowserResult::RomSelected(path.clone());
match result {
BrowserResult::RomSelected(p) => assert_eq!(p, path),
BrowserResult::Closed => panic!("expected RomSelected"),
}
}
fn make_entry(name: &str) -> RomEntry {
RomEntry {
path: PathBuf::from(format!("{name}.nes")),
display_name: name.to_string(),
search_key: name.to_lowercase(),
mapper_label: "-".to_string(),
mapper: None,
hardware: None,
crc: None,
recording_duration: None,
metadata_game_id: None,
genres: Vec::new(),
overview: None,
release_date: None,
players: None,
rating: None,
boxart_path: None,
screenshot_paths: Vec::new(),
is_favorite: false,
platform: Platform::Nes,
}
}
fn test_browser(entries: Vec<RomEntry>) -> RomBrowserApp {
let dir = tempfile::TempDir::new().unwrap();
let fav_path = dir.path().join("favorites.json");
let mut app = RomBrowserApp {
app_context: crate::platform::app_context::AppContext::new().into_shared(),
gl: None,
result: BrowserResult::Closed,
default_width: 1280,
default_height: 720,
fullscreen: false,
last_render_instant: Instant::now(),
catalog: Vec::new(),
filtered_indices: Vec::new(),
selected_index: 0,
scroll_offset: 0.0,
scroll_target: 0.0,
search_active: false,
search_query: String::new(),
search_anim: 0.0,
search_kb_row: 1,
search_kb_col: 0,
genre_filter_active: false,
available_genres: Vec::new(),
active_genres: Vec::new(),
genre_cursor: 0,
detail_view_active: false,
detail_screenshot_index: 0,
detail_scroll_last_advance: Instant::now(),
detail_scroll_forward: true,
filter_panel_active: false,
filter_panel_anim: 0.0,
filter_panel_cursor: 0,
filter_panel_column: 0,
active_platform: None,
min_players_filter: None,
favorites: Favorites::load(&fav_path),
show_favorites_only: false,
catalog_state: CatalogState::Ready,
modifiers: winit::keyboard::ModifiersState::empty(),
gilrs: None, gamepad_axis: GamepadAxisState::default(),
gamepad_repeat: GamepadRepeatState::default(),
texture_request_tx: {
let (tx, _rx) = mpsc::channel();
tx
},
texture_result_rx: {
let (_tx, rx) = mpsc::channel();
rx
},
texture_pending: Vec::new(),
boxart_by_game_id: std::collections::HashMap::new(),
};
app.set_catalog(entries);
app
}
#[test]
fn rebuild_filtered_shows_all_when_no_query() {
let app = test_browser(vec![
make_entry("Super Mario Bros"),
make_entry("Zelda"),
make_entry("Metroid"),
]);
assert_eq!(app.filtered_indices.len(), 3);
assert_eq!(app.filtered_indices, vec![0, 1, 2]);
}
#[test]
fn rebuild_filtered_narrows_by_search() {
let mut app = test_browser(vec![
make_entry("Super Mario Bros"),
make_entry("Zelda"),
make_entry("Super Metroid"),
]);
app.search_query = "super".to_string();
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 2);
assert_eq!(app.filtered_indices, vec![0, 2]);
}
#[test]
fn rebuild_filtered_empty_result() {
let mut app = test_browser(vec![make_entry("Mario"), make_entry("Zelda")]);
app.search_query = "castlevania".to_string();
app.rebuild_filtered();
assert!(app.filtered_indices.is_empty());
}
#[test]
fn selected_entry_returns_correct_rom() {
let app = test_browser(vec![
make_entry("Game A"),
make_entry("Game B"),
make_entry("Game C"),
]);
assert_eq!(app.selected_entry().unwrap().display_name, "Game A");
}
#[test]
fn selected_entry_after_filter_returns_filtered_item() {
let mut app = test_browser(vec![
make_entry("Alpha"),
make_entry("Beta"),
make_entry("Gamma"),
]);
app.search_query = "beta".to_string();
app.rebuild_filtered();
assert_eq!(app.selected_entry().unwrap().display_name, "Beta");
}
#[test]
fn selection_clamps_after_filter_reduces_results() {
let mut app = test_browser(vec![make_entry("A"), make_entry("B"), make_entry("C")]);
app.selected_index = 2; app.search_query = "a".to_string();
app.rebuild_filtered();
assert_eq!(app.selected_index, 0);
assert_eq!(app.selected_entry().unwrap().display_name, "A");
}
fn make_entry_with_genres(name: &str, genres: Vec<&str>) -> RomEntry {
let mut entry = make_entry(name);
entry.genres = genres.into_iter().map(String::from).collect();
entry
}
#[test]
fn set_catalog_collects_available_genres() {
let app = test_browser(vec![
make_entry_with_genres("Mario", vec!["Platform", "Action"]),
make_entry_with_genres("Zelda", vec!["Adventure", "Action"]),
make_entry_with_genres("Tetris", vec!["Puzzle"]),
]);
assert_eq!(
app.available_genres,
vec!["Action", "Adventure", "Platform", "Puzzle"]
);
}
#[test]
fn genre_filter_narrows_results() {
let mut app = test_browser(vec![
make_entry_with_genres("Mario", vec!["Platform"]),
make_entry_with_genres("Zelda", vec!["Adventure"]),
make_entry_with_genres("Contra", vec!["Platform", "Shooter"]),
]);
app.active_genres = vec!["Platform".to_string()];
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 2);
assert_eq!(app.filtered_indices, vec![0, 2]);
}
#[test]
fn genre_and_search_combined() {
let mut app = test_browser(vec![
make_entry_with_genres("Super Mario", vec!["Platform"]),
make_entry_with_genres("Super Contra", vec!["Platform", "Shooter"]),
make_entry_with_genres("Zelda", vec!["Adventure"]),
]);
app.active_genres = vec!["Platform".to_string()];
app.search_query = "super".to_string();
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 2);
assert_eq!(app.filtered_indices, vec![0, 1]);
}
#[test]
fn detail_view_opens_when_entry_selected() {
let mut app = test_browser(vec![make_entry("Castlevania")]);
assert!(!app.detail_view_active);
app.open_detail_view();
assert!(app.detail_view_active);
assert_eq!(app.detail_screenshot_index, 0);
}
#[test]
fn detail_view_does_not_open_when_catalog_empty() {
let mut app = test_browser(vec![]);
assert!(!app.detail_view_active);
app.open_detail_view();
assert!(!app.detail_view_active);
}
#[test]
fn detail_screenshot_auto_scroll() {
let mut entry = make_entry("Zelda");
entry.screenshot_paths = vec![
PathBuf::from("s1.jpg"),
PathBuf::from("s2.jpg"),
PathBuf::from("s3.jpg"),
];
let mut app = test_browser(vec![entry]);
app.open_detail_view();
assert!(app.detail_view_active);
assert_eq!(app.detail_screenshot_index, 0);
assert!(app.detail_scroll_forward);
app.advance_screenshot_auto_scroll();
assert_eq!(app.detail_screenshot_index, 0);
app.detail_scroll_last_advance = Instant::now() - std::time::Duration::from_secs_f64(2.1);
app.advance_screenshot_auto_scroll();
assert_eq!(app.detail_screenshot_index, 1);
app.detail_scroll_last_advance = Instant::now() - std::time::Duration::from_secs_f64(1.6);
app.advance_screenshot_auto_scroll();
assert_eq!(app.detail_screenshot_index, 2);
app.detail_scroll_last_advance = Instant::now() - std::time::Duration::from_secs_f64(2.1);
app.advance_screenshot_auto_scroll();
assert_eq!(app.detail_screenshot_index, 1);
assert!(!app.detail_scroll_forward);
app.detail_scroll_last_advance = Instant::now() - std::time::Duration::from_secs_f64(1.6);
app.advance_screenshot_auto_scroll();
assert_eq!(app.detail_screenshot_index, 0);
app.detail_scroll_last_advance = Instant::now() - std::time::Duration::from_secs_f64(2.1);
app.advance_screenshot_auto_scroll();
assert_eq!(app.detail_screenshot_index, 1);
assert!(app.detail_scroll_forward);
}
#[test]
fn detail_screenshot_resets_on_reopen() {
let mut entry = make_entry("Zelda");
entry.screenshot_paths = vec![PathBuf::from("s1.jpg"), PathBuf::from("s2.jpg")];
let mut app = test_browser(vec![entry]);
app.open_detail_view();
app.detail_screenshot_index = 1;
app.detail_scroll_forward = false;
app.detail_view_active = false;
app.open_detail_view();
assert_eq!(app.detail_screenshot_index, 0);
assert!(app.detail_scroll_forward);
}
#[test]
fn toggle_favorite_marks_entry() {
let mut app = test_browser(vec![make_entry("Zelda"), make_entry("Mario")]);
assert!(!app.catalog[0].is_favorite);
app.toggle_favorite();
assert!(app.catalog[0].is_favorite);
app.toggle_favorite();
assert!(!app.catalog[0].is_favorite);
}
#[test]
fn show_favorites_only_filters_non_favorites() {
let mut app = test_browser(vec![
make_entry("Zelda"),
make_entry("Mario"),
make_entry("Contra"),
]);
app.selected_index = 1;
app.toggle_favorite();
app.show_favorites_only = true;
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 1);
assert_eq!(app.catalog[app.filtered_indices[0]].display_name, "Mario");
}
#[test]
fn toggle_favorite_rebuilds_filter_when_showing_favorites() {
let mut app = test_browser(vec![make_entry("Zelda"), make_entry("Mario")]);
app.toggle_favorite();
app.selected_index = 1;
app.toggle_favorite();
app.show_favorites_only = true;
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 2);
app.selected_index = 0;
app.toggle_favorite();
assert_eq!(app.filtered_indices.len(), 1);
}
#[test]
fn poll_catalog_loading_receives_catalog() {
let mut app = test_browser(vec![]);
assert!(app.catalog.is_empty());
let (tx, rx) = mpsc::channel();
app.catalog_state = CatalogState::Loading {
receiver: rx,
progress: None,
};
app.poll_catalog_loading();
assert!(app.catalog.is_empty());
tx.send(CatalogMessage::Done(vec![
make_entry("Zelda"),
make_entry("Mario"),
]))
.unwrap();
app.poll_catalog_loading();
assert_eq!(app.catalog.len(), 2);
assert!(matches!(app.catalog_state, CatalogState::Ready));
}
#[test]
fn poll_catalog_loading_tracks_progress() {
let mut app = test_browser(vec![]);
let (tx, rx) = mpsc::channel();
app.catalog_state = CatalogState::Loading {
receiver: rx,
progress: None,
};
tx.send(CatalogMessage::Progress(EnrichmentProgress {
current: 3,
total: 10,
game_title: "Zelda".to_string(),
phase: EnrichmentPhase::MatchingMetadata,
}))
.unwrap();
app.poll_catalog_loading();
if let CatalogState::Loading { ref progress, .. } = app.catalog_state {
let p = progress.as_ref().unwrap();
assert_eq!(p.current, 3);
assert_eq!(p.total, 10);
assert_eq!(p.game_title, "Zelda");
} else {
panic!("Expected CatalogState::Loading");
}
}
#[test]
fn back_opens_filter_panel_instead_of_exiting() {
let mut app = test_browser(vec![make_entry("Zelda")]);
assert!(!app.filter_panel_active);
app.open_filter_panel();
assert!(app.filter_panel_active);
assert_eq!(app.filter_panel_cursor, 0);
assert_eq!(app.filter_panel_column, 0);
}
#[test]
fn back_closes_filter_panel() {
let mut app = test_browser(vec![make_entry("Zelda")]);
app.filter_panel_active = true;
app.close_filter_panel();
assert!(!app.filter_panel_active);
}
#[test]
fn platform_filter_narrows_results() {
let mut nes = make_entry("Zelda");
nes.platform = Platform::Nes;
let mut gb = make_entry("Pokemon");
gb.platform = Platform::Gb;
let mut app = test_browser(vec![nes, gb]);
assert_eq!(app.filtered_indices.len(), 2);
app.active_platform = Some(Platform::Nes);
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 1);
assert_eq!(app.catalog[app.filtered_indices[0]].display_name, "Zelda");
app.active_platform = Some(Platform::Gb);
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 1);
assert_eq!(app.catalog[app.filtered_indices[0]].display_name, "Pokemon");
app.active_platform = None;
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 2);
}
#[test]
fn filter_panel_cursor_bounded_within_column() {
let mut app = test_browser(vec![make_entry("Zelda")]);
app.available_genres = vec!["Action".to_string(), "RPG".to_string()];
app.filter_panel_active = true;
app.filter_panel_column = 0;
app.filter_panel_cursor = 0;
app.filter_panel_move_cursor_down();
app.filter_panel_move_cursor_down();
assert_eq!(app.filter_panel_cursor, 2); app.filter_panel_move_cursor_down();
assert_eq!(app.filter_panel_cursor, 2);
app.filter_panel_column = 2;
app.filter_panel_cursor = 0;
app.filter_panel_move_cursor_down();
assert_eq!(app.filter_panel_cursor, 1);
app.filter_panel_move_cursor_down();
assert_eq!(app.filter_panel_cursor, 1); }
#[test]
fn filter_panel_confirm_toggles_platform() {
let mut nes = make_entry("Zelda");
nes.platform = Platform::Nes;
let mut gb = make_entry("Pokemon");
gb.platform = Platform::Gb;
let mut app = test_browser(vec![nes, gb]);
app.filter_panel_active = true;
app.filter_panel_column = 0;
assert_eq!(app.active_platform, None);
app.filter_panel_cursor = 0;
app.filter_panel_confirm();
assert_eq!(app.active_platform, Some(Platform::Nes));
assert_eq!(app.filtered_indices.len(), 1);
app.filter_panel_confirm();
assert_eq!(app.active_platform, None);
assert_eq!(app.filtered_indices.len(), 2);
app.filter_panel_cursor = 1;
app.filter_panel_confirm();
assert_eq!(app.active_platform, Some(Platform::Gb));
assert_eq!(app.filtered_indices.len(), 1);
app.filter_panel_confirm();
assert_eq!(app.active_platform, None);
app.filter_panel_cursor = 2;
app.filter_panel_confirm();
assert_eq!(app.active_platform, Some(Platform::Gbc));
assert_eq!(app.filtered_indices.len(), 0);
}
#[test]
fn filter_panel_confirm_toggles_genre() {
let mut entry = make_entry("Zelda");
entry.genres = vec!["Action".to_string(), "RPG".to_string()];
let mut app = test_browser(vec![entry, make_entry("Mario")]);
app.available_genres = vec!["Action".to_string(), "RPG".to_string()];
app.filter_panel_active = true;
app.filter_panel_column = 2;
app.filter_panel_cursor = 0;
app.filter_panel_confirm();
assert!(app.active_genres.contains(&"Action".to_string()));
assert_eq!(app.filtered_indices.len(), 1);
app.filter_panel_confirm();
assert!(!app.active_genres.contains(&"Action".to_string()));
assert_eq!(app.filtered_indices.len(), 2);
}
#[test]
fn filter_panel_move_cursor_up_down() {
let mut app = test_browser(vec![make_entry("Zelda")]);
app.available_genres = vec!["Action".to_string(), "RPG".to_string()];
app.filter_panel_active = true;
app.filter_panel_column = 0;
app.filter_panel_cursor = 0;
app.filter_panel_move_cursor_down();
assert_eq!(app.filter_panel_cursor, 1);
app.filter_panel_move_cursor_down();
assert_eq!(app.filter_panel_cursor, 2);
app.filter_panel_move_cursor_up();
assert_eq!(app.filter_panel_cursor, 1);
app.filter_panel_cursor = 0;
app.filter_panel_move_cursor_up();
assert_eq!(app.filter_panel_cursor, 0);
app.filter_panel_cursor = 2;
app.filter_panel_move_cursor_down();
assert_eq!(app.filter_panel_cursor, 2);
}
#[test]
fn filter_panel_left_right_switches_column() {
let mut app = test_browser(vec![make_entry("Zelda")]);
app.available_genres = vec!["Action".to_string(), "RPG".to_string()];
app.filter_panel_active = true;
app.filter_panel_column = 0;
app.filter_panel_cursor = 1;
app.filter_panel_move_right();
assert_eq!(app.filter_panel_column, 1);
assert_eq!(app.filter_panel_cursor, 1);
app.filter_panel_move_left();
assert_eq!(app.filter_panel_column, 0);
assert_eq!(app.filter_panel_cursor, 1);
app.filter_panel_move_left();
assert_eq!(app.filter_panel_column, 0);
app.filter_panel_column = 1;
app.filter_panel_move_right();
assert_eq!(app.filter_panel_column, 2);
app.filter_panel_move_right();
assert_eq!(app.filter_panel_column, 2);
}
#[test]
fn filter_panel_player_filter_narrows_results() {
let mut entry1 = make_entry("Single");
entry1.players = Some(1);
let mut entry2 = make_entry("TwoPlayer");
entry2.players = Some(2);
let mut entry4 = make_entry("FourPlayer");
entry4.players = Some(4);
let mut app = test_browser(vec![entry1, entry2, entry4]);
assert_eq!(app.filtered_indices.len(), 3);
app.min_players_filter = Some(2);
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 2);
app.min_players_filter = Some(4);
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 1);
assert_eq!(
app.catalog[app.filtered_indices[0]].display_name,
"FourPlayer"
);
app.min_players_filter = None;
app.rebuild_filtered();
assert_eq!(app.filtered_indices.len(), 3);
}
#[test]
fn filter_panel_confirm_toggles_player_filter() {
let mut app = test_browser(vec![make_entry("Zelda")]);
app.filter_panel_active = true;
app.filter_panel_column = 1; app.filter_panel_cursor = 0;
app.filter_panel_confirm();
assert_eq!(app.min_players_filter, None);
app.filter_panel_cursor = 1;
app.filter_panel_confirm();
assert_eq!(app.min_players_filter, Some(2));
app.filter_panel_cursor = 2;
app.filter_panel_confirm();
assert_eq!(app.min_players_filter, Some(4));
app.filter_panel_confirm();
assert_eq!(app.min_players_filter, None);
}
#[test]
fn open_search_panel_sets_state() {
let mut app = test_browser(vec![make_entry("A")]);
assert!(!app.search_active);
app.open_search_panel();
assert!(app.search_active);
assert_eq!(app.search_kb_row, 1);
assert_eq!(app.search_kb_col, 0);
}
#[test]
fn close_search_panel_clears_active() {
let mut app = test_browser(vec![make_entry("A")]);
app.open_search_panel();
app.close_search_panel();
assert!(!app.search_active);
}
#[test]
fn search_kb_confirm_types_character() {
let mut app = test_browser(vec![make_entry("A")]);
app.open_search_panel();
app.search_kb_confirm();
assert_eq!(app.search_query, "q");
}
#[test]
fn search_kb_confirm_backspace_deletes() {
let mut app = test_browser(vec![make_entry("A")]);
app.search_query = "hello".to_string();
app.open_search_panel();
app.search_kb_row = 3;
app.search_kb_col = 8;
app.search_kb_confirm();
assert_eq!(app.search_query, "hell");
}
#[test]
fn search_kb_confirm_enter_closes_search() {
let mut app = test_browser(vec![make_entry("A")]);
app.open_search_panel();
app.search_kb_row = 3;
app.search_kb_col = 9;
app.search_kb_confirm();
assert!(!app.search_active);
}
#[test]
fn search_kb_navigation_bounded() {
let mut app = test_browser(vec![make_entry("A")]);
app.open_search_panel();
app.search_kb_move_up();
assert_eq!(app.search_kb_row, 0);
app.search_kb_move_up();
assert_eq!(app.search_kb_row, 0);
app.search_kb_move_down();
app.search_kb_move_down();
app.search_kb_move_down();
assert_eq!(app.search_kb_row, 3);
app.search_kb_move_down();
assert_eq!(app.search_kb_row, 3);
}
#[test]
fn search_kb_left_right_bounded() {
let mut app = test_browser(vec![make_entry("A")]);
app.open_search_panel();
app.search_kb_move_left();
assert_eq!(app.search_kb_col, 0);
for _ in 0..20 {
app.search_kb_move_right();
}
assert_eq!(app.search_kb_col, 9);
}
#[test]
fn search_kb_space_inserts_space() {
let mut app = test_browser(vec![make_entry("A")]);
app.open_search_panel();
app.search_kb_row = 3;
app.search_kb_col = 7; app.search_kb_confirm();
assert_eq!(app.search_query, " ");
}
}