Skip to main content

romm_cli/tui/screens/
extras_picker.rs

1//! Full-screen picker for which extras to download (TUI).
2
3use 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/// What to download when this row is checked and confirmed.
24#[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
40/// Select extras then confirm to queue a composite download job.
41pub 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    /// Build download targets for checked rows. Fails if ROM dir is not configured.
172    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; // cover only
434
435        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}