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::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/// What to download when this row is checked and confirmed.
26#[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
42/// Select extras then confirm to queue a composite download job.
43pub 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    /// Build download targets for checked rows. Fails if ROM dir is not configured.
174    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; // cover only
448
449        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}