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