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}