modde-ui 0.2.1

GUI application for modde
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;

use modde_core::resolver::GameId;
use modde_core::settings::AppSettings;

use super::{FOMODWizardState, ToolOptionCatalog};

/// Direction for `Message::ReorderMod`. Re-export of the core type so
/// view and message construction sites don't have to import from
/// `modde_core::profile` directly. The enforcement logic itself lives in
/// `modde_core::profile::try_reorder` and is the single source of truth.
pub use modde_core::profile::ReorderDirection;

/// Render a `LockReason` as a short, human-readable phrase for status
/// messages and UI banners. Centralised so `lock_reason` strings stay
/// consistent across the handler, `load_order` banner, and `mod_list` row.
pub(crate) fn format_lock_reason(reason: &modde_core::LockReason) -> String {
    use modde_core::LockReason::{Manual, NexusCollection, TomlImport, Wabbajack};
    match reason {
        Wabbajack { manifest_hash } => format!("Wabbajack (hash {manifest_hash})"),
        NexusCollection { slug, version } => format!("Nexus Collection '{slug}' v{version}"),
        TomlImport { source_path } => format!("TOML import from {source_path}"),
        Manual { note: Some(n) } => format!("manual ({n})"),
        Manual { note: None } => "manual".to_string(),
    }
}

/// Which view is currently displayed.
#[derive(Debug, Clone)]
pub enum View {
    ModList,
    Collections,
    /// Unified Nexus browse surface — Top / Month / Collections / Search.
    BrowseNexus,
    WabbajackInstaller(WabbajackInstallerState),
    FOMODWizard(FOMODWizardState),
    Settings,
    Saves,
    Downloads,
    DataTab,
    Diagnostics,
    Tools,
    Executables,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SidebarGroup {
    Game,
    Install,
    General,
}

impl SidebarGroup {
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            SidebarGroup::Game => "Game",
            SidebarGroup::Install => "Install",
            SidebarGroup::General => "General",
        }
    }
}

#[derive(Debug, Clone, Default)]
#[allow(clippy::struct_excessive_bools)]
pub struct ToolState {
    pub entries: Vec<ToolUiEntry>,
    pub active_tool_id: Option<String>,
    pub game_label: Option<String>,
    pub game_dir_configured: bool,
    pub loading: bool,
    pub load_error: Option<String>,
    pub load_generation: u64,
    pub show_advanced_settings: bool,
    pub active_operations: HashSet<String>,
    pub tool_option_catalog: ToolOptionCatalog,
    pub optiscaler_releases: Vec<modde_games::tools::ToolReleaseSummary>,
    pub optiscaler_releases_loading: bool,
    pub proton_versions_loading: bool,
    pub executables: Vec<ExecutableUiEntry>,
    pub executables_loading: bool,
    pub executables_load_error: Option<String>,
    pub executables_load_generation: u64,
    pub executable_draft: ExecutableDraft,
    pub executable_editor_open: bool,
    pub executable_error: Option<String>,
    pub active_executable_operations: HashSet<String>,
}

impl ToolState {
    #[must_use]
    pub fn is_tool_busy(&self, tool_id: &str) -> bool {
        self.active_operations.contains(tool_id)
    }

    #[must_use]
    pub fn is_executable_busy(&self, name: &str) -> bool {
        self.active_executable_operations.contains(name)
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExecutableUiEntry {
    pub name: String,
    pub executable_path: String,
    pub arguments: String,
    pub working_dir: String,
    pub environment: String,
    pub wine_dll_overrides: String,
    pub output_mod: String,
    pub enabled: bool,
}

impl ExecutableUiEntry {
    pub(super) fn from_row(row: modde_core::db::ExecutableConfigRow) -> Self {
        let args = serde_json::from_str::<Vec<String>>(&row.arguments_json).unwrap_or_default();
        let env = serde_json::from_str::<HashMap<String, String>>(&row.environment_json)
            .unwrap_or_default();
        let mut env_lines = env
            .into_iter()
            .map(|(key, value)| format!("{key}={value}"))
            .collect::<Vec<_>>();
        env_lines.sort();
        Self {
            name: row.name,
            executable_path: row.executable_path.display().to_string(),
            arguments: args.join(" "),
            working_dir: row
                .working_dir
                .map(|path| path.display().to_string())
                .unwrap_or_default(),
            environment: env_lines.join("\n"),
            wine_dll_overrides: row.wine_dll_overrides.unwrap_or_default(),
            output_mod: row.output_mod,
            enabled: row.enabled,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutableDraft {
    pub name: String,
    pub executable_path: String,
    pub arguments: String,
    pub working_dir: String,
    pub environment: String,
    pub wine_dll_overrides: String,
    pub output_mod: String,
}

impl Default for ExecutableDraft {
    fn default() -> Self {
        Self {
            name: String::new(),
            executable_path: String::new(),
            arguments: String::new(),
            working_dir: String::new(),
            environment: String::new(),
            wine_dll_overrides: String::new(),
            output_mod: "__overwrite__".to_string(),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutableDraftField {
    Name,
    Path,
    Arguments,
    WorkingDir,
    Environment,
    WineDllOverrides,
    OutputMod,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AddCustomGameDraft {
    pub id: String,
    pub display_name: String,
    pub install_path: String,
    pub executable_dir: Option<String>,
    pub steam_app_id: Option<String>,
    pub nexus_domain: Option<String>,
    pub proxy_dlls_csv: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AddCustomGameState {
    pub draft: AddCustomGameDraft,
    pub detected_dirs: Vec<modde_games::DetectCandidateDir>,
    pub error: Option<String>,
}

impl AddCustomGameState {
    #[must_use]
    pub fn can_submit(&self) -> bool {
        self.build_spec().is_ok()
    }

    pub fn build_spec(&self) -> Result<modde_games::generic::spec::GameSpec, String> {
        let install_path = PathBuf::from(self.draft.install_path.trim());
        if self.draft.id.trim().is_empty()
            || self.draft.display_name.trim().is_empty()
            || self.draft.executable_dir.is_none()
            || self.draft.install_path.trim().is_empty()
        {
            return Err("Fill in the required custom game fields.".to_string());
        }
        if !install_path.is_dir() {
            return Err(format!(
                "Install path does not exist: {}",
                install_path.display()
            ));
        }

        let spec = modde_games::generic::spec::GameSpec {
            id: self.draft.id.trim().to_string(),
            display_name: self.draft.display_name.trim().to_string(),
            steam_app_id: empty_to_none(self.draft.steam_app_id.as_deref()),
            install_dir_name: None,
            install_path_override: None,
            executable_dir: PathBuf::from(
                self.draft
                    .executable_dir
                    .as_deref()
                    .unwrap_or_default()
                    .trim(),
            ),
            mod_dir: None,
            nexus_domain: empty_to_none(self.draft.nexus_domain.as_deref()),
            proxy_dlls: self
                .draft
                .proxy_dlls_csv
                .split(',')
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(str::to_string)
                .collect(),
        };

        spec.validate().map_err(|error| error.to_string())?;
        Ok(spec)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddCustomGameDraftField {
    Id,
    DisplayName,
    InstallPath,
    ExecutableDir,
    SteamAppId,
    NexusDomain,
    ProxyDlls,
}

pub(super) fn empty_to_none(value: Option<&str>) -> Option<String> {
    value
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
}

#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct ToolUiEntry {
    pub tool_id: String,
    pub display_name: String,
    pub description: String,
    pub category: String,
    pub available: bool,
    pub availability_text: String,
    pub enabled: bool,
    pub settings: serde_json::Value,
    pub setting_specs: Vec<modde_games::tools::ToolSettingSpec>,
    pub generated_config_path: Option<String>,
    pub applied_files: Vec<String>,
    pub has_file_patching: bool,
    pub release_support: ToolReleaseSupport,
    pub status_message: Option<String>,
    pub env_preview: Vec<(String, String)>,
    pub dll_overrides: Vec<String>,
    pub wrapper_preview: Vec<String>,
    pub derived_facts: Vec<(String, String)>,
    pub optiscaler_state: Option<String>,
    pub optiscaler_latest_backup: Option<String>,
    pub optiscaler_detected_files: usize,
    pub apply_pending: bool,
    pub apply_missing_inputs: Vec<String>,
    pub setting_history: Vec<ToolHistoryUiEntry>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolHistoryUiEntry {
    pub node_id: String,
    pub label: String,
    pub reason: String,
    pub enabled: bool,
    pub is_current: bool,
}

impl ToolHistoryUiEntry {
    pub(super) fn from_node(node: modde_core::db::ToolSettingHistoryNode) -> Self {
        let short_id = node.node_id.chars().take(18).collect::<String>();
        let state = if node.enabled { "enabled" } else { "disabled" };
        Self {
            node_id: node.node_id,
            label: format!("{} - {state}", node.created_at),
            reason: node.reason,
            enabled: node.enabled,
            is_current: node.is_current,
        }
        .with_short_id(short_id)
    }

    fn with_short_id(mut self, short_id: String) -> Self {
        if !short_id.is_empty() {
            self.label = format!("{} ({short_id})", self.label);
        }
        self
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolReleaseSupport {
    None,
    Supported,
}

impl ToolReleaseSupport {
    #[must_use]
    pub fn from_supports_releases(supports_releases: bool) -> Self {
        if supports_releases {
            Self::Supported
        } else {
            Self::None
        }
    }

    #[must_use]
    pub fn is_supported(self) -> bool {
        matches!(self, Self::Supported)
    }
}

#[derive(Debug, Clone)]
pub struct ToolApplyResult {
    pub display_name: String,
    pub applied_file_count: usize,
    pub validation_message: Option<String>,
}

#[derive(Debug, Clone)]
pub struct ToolRevertResult {
    pub display_name: String,
}

#[derive(Debug, Clone)]
pub struct ToolLoadSnapshot {
    pub entries: Vec<ToolUiEntry>,
    pub active_tool_id: Option<String>,
    pub game_label: Option<String>,
    pub game_dir_configured: bool,
    pub tool_option_catalog: ToolOptionCatalog,
    pub executables: Vec<ExecutableUiEntry>,
}

#[derive(Debug, Clone)]
pub(super) struct ToolLoadRequest {
    pub(super) game_id: String,
    pub(super) display_name: String,
    pub(super) configured_game_dir: Option<PathBuf>,
    pub(super) optiscaler_releases: Vec<modde_games::tools::ToolReleaseSummary>,
    pub(super) tool_option_catalog: ToolOptionCatalog,
    pub(super) previous_active_tool_id: Option<String>,
}

#[derive(Debug, Clone, Default)]
#[allow(clippy::struct_excessive_bools)]
pub struct WabbajackInstallerState {
    pub tab: WabbajackTab,
    pub entries: Vec<modde_sources::wabbajack::catalog::WabbajackCatalogEntry>,
    pub loading: bool,
    pub error: Option<String>,
    pub search: String,
    pub game_filter: Option<String>,
    pub game_filter_user_edited: bool,
    pub official_only: bool,
    pub include_nsfw: bool,
    pub include_down: bool,
    pub selected_index: Option<usize>,
    pub manual_source: String,
    pub hm_profile: String,
    pub hm_game: String,
    pub hm_game_dir: String,
    pub hm_game_dir_user_edited: bool,
    pub hm_snippet: String,
    pub downloaded_path: Option<PathBuf>,
    pub file_path: Option<PathBuf>,
    pub progress: f32,
    pub status: String,
    pub log_lines: Vec<String>,
}

pub(super) fn prefill_wabbajack_game_dir(
    settings: &AppSettings,
    state: &mut WabbajackInstallerState,
) {
    if state.hm_game_dir_user_edited && !state.hm_game_dir.is_empty() {
        return;
    }
    let Some(path) = settings.game_path(&GameId::from(state.hm_game.as_str())) else {
        return;
    };
    state.hm_game_dir = path.display().to_string();
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum WabbajackTab {
    #[default]
    Catalog,
    AuthoredFiles,
    Manual,
}