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 struct ToolSettingWriteResult {
371    pub status_message: String,
372    pub tool_option_catalog: Option<ToolOptionCatalog>,
373}
374
375#[derive(Debug, Clone)]
376pub(super) struct ToolLoadRequest {
377    pub(super) game_id: String,
378    pub(super) display_name: String,
379    pub(super) configured_game_dir: Option<PathBuf>,
380    pub(super) optiscaler_releases: Vec<modde_games::tools::ToolReleaseSummary>,
381    pub(super) tool_option_catalog: ToolOptionCatalog,
382    pub(super) previous_active_tool_id: Option<String>,
383}
384
385/// Owned, off-thread-computed result of (re)loading the profile + data-tab +
386/// tool context for a game/profile pair.
387///
388/// Built by `load_profile_context` on a blocking task and
389/// applied synchronously by `Modde::apply_profile_context`, so the iced event
390/// loop never blocks on the multi-query profile reload that
391/// `reload_profile`/`switch_game_context` used to perform inline.
392#[derive(Debug, Clone)]
393pub struct ProfileContextSnapshot {
394    pub profiles: Vec<modde_core::profile::ProfileSummary>,
395    pub active_profile: Option<String>,
396    pub profile_outcome: ProfileLoadOutcome,
397    pub data_tab_conflicts: Vec<(String, Vec<String>)>,
398    pub missing_store_mod_count: usize,
399    /// Folded tool snapshot. `Some` when a game is in scope (apply it via
400    /// `apply_tool_snapshot`); `None` when no game is selected (clear the tool
401    /// state, mirroring the old `refresh_tools_state` empty branch).
402    pub tools: Option<ToolLoadSnapshot>,
403    /// Re-run diagnostics after applying, when the Diagnostics view is active.
404    /// Only set by the profile-switch handler (preserves the old synchronous
405    /// `SwitchProfile` behavior).
406    pub rerun_diagnostics: bool,
407}
408
409#[derive(Debug, Clone)]
410pub struct DiagnosticsComputed {
411    pub report: crate::views::diagnostics::DiagnosticsReport,
412    pub data_tab_conflicts: Vec<(String, Vec<String>)>,
413    pub missing_store_mod_count: usize,
414}
415
416/// What happened when the loader tried to (re)load the active profile.
417#[derive(Debug, Clone)]
418pub enum ProfileLoadOutcome {
419    /// No active profile — clear `loaded_profile` and `mod_id_filter_keys`.
420    Cleared,
421    /// Profile loaded fresh from the DB.
422    Loaded {
423        profile: Box<modde_core::Profile>,
424        experiment_depth: usize,
425        current_fingerprint: Option<modde_core::save::SaveFingerprint>,
426        mod_id_filter_keys: Vec<String>,
427    },
428    /// `pm.load` failed — leave the previously-loaded profile (and its derived
429    /// fields) untouched, mirroring the old `reload_profile` behavior on a load
430    /// error.
431    KeepPrevious,
432}
433
434/// Inputs for [`super::model::load_profile_context`], built on the iced thread
435/// from `&Modde` and moved into the blocking loader.
436pub(super) struct ProfileContextRequest {
437    pub(super) selected_game: Option<String>,
438    pub(super) active_profile: Option<String>,
439    /// `true` for game switches: recompute the active profile from the DB
440    /// (active pointer, then first listed profile) instead of trusting
441    /// `active_profile`.
442    pub(super) recompute_active: bool,
443    /// The currently-loaded profile, used to recompute data-tab conflicts when
444    /// the load fails ([`ProfileLoadOutcome::KeepPrevious`]).
445    pub(super) fallback_profile: Option<modde_core::Profile>,
446    /// `Some` to fold a tool reload into the same off-thread job; `None` clears
447    /// the tool state on apply.
448    pub(super) tool_request: Option<ToolLoadRequest>,
449    pub(super) rerun_diagnostics: bool,
450}
451
452/// Off-thread-computed data-tab conflict result — the lightweight refresh that
453/// `Message::DataTabConflictsLoaded` applies when the Data tab is opened.
454#[derive(Debug, Clone)]
455pub struct DataTabConflicts {
456    pub conflicts: Vec<(String, Vec<String>)>,
457    pub missing_store_mod_count: usize,
458}
459
460#[derive(Debug, Clone, Default)]
461#[allow(clippy::struct_excessive_bools)]
462pub struct WabbajackInstallerState {
463    pub tab: WabbajackTab,
464    pub entries: Vec<modde_sources::wabbajack::catalog::WabbajackCatalogEntry>,
465    pub loading: bool,
466    pub error: Option<String>,
467    pub search: String,
468    pub game_filter: Option<String>,
469    pub game_filter_user_edited: bool,
470    pub official_only: bool,
471    pub include_nsfw: bool,
472    pub include_down: bool,
473    pub selected_index: Option<usize>,
474    pub manual_source: String,
475    pub hm_profile: String,
476    pub hm_game: String,
477    pub hm_game_dir: String,
478    pub hm_game_dir_user_edited: bool,
479    pub hm_snippet: String,
480    pub downloaded_path: Option<PathBuf>,
481    pub file_path: Option<PathBuf>,
482    pub progress: f32,
483    pub status: String,
484    pub log_lines: Vec<String>,
485}
486
487pub(super) fn prefill_wabbajack_game_dir(
488    settings: &AppSettings,
489    state: &mut WabbajackInstallerState,
490) {
491    if state.hm_game_dir_user_edited && !state.hm_game_dir.is_empty() {
492        return;
493    }
494    let Some(path) = settings.game_path(&GameId::from(state.hm_game.as_str())) else {
495        return;
496    };
497    state.hm_game_dir = path.display().to_string();
498}
499
500#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
501pub enum WabbajackTab {
502    #[default]
503    Catalog,
504    AuthoredFiles,
505    Manual,
506}