Skip to main content

modde_ui/views/
game_picker.rs

1use std::collections::HashSet;
2use std::fmt;
3
4use iced::widget::pick_list;
5use iced::{Element, Length};
6use modde_sources::wabbajack::catalog::{CatalogEntrySource, WabbajackCatalogEntry};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct GameOption {
10    pub value: String,
11    label: String,
12}
13
14impl GameOption {
15    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
16        Self {
17            value: value.into(),
18            label: label.into(),
19        }
20    }
21
22    pub fn from_game_id(game_id: impl Into<String>) -> Self {
23        let value = game_id.into();
24        let label = human_game_label(&value);
25        Self { value, label }
26    }
27
28    pub fn from_wabbajack_game(game: &str) -> Self {
29        let value = modde_games::normalize_wabbajack_game(game)
30            .map(str::to_string)
31            .unwrap_or_else(|| game.trim().to_ascii_lowercase());
32        let label = human_game_label(game);
33        Self { value, label }
34    }
35}
36
37impl fmt::Display for GameOption {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.write_str(&self.label)
40    }
41}
42
43pub fn game_pick_list<'a, Message>(
44    options: Vec<GameOption>,
45    selected: Option<GameOption>,
46    placeholder: &'static str,
47    on_selected: impl Fn(GameOption) -> Message + 'a,
48) -> Element<'a, Message>
49where
50    Message: Clone + 'a,
51{
52    let width = pick_list_width(&options, placeholder);
53    pick_list(options, selected, on_selected)
54        .placeholder(placeholder)
55        .width(width)
56        .into()
57}
58
59pub fn supported_game_options<'a>(
60    games: impl IntoIterator<Item = &'a (String, String)>,
61) -> Vec<GameOption> {
62    games
63        .into_iter()
64        .map(|(id, _)| GameOption::from_game_id(id.clone()))
65        .collect()
66}
67
68pub fn nexus_game_options<'a>(
69    games: impl IntoIterator<Item = &'a (String, String)>,
70) -> Vec<GameOption> {
71    games
72        .into_iter()
73        .filter(|(id, _)| {
74            modde_games::resolve_game(id)
75                .is_some_and(|game| game.nexus_domain.is_some() && game.nexus_game_id.is_some())
76        })
77        .map(|(id, _)| GameOption::from_game_id(id.clone()))
78        .collect()
79}
80
81pub fn supported_game_options_ordered<'a>(
82    games: impl IntoIterator<Item = &'a (String, String)>,
83    detected_game_ids: &HashSet<String>,
84) -> Vec<GameOption> {
85    let mut options: Vec<GameOption> = games
86        .into_iter()
87        .map(|(id, _)| GameOption::from_game_id(id.clone()))
88        .collect();
89    options.sort_by(|a, b| {
90        let a_undetected = !detected_game_ids.contains(&a.value);
91        let b_undetected = !detected_game_ids.contains(&b.value);
92        a_undetected
93            .cmp(&b_undetected)
94            .then_with(|| a.to_string().cmp(&b.to_string()))
95    });
96    options
97}
98
99pub fn wabbajack_game_options(
100    entries: &[WabbajackCatalogEntry],
101    source: &CatalogEntrySource,
102) -> Vec<GameOption> {
103    let mut options: Vec<GameOption> = entries
104        .iter()
105        .filter(|entry| &entry.source == source)
106        .filter_map(|entry| entry.game.as_deref())
107        .map(GameOption::from_wabbajack_game)
108        .collect();
109    options.sort_by_key(std::string::ToString::to_string);
110    options.dedup_by(|a, b| a.value == b.value);
111    options
112}
113
114pub fn human_game_label(game: &str) -> String {
115    let game_id = modde_games::normalize_wabbajack_game(game).unwrap_or(game);
116    modde_games::resolve_game_plugin(game_id)
117        .map(|plugin| plugin.display_name().to_string())
118        .or_else(|| known_wabbajack_game_label(game_id))
119        .unwrap_or_else(|| titleize_game_name(game))
120}
121
122pub(crate) fn pick_list_width(options: &[GameOption], placeholder: &str) -> Length {
123    let longest = options
124        .iter()
125        .map(|option| option.label.chars().count())
126        .chain(std::iter::once(placeholder.chars().count()))
127        .max()
128        .unwrap_or(0);
129    let width = (longest as f32 * 8.0 + 58.0).max(160.0);
130    Length::Fixed(width)
131}
132
133fn titleize_game_name(game: &str) -> String {
134    let mut words = Vec::new();
135    let mut current = String::new();
136    let mut prev_lowercase = false;
137
138    for ch in game.trim().chars() {
139        if ch == '-' || ch == '_' || ch.is_whitespace() {
140            if !current.is_empty() {
141                words.push(std::mem::take(&mut current));
142            }
143            prev_lowercase = false;
144            continue;
145        }
146
147        if ch.is_ascii_uppercase() && prev_lowercase && !current.is_empty() {
148            words.push(std::mem::take(&mut current));
149        }
150
151        current.push(ch);
152        prev_lowercase = ch.is_ascii_lowercase();
153    }
154
155    if !current.is_empty() {
156        words.push(current);
157    }
158
159    words
160        .into_iter()
161        .map(|word| {
162            let mut chars = word.chars();
163            match chars.next() {
164                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
165                None => String::new(),
166            }
167        })
168        .collect::<Vec<_>>()
169        .join(" ")
170}
171
172fn known_wabbajack_game_label(game: &str) -> Option<String> {
173    let key: String = game
174        .chars()
175        .filter(char::is_ascii_alphanumeric)
176        .flat_map(char::to_lowercase)
177        .collect();
178
179    let label = match key.as_str() {
180        "fallout3" => "Fallout 3",
181        "falloutnewvegas" | "newvegas" => "Fallout: New Vegas",
182        "morrowind" => "Morrowind",
183        "oblivion" => "Oblivion",
184        "oblivionremastered" => "Oblivion Remastered",
185        "skyrim" => "The Elder Scrolls V: Skyrim",
186        "enderal" | "enderalspecialedition" => "Enderal Special Edition",
187        _ => return None,
188    };
189    Some(label.to_string())
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn wabbajack_entry(game: &str, source: CatalogEntrySource) -> WabbajackCatalogEntry {
197        WabbajackCatalogEntry {
198            title: format!("{game} list"),
199            game: Some(game.to_string()),
200            author: None,
201            version: None,
202            tags: Vec::new(),
203            image_url: None,
204            readme_url: None,
205            download_url: format!("https://example/{game}.wabbajack"),
206            repository_name: None,
207            machine_url: None,
208            discord_url: None,
209            website_url: None,
210            official: true,
211            nsfw: false,
212            force_down: false,
213            size: Default::default(),
214            source,
215        }
216    }
217
218    #[test]
219    fn pick_list_width_fits_longest_game_label() {
220        let options = vec![
221            GameOption::new("short", "Short"),
222            GameOption::new(
223                "long",
224                "The Elder Scrolls V Skyrim Special Edition Anniversary Edition",
225            ),
226        ];
227
228        let Length::Fixed(width) = pick_list_width(&options, "Select a game") else {
229            panic!("expected fixed pick list width");
230        };
231
232        assert!(width > 360.0);
233    }
234
235    #[test]
236    fn wabbajack_game_options_include_supported_and_unsupported_games() {
237        let entries = vec![
238            wabbajack_entry("skyrimspecialedition", CatalogEntrySource::Official),
239            wabbajack_entry("oblivionremastered", CatalogEntrySource::Official),
240            wabbajack_entry("morrowind", CatalogEntrySource::Official),
241            wabbajack_entry("fallout4", CatalogEntrySource::Authored),
242        ];
243
244        let options = wabbajack_game_options(&entries, &CatalogEntrySource::Official);
245        let labels: Vec<String> = options.iter().map(ToString::to_string).collect();
246
247        assert!(labels.contains(&"The Elder Scrolls V: Skyrim Special Edition".to_string()));
248        assert!(labels.contains(&"The Elder Scrolls IV: Oblivion Remastered".to_string()));
249        assert!(labels.contains(&"Morrowind".to_string()));
250        assert!(!labels.contains(&"Fallout 4".to_string()));
251    }
252
253    #[test]
254    fn wabbajack_game_options_dedupe_by_filter_value() {
255        let entries = vec![
256            wabbajack_entry("SkyrimSpecialEdition", CatalogEntrySource::Official),
257            wabbajack_entry("skyrimspecialedition", CatalogEntrySource::Official),
258            wabbajack_entry("OblivionRemastered", CatalogEntrySource::Official),
259            wabbajack_entry("oblivionremastered", CatalogEntrySource::Official),
260        ];
261
262        let options = wabbajack_game_options(&entries, &CatalogEntrySource::Official);
263
264        assert_eq!(
265            options
266                .iter()
267                .filter(|option| option.value == "skyrim-se")
268                .count(),
269            1
270        );
271        assert_eq!(
272            options
273                .iter()
274                .filter(|option| option.value == "oblivion-remastered")
275                .count(),
276            1
277        );
278    }
279
280    #[test]
281    fn supported_game_options_put_undetected_games_last() {
282        let games = [
283            ("missing-game".to_string(), "Missing".to_string()),
284            ("skyrim-se".to_string(), "Skyrim".to_string()),
285            ("cyberpunk2077".to_string(), "Cyberpunk".to_string()),
286        ];
287        let detected = HashSet::from(["skyrim-se".to_string(), "cyberpunk2077".to_string()]);
288
289        let options = supported_game_options_ordered(games.iter(), &detected);
290        let values: Vec<&str> = options.iter().map(|option| option.value.as_str()).collect();
291
292        assert_eq!(values, vec!["cyberpunk2077", "skyrim-se", "missing-game"]);
293    }
294
295    #[test]
296    fn nexus_game_options_only_include_games_with_nexus_domains() {
297        let games = [
298            ("skyrim-se".to_string(), "Skyrim SE".to_string()),
299            ("fallout4".to_string(), "Fallout 4".to_string()),
300            ("stellar-blade".to_string(), "Stellar Blade".to_string()),
301        ];
302
303        let options = nexus_game_options(games.iter());
304        let values: Vec<&str> = options.iter().map(|option| option.value.as_str()).collect();
305
306        assert!(values.contains(&"skyrim-se"));
307        assert!(values.contains(&"fallout4"));
308        assert!(!values.contains(&"stellar-blade"));
309    }
310}