Skip to main content

modde_ui/app/
state.rs

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
9/// Direction for `Message::ReorderMod`. Re-export of the core type so
10/// view and message construction sites don't have to import from
11/// `modde_core::profile` directly. The enforcement logic itself lives in
12/// `modde_core::profile::try_reorder` and is the single source of truth.
13pub use modde_core::profile::ReorderDirection;
14
15/// Render a `LockReason` as a short, human-readable phrase for status
16/// messages and UI banners. Centralised so `lock_reason` strings stay
17/// consistent across the handler, `load_order` banner, and `mod_list` row.
18pub(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/// Which view is currently displayed.
30#[derive(Debug, Clone)]
31pub enum View {
32    ModList,
33    Collections,
34    /// Unified Nexus browse surface — Top / Month / Collections / Search.
35    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}