1use std::time::Duration;
4
5use anyhow::Result;
6use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
10use ratatui::Frame;
11use std::time::Instant;
12
13use crate::config::ExtrasDefaults;
14use crate::config::RomsLayoutConfig;
15use crate::core::download::resolve_download_directory;
16use crate::core::extras::{
17 build_cover_target, build_manual_target, build_update_dlc_file_targets_for_rom,
18 collect_update_dlc_files, extras_root_dir, related_rom_download_target, DownloadTarget,
19};
20use crate::types::{Rom, RomFile};
21
22use super::game_detail::GameDetailScreen;
23
24#[derive(Debug, Clone)]
26pub enum ExtrasTargetSeed {
27 RelatedRom(Box<Rom>),
28 InternalRomFile(RomFile),
29 Cover,
30 Manual,
31}
32
33#[derive(Debug, Clone)]
34pub struct ExtrasPickerItem {
35 pub label: String,
36 pub sublabel: String,
37 pub checked: bool,
38 pub seed: ExtrasTargetSeed,
39}
40
41pub struct ExtrasPickerScreen {
43 pub rom: Rom,
44 pub items: Vec<ExtrasPickerItem>,
45 pub selected_index: usize,
46 pub previous: Box<GameDetailScreen>,
47 pub message: Option<String>,
48 pub message_clear_at: Option<Instant>,
49}
50
51impl ExtrasPickerScreen {
52 pub fn new(previous: Box<GameDetailScreen>, defaults: &ExtrasDefaults) -> Self {
53 let rom = previous.rom.clone();
54 let mut items = Vec::new();
55
56 for other in &previous.other_files {
57 items.push(ExtrasPickerItem {
58 label: other.fs_name.clone(),
59 sublabel: format!("Related ROM (id {})", other.id),
60 checked: defaults.include_related_roms,
61 seed: ExtrasTargetSeed::RelatedRom(Box::new(other.clone())),
62 });
63 }
64
65 for file in collect_update_dlc_files(&rom) {
66 let tag = match file.category {
67 Some(crate::types::RomFileCategory::Update) => "Update",
68 Some(crate::types::RomFileCategory::Dlc) => "DLC",
69 _ => "ROM file",
70 };
71 items.push(ExtrasPickerItem {
72 label: file.file_name.clone(),
73 sublabel: format!("{tag} (file id {})", file.id),
74 checked: defaults.include_related_roms,
75 seed: ExtrasTargetSeed::InternalRomFile(file),
76 });
77 }
78
79 if rom
80 .url_cover
81 .as_deref()
82 .map(str::trim)
83 .filter(|s| !s.is_empty())
84 .is_some()
85 {
86 items.push(ExtrasPickerItem {
87 label: "Cover image".to_string(),
88 sublabel: "From url_cover".to_string(),
89 checked: defaults.include_cover,
90 seed: ExtrasTargetSeed::Cover,
91 });
92 }
93
94 if rom
95 .url_manual
96 .as_deref()
97 .map(str::trim)
98 .filter(|s| !s.is_empty())
99 .is_some()
100 {
101 items.push(ExtrasPickerItem {
102 label: "Manual".to_string(),
103 sublabel: "From url_manual".to_string(),
104 checked: defaults.include_manual,
105 seed: ExtrasTargetSeed::Manual,
106 });
107 }
108
109 Self {
110 rom,
111 items,
112 selected_index: 0,
113 previous,
114 message: None,
115 message_clear_at: None,
116 }
117 }
118
119 pub fn item_count(&self) -> usize {
120 self.items.len()
121 }
122
123 pub fn selected_count(&self) -> usize {
124 self.items.iter().filter(|i| i.checked).count()
125 }
126
127 pub fn move_up(&mut self) {
128 if self.items.is_empty() {
129 return;
130 }
131 if self.selected_index == 0 {
132 self.selected_index = self.items.len() - 1;
133 } else {
134 self.selected_index -= 1;
135 }
136 }
137
138 pub fn move_down(&mut self) {
139 if self.items.is_empty() {
140 return;
141 }
142 self.selected_index = (self.selected_index + 1) % self.items.len();
143 }
144
145 pub fn toggle_current(&mut self) {
146 if let Some(i) = self.items.get_mut(self.selected_index) {
147 i.checked = !i.checked;
148 }
149 }
150
151 pub fn toggle_all(&mut self) {
152 let any_unchecked = self.items.iter().any(|i| !i.checked);
153 for i in &mut self.items {
154 i.checked = any_unchecked;
155 }
156 }
157
158 pub fn show_message(&mut self, msg: impl Into<String>, ttl: Duration) {
159 self.message = Some(msg.into());
160 self.message_clear_at = Some(Instant::now() + ttl);
161 }
162
163 pub fn tick_message(&mut self) {
164 if let Some(clear_at) = self.message_clear_at {
165 if Instant::now() >= clear_at {
166 self.message = None;
167 self.message_clear_at = None;
168 }
169 }
170 }
171
172 pub fn build_selected_targets(
174 &self,
175 layout: &RomsLayoutConfig,
176 configured_download_dir: Option<&str>,
177 ) -> Result<Vec<DownloadTarget>> {
178 let out = resolve_download_directory(configured_download_dir)?;
179 let root = extras_root_dir(layout, &out, &self.rom)?;
180 let mut targets = Vec::new();
181 let internal_targets = build_update_dlc_file_targets_for_rom(&self.rom, layout, &out)?;
182
183 for item in &self.items {
184 if !item.checked {
185 continue;
186 }
187 match &item.seed {
188 ExtrasTargetSeed::RelatedRom(other) => {
189 targets.push(related_rom_download_target(&self.rom, other, &root));
190 }
191 ExtrasTargetSeed::InternalRomFile(file) => {
192 if let Some(t) = internal_targets
193 .iter()
194 .find(|t| {
195 t.source_url
196 .contains(&format!("/api/roms/{}/files/", file.id))
197 || t.source_url
198 .contains(&format!("/api/romsfiles/{}/", file.id))
199 })
200 .cloned()
201 {
202 targets.push(t);
203 }
204 }
205 ExtrasTargetSeed::Cover => {
206 if let Some(t) = build_cover_target(&self.rom, &root) {
207 targets.push(t);
208 }
209 }
210 ExtrasTargetSeed::Manual => {
211 if let Some(t) = build_manual_target(&self.rom, &root) {
212 targets.push(t);
213 }
214 }
215 }
216 }
217
218 Ok(targets)
219 }
220
221 pub fn render(&mut self, f: &mut Frame, area: Rect) {
222 self.tick_message();
223 let chunks = Layout::default()
224 .constraints([
225 Constraint::Length(3),
226 Constraint::Min(6),
227 Constraint::Length(3),
228 Constraint::Length(3),
229 ])
230 .direction(Direction::Vertical)
231 .split(area);
232
233 let title = format!("Extras — {}", self.rom.name);
234 f.render_widget(
235 Paragraph::new(title)
236 .alignment(Alignment::Center)
237 .style(Style::default().add_modifier(Modifier::BOLD))
238 .block(Block::default().borders(Borders::ALL)),
239 chunks[0],
240 );
241
242 let list_items: Vec<ListItem> = self
243 .items
244 .iter()
245 .map(|it| {
246 let mark = if it.checked { "[x]" } else { "[ ]" };
247 ListItem::new(Line::from(vec![
248 Span::styled(format!("{} ", mark), Style::default().fg(Color::Cyan)),
249 Span::styled(&it.label, Style::default().fg(Color::White)),
250 Span::raw(" — "),
251 Span::styled(&it.sublabel, Style::default().fg(Color::DarkGray)),
252 ]))
253 })
254 .collect();
255
256 let mut state = ListState::default();
257 state.select(Some(self.selected_index));
258
259 let list = List::new(list_items)
260 .block(Block::default().title("Items").borders(Borders::ALL))
261 .highlight_style(Style::default().fg(Color::Yellow));
262
263 f.render_stateful_widget(list, chunks[1], &mut state);
264
265 let hint = self.message.as_deref().unwrap_or(
266 "↑/↓: Navigate | Space: Toggle | a: Toggle all | Enter: Download selected | Esc: Back",
267 );
268 f.render_widget(
269 Paragraph::new(hint)
270 .block(Block::default().borders(Borders::ALL))
271 .style(Style::default().fg(Color::Gray)),
272 chunks[2],
273 );
274
275 let footer = Paragraph::new("At least one item must be checked to start download.")
276 .alignment(Alignment::Center)
277 .block(Block::default().borders(Borders::ALL));
278 f.render_widget(footer, chunks[3]);
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::core::download::DownloadJob;
286 use crate::tui::screens::game_detail::{GameDetailPrevious, GameDetailScreen};
287 use crate::tui::screens::SearchScreen;
288 use std::path::PathBuf;
289 use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
290 use std::time::{SystemTime, UNIX_EPOCH};
291
292 fn minimal_rom() -> Rom {
293 Rom {
294 id: 1,
295 platform_id: 2,
296 platform_slug: Some("nes".into()),
297 platform_fs_slug: Some("NES".into()),
298 platform_custom_name: None,
299 platform_display_name: None,
300 fs_name: "game.zip".into(),
301 fs_name_no_tags: "game".into(),
302 fs_name_no_ext: "game".into(),
303 fs_extension: "zip".into(),
304 fs_path: "/game.zip".into(),
305 fs_size_bytes: 1,
306 name: "Game".into(),
307 slug: None,
308 summary: None,
309 path_cover_small: None,
310 path_cover_large: None,
311 url_cover: None,
312 has_manual: false,
313 path_manual: None,
314 url_manual: None,
315 is_unidentified: false,
316 is_identified: true,
317 files: Vec::new(),
318 }
319 }
320
321 fn detail_with_extras() -> GameDetailScreen {
322 let mut primary = minimal_rom();
323 primary.url_cover = Some("https://x/c.png".into());
324 primary.url_manual = Some("https://x/m.pdf".into());
325
326 let other = Rom {
327 id: 2,
328 ..minimal_rom()
329 };
330
331 let prev = GameDetailPrevious::Search(SearchScreen::new());
332 let downloads = Arc::new(Mutex::new(Vec::<DownloadJob>::new()));
333 GameDetailScreen::new(primary, vec![other], prev, downloads)
334 }
335
336 fn test_download_dir(label: &str) -> PathBuf {
337 let ts = SystemTime::now()
338 .duration_since(UNIX_EPOCH)
339 .unwrap()
340 .as_nanos();
341 std::env::temp_dir().join(format!("romm-extras-{label}-{}-{ts}", std::process::id()))
342 }
343
344 fn env_lock() -> &'static Mutex<()> {
345 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
346 LOCK.get_or_init(|| Mutex::new(()))
347 }
348
349 struct TestDownloadEnv {
350 _guard: MutexGuard<'static, ()>,
351 dir: PathBuf,
352 prev_roms_dir: Option<String>,
353 prev_download_dir: Option<String>,
354 }
355
356 impl TestDownloadEnv {
357 fn new(label: &str) -> Self {
358 let guard = env_lock().lock().expect("env lock");
359 let dir = test_download_dir(label);
360 let prev_roms_dir = std::env::var("ROMM_ROMS_DIR").ok();
361 let prev_download_dir = std::env::var("ROMM_DOWNLOAD_DIR").ok();
362 std::env::set_var("ROMM_ROMS_DIR", &dir);
363 std::env::remove_var("ROMM_DOWNLOAD_DIR");
364 Self {
365 _guard: guard,
366 dir,
367 prev_roms_dir,
368 prev_download_dir,
369 }
370 }
371 }
372
373 impl Drop for TestDownloadEnv {
374 fn drop(&mut self) {
375 match &self.prev_roms_dir {
376 Some(value) => std::env::set_var("ROMM_ROMS_DIR", value),
377 None => std::env::remove_var("ROMM_ROMS_DIR"),
378 }
379 match &self.prev_download_dir {
380 Some(value) => std::env::set_var("ROMM_DOWNLOAD_DIR", value),
381 None => std::env::remove_var("ROMM_DOWNLOAD_DIR"),
382 }
383 }
384 }
385
386 #[test]
387 fn picker_seeds_checked_state_from_defaults() {
388 let detail = detail_with_extras();
389 let defaults = ExtrasDefaults {
390 include_related_roms: true,
391 include_cover: false,
392 include_manual: true,
393 };
394 let picker = ExtrasPickerScreen::new(Box::new(detail), &defaults);
395 assert_eq!(picker.items.len(), 3);
396 assert!(picker.items[0].checked);
397 assert!(!picker.items[1].checked);
398 assert!(picker.items[2].checked);
399 }
400
401 #[test]
402 fn picker_toggle_all_inverts_when_any_unchecked() {
403 let detail = detail_with_extras();
404 let defaults = ExtrasDefaults::default();
405 let mut picker = ExtrasPickerScreen::new(Box::new(detail), &defaults);
406 picker.items[0].checked = false;
407 picker.toggle_all();
408 assert!(picker.items.iter().all(|i| i.checked));
409 picker.toggle_all();
410 assert!(picker.items.iter().all(|i| !i.checked));
411 }
412
413 #[test]
414 fn build_selected_targets_empty_when_none_checked() {
415 let detail = detail_with_extras();
416 let mut picker = ExtrasPickerScreen::new(Box::new(detail), &ExtrasDefaults::default());
417 for i in &mut picker.items {
418 i.checked = false;
419 }
420 let env = TestDownloadEnv::new("empty");
421 let dir = env.dir.clone();
422 let targets = picker
423 .build_selected_targets(&RomsLayoutConfig::default(), Some("ignored"))
424 .unwrap();
425 assert!(targets.is_empty());
426 drop(env);
427 let _ = std::fs::remove_dir_all(dir);
428 }
429
430 #[test]
431 fn picker_emits_targets_for_checked_items_only() {
432 let detail = detail_with_extras();
433 let mut picker = ExtrasPickerScreen::new(Box::new(detail), &ExtrasDefaults::default());
434 for i in &mut picker.items {
435 i.checked = false;
436 }
437 picker.items[1].checked = true; let env = TestDownloadEnv::new("cover");
440 let dir = env.dir.clone();
441 let targets = picker
442 .build_selected_targets(&RomsLayoutConfig::default(), Some("ignored"))
443 .expect("targets");
444 assert_eq!(targets.len(), 1);
445 assert!(matches!(
446 targets[0].kind,
447 crate::core::extras::DownloadAssetKind::Cover
448 ));
449 drop(env);
450 let _ = std::fs::remove_dir_all(dir);
451 }
452}