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