1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use modde_core::resolver::GameId;
5use modde_core::settings::AppSettings;
6
7use super::{FOMODWizardState, ToolOptionCatalog};
8
9pub use modde_core::profile::ReorderDirection;
14
15pub(crate) fn format_lock_reason(reason: &modde_core::LockReason) -> String {
19 use modde_core::LockReason::{Manual, NexusCollection, TomlImport, Wabbajack};
20 match reason {
21 Wabbajack { manifest_hash } => format!("Wabbajack (hash {manifest_hash})"),
22 NexusCollection { slug, version } => format!("Nexus Collection '{slug}' v{version}"),
23 TomlImport { source_path } => format!("TOML import from {source_path}"),
24 Manual { note: Some(n) } => format!("manual ({n})"),
25 Manual { note: None } => "manual".to_string(),
26 }
27}
28
29#[derive(Debug, Clone)]
31pub enum View {
32 ModList,
33 Collections,
34 BrowseNexus,
36 WabbajackInstaller(WabbajackInstallerState),
37 FOMODWizard(FOMODWizardState),
38 Settings,
39 Saves,
40 Downloads,
41 DataTab,
42 Diagnostics,
43 Tools,
44 Executables,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum SidebarGroup {
49 Game,
50 Install,
51 General,
52}
53
54impl SidebarGroup {
55 #[must_use]
56 pub fn label(self) -> &'static str {
57 match self {
58 SidebarGroup::Game => "Game",
59 SidebarGroup::Install => "Install",
60 SidebarGroup::General => "General",
61 }
62 }
63}
64
65#[derive(Debug, Clone, Default)]
66#[allow(clippy::struct_excessive_bools)]
67pub struct ToolState {
68 pub entries: Vec<ToolUiEntry>,
69 pub active_tool_id: Option<String>,
70 pub game_label: Option<String>,
71 pub game_dir_configured: bool,
72 pub loading: bool,
73 pub load_error: Option<String>,
74 pub load_generation: u64,
75 pub show_advanced_settings: bool,
76 pub active_operations: HashSet<String>,
77 pub tool_option_catalog: ToolOptionCatalog,
78 pub optiscaler_releases: Vec<modde_games::tools::ToolReleaseSummary>,
79 pub optiscaler_releases_loading: bool,
80 pub proton_versions_loading: bool,
81 pub executables: Vec<ExecutableUiEntry>,
82 pub executables_loading: bool,
83 pub executables_load_error: Option<String>,
84 pub executables_load_generation: u64,
85 pub executable_draft: ExecutableDraft,
86 pub executable_editor_open: bool,
87 pub executable_error: Option<String>,
88 pub active_executable_operations: HashSet<String>,
89}
90
91impl ToolState {
92 #[must_use]
93 pub fn is_tool_busy(&self, tool_id: &str) -> bool {
94 self.active_operations.contains(tool_id)
95 }
96
97 #[must_use]
98 pub fn is_executable_busy(&self, name: &str) -> bool {
99 self.active_executable_operations.contains(name)
100 }
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub struct ExecutableUiEntry {
105 pub name: String,
106 pub executable_path: String,
107 pub arguments: String,
108 pub working_dir: String,
109 pub environment: String,
110 pub wine_dll_overrides: String,
111 pub output_mod: String,
112 pub enabled: bool,
113}
114
115impl ExecutableUiEntry {
116 pub(super) fn from_row(row: modde_core::db::ExecutableConfigRow) -> Self {
117 let args = serde_json::from_str::<Vec<String>>(&row.arguments_json).unwrap_or_default();
118 let env = serde_json::from_str::<HashMap<String, String>>(&row.environment_json)
119 .unwrap_or_default();
120 let mut env_lines = env
121 .into_iter()
122 .map(|(key, value)| format!("{key}={value}"))
123 .collect::<Vec<_>>();
124 env_lines.sort();
125 Self {
126 name: row.name,
127 executable_path: row.executable_path.display().to_string(),
128 arguments: args.join(" "),
129 working_dir: row
130 .working_dir
131 .map(|path| path.display().to_string())
132 .unwrap_or_default(),
133 environment: env_lines.join("\n"),
134 wine_dll_overrides: row.wine_dll_overrides.unwrap_or_default(),
135 output_mod: row.output_mod,
136 enabled: row.enabled,
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ExecutableDraft {
143 pub name: String,
144 pub executable_path: String,
145 pub arguments: String,
146 pub working_dir: String,
147 pub environment: String,
148 pub wine_dll_overrides: String,
149 pub output_mod: String,
150}
151
152impl Default for ExecutableDraft {
153 fn default() -> Self {
154 Self {
155 name: String::new(),
156 executable_path: String::new(),
157 arguments: String::new(),
158 working_dir: String::new(),
159 environment: String::new(),
160 wine_dll_overrides: String::new(),
161 output_mod: "__overwrite__".to_string(),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum ExecutableDraftField {
168 Name,
169 Path,
170 Arguments,
171 WorkingDir,
172 Environment,
173 WineDllOverrides,
174 OutputMod,
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Eq)]
178pub struct AddCustomGameDraft {
179 pub id: String,
180 pub display_name: String,
181 pub install_path: String,
182 pub executable_dir: Option<String>,
183 pub steam_app_id: Option<String>,
184 pub nexus_domain: Option<String>,
185 pub proxy_dlls_csv: String,
186}
187
188#[derive(Debug, Clone, Default, PartialEq, Eq)]
189pub struct AddCustomGameState {
190 pub draft: AddCustomGameDraft,
191 pub detected_dirs: Vec<modde_games::DetectCandidateDir>,
192 pub error: Option<String>,
193}
194
195impl AddCustomGameState {
196 #[must_use]
197 pub fn can_submit(&self) -> bool {
198 self.build_spec().is_ok()
199 }
200
201 pub fn build_spec(&self) -> Result<modde_games::generic::spec::GameSpec, String> {
202 let install_path = PathBuf::from(self.draft.install_path.trim());
203 if self.draft.id.trim().is_empty()
204 || self.draft.display_name.trim().is_empty()
205 || self.draft.executable_dir.is_none()
206 || self.draft.install_path.trim().is_empty()
207 {
208 return Err("Fill in the required custom game fields.".to_string());
209 }
210 if !install_path.is_dir() {
211 return Err(format!(
212 "Install path does not exist: {}",
213 install_path.display()
214 ));
215 }
216
217 let spec = modde_games::generic::spec::GameSpec {
218 id: self.draft.id.trim().to_string(),
219 display_name: self.draft.display_name.trim().to_string(),
220 steam_app_id: empty_to_none(self.draft.steam_app_id.as_deref()),
221 install_dir_name: None,
222 install_path_override: None,
223 executable_dir: PathBuf::from(
224 self.draft
225 .executable_dir
226 .as_deref()
227 .unwrap_or_default()
228 .trim(),
229 ),
230 mod_dir: None,
231 nexus_domain: empty_to_none(self.draft.nexus_domain.as_deref()),
232 proxy_dlls: self
233 .draft
234 .proxy_dlls_csv
235 .split(',')
236 .map(str::trim)
237 .filter(|value| !value.is_empty())
238 .map(str::to_string)
239 .collect(),
240 };
241
242 spec.validate().map_err(|error| error.to_string())?;
243 Ok(spec)
244 }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub enum AddCustomGameDraftField {
249 Id,
250 DisplayName,
251 InstallPath,
252 ExecutableDir,
253 SteamAppId,
254 NexusDomain,
255 ProxyDlls,
256}
257
258pub(super) fn empty_to_none(value: Option<&str>) -> Option<String> {
259 value
260 .map(str::trim)
261 .filter(|value| !value.is_empty())
262 .map(str::to_string)
263}
264
265#[derive(Debug, Clone)]
266#[allow(clippy::struct_excessive_bools)]
267pub struct ToolUiEntry {
268 pub tool_id: String,
269 pub display_name: String,
270 pub description: String,
271 pub category: String,
272 pub available: bool,
273 pub availability_text: String,
274 pub enabled: bool,
275 pub settings: serde_json::Value,
276 pub setting_specs: Vec<modde_games::tools::ToolSettingSpec>,
277 pub generated_config_path: Option<String>,
278 pub applied_files: Vec<String>,
279 pub has_file_patching: bool,
280 pub release_support: ToolReleaseSupport,
281 pub status_message: Option<String>,
282 pub env_preview: Vec<(String, String)>,
283 pub dll_overrides: Vec<String>,
284 pub wrapper_preview: Vec<String>,
285 pub derived_facts: Vec<(String, String)>,
286 pub optiscaler_state: Option<String>,
287 pub optiscaler_latest_backup: Option<String>,
288 pub optiscaler_detected_files: usize,
289 pub apply_pending: bool,
290 pub apply_missing_inputs: Vec<String>,
291 pub setting_history: Vec<ToolHistoryUiEntry>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct ToolHistoryUiEntry {
296 pub node_id: String,
297 pub label: String,
298 pub reason: String,
299 pub enabled: bool,
300 pub is_current: bool,
301}
302
303impl ToolHistoryUiEntry {
304 pub(super) fn from_node(node: modde_core::db::ToolSettingHistoryNode) -> Self {
305 let short_id = node.node_id.chars().take(18).collect::<String>();
306 let state = if node.enabled { "enabled" } else { "disabled" };
307 Self {
308 node_id: node.node_id,
309 label: format!("{} - {state}", node.created_at),
310 reason: node.reason,
311 enabled: node.enabled,
312 is_current: node.is_current,
313 }
314 .with_short_id(short_id)
315 }
316
317 fn with_short_id(mut self, short_id: String) -> Self {
318 if !short_id.is_empty() {
319 self.label = format!("{} ({short_id})", self.label);
320 }
321 self
322 }
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum ToolReleaseSupport {
327 None,
328 Supported,
329}
330
331impl ToolReleaseSupport {
332 #[must_use]
333 pub fn from_supports_releases(supports_releases: bool) -> Self {
334 if supports_releases {
335 Self::Supported
336 } else {
337 Self::None
338 }
339 }
340
341 #[must_use]
342 pub fn is_supported(self) -> bool {
343 matches!(self, Self::Supported)
344 }
345}
346
347#[derive(Debug, Clone)]
348pub struct ToolApplyResult {
349 pub display_name: String,
350 pub applied_file_count: usize,
351 pub validation_message: Option<String>,
352}
353
354#[derive(Debug, Clone)]
355pub struct ToolRevertResult {
356 pub display_name: String,
357}
358
359#[derive(Debug, Clone)]
360pub struct ToolLoadSnapshot {
361 pub entries: Vec<ToolUiEntry>,
362 pub active_tool_id: Option<String>,
363 pub game_label: Option<String>,
364 pub game_dir_configured: bool,
365 pub tool_option_catalog: ToolOptionCatalog,
366 pub executables: Vec<ExecutableUiEntry>,
367}
368
369#[derive(Debug, Clone)]
370pub(super) struct ToolLoadRequest {
371 pub(super) game_id: String,
372 pub(super) display_name: String,
373 pub(super) configured_game_dir: Option<PathBuf>,
374 pub(super) optiscaler_releases: Vec<modde_games::tools::ToolReleaseSummary>,
375 pub(super) tool_option_catalog: ToolOptionCatalog,
376 pub(super) previous_active_tool_id: Option<String>,
377}
378
379#[derive(Debug, Clone, Default)]
380#[allow(clippy::struct_excessive_bools)]
381pub struct WabbajackInstallerState {
382 pub tab: WabbajackTab,
383 pub entries: Vec<modde_sources::wabbajack::catalog::WabbajackCatalogEntry>,
384 pub loading: bool,
385 pub error: Option<String>,
386 pub search: String,
387 pub game_filter: Option<String>,
388 pub game_filter_user_edited: bool,
389 pub official_only: bool,
390 pub include_nsfw: bool,
391 pub include_down: bool,
392 pub selected_index: Option<usize>,
393 pub manual_source: String,
394 pub hm_profile: String,
395 pub hm_game: String,
396 pub hm_game_dir: String,
397 pub hm_game_dir_user_edited: bool,
398 pub hm_snippet: String,
399 pub downloaded_path: Option<PathBuf>,
400 pub file_path: Option<PathBuf>,
401 pub progress: f32,
402 pub status: String,
403 pub log_lines: Vec<String>,
404}
405
406pub(super) fn prefill_wabbajack_game_dir(
407 settings: &AppSettings,
408 state: &mut WabbajackInstallerState,
409) {
410 if state.hm_game_dir_user_edited && !state.hm_game_dir.is_empty() {
411 return;
412 }
413 let Some(path) = settings.game_path(&GameId::from(state.hm_game.as_str())) else {
414 return;
415 };
416 state.hm_game_dir = path.display().to_string();
417}
418
419#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
420pub enum WabbajackTab {
421 #[default]
422 Catalog,
423 AuthoredFiles,
424 Manual,
425}