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