From 46085b4708508c9044e0050ddf6daac1d705b85d Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:30:37 +0200 Subject: [PATCH 01/77] Add vscode / gitattributes settings --- .editorconfig | 14 ++++++++++++++ .gitattributes | 1 + .vscode/extensions.json | 7 +++++++ .vscode/settings.json | 3 +++ Actions/LaunchManager.cs | 6 +++--- MulderConfig.csproj | 2 +- Program.cs | 2 +- UI/Form1.Designer.cs | 20 ++++++++++---------- UI/Form1.resx | 2 +- tests/Actions/LaunchManagerTests.cs | 4 ++-- 10 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ddfa61f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +# Default for all files +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +# Markdown: preserve trailing spaces (used for line breaks) +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..6b79f99 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "rust-lang.rust-analyzer", + "vadimcn.vscode-lldb" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8582900 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.eol": "\n" +} diff --git a/Actions/LaunchManager.cs b/Actions/LaunchManager.cs index 14601fa..c30844a 100644 --- a/Actions/LaunchManager.cs +++ b/Actions/LaunchManager.cs @@ -31,9 +31,9 @@ public void Launch() }; process.Start(); - if (wait) - { - process.WaitForExit(); + if (wait) + { + process.WaitForExit(); } } diff --git a/MulderConfig.csproj b/MulderConfig.csproj index 5374919..7d4b5da 100644 --- a/MulderConfig.csproj +++ b/MulderConfig.csproj @@ -21,4 +21,4 @@ \ - \ No newline at end of file + diff --git a/Program.cs b/Program.cs index efc95ed..09b25fa 100644 --- a/Program.cs +++ b/Program.cs @@ -20,7 +20,7 @@ static void Main(string[] args) try { config = ConfigProvider.GetConfig(); - } + } catch (Exception ex) { MessageBox.Show($"Error loading configuration:\n{ex.Message}"); diff --git a/UI/Form1.Designer.cs b/UI/Form1.Designer.cs index 66986ac..785fd0c 100644 --- a/UI/Form1.Designer.cs +++ b/UI/Form1.Designer.cs @@ -22,9 +22,9 @@ private void InitializeComponent() btnApply = new Button(); btnSave = new Button(); SuspendLayout(); - // + // // comboBoxTitle - // + // comboBoxTitle.BackColor = Color.FromArgb(89, 101, 119); comboBoxTitle.Font = new Font("Segoe UI", 9F); comboBoxTitle.ForeColor = SystemColors.HighlightText; @@ -34,9 +34,9 @@ private void InitializeComponent() comboBoxTitle.Size = new Size(418, 23); comboBoxTitle.TabIndex = 0; comboBoxTitle.SelectedIndexChanged += comboBoxTitle_SelectedIndexChanged; - // + // // panelOptions - // + // panelOptions.AutoSize = true; panelOptions.BorderStyle = BorderStyle.FixedSingle; panelOptions.ForeColor = SystemColors.Control; @@ -45,9 +45,9 @@ private void InitializeComponent() panelOptions.Padding = new Padding(20); panelOptions.Size = new Size(600, 60); panelOptions.TabIndex = 1; - // + // // btnApply - // + // btnApply.Location = new Point(537, 12); btnApply.Name = "btnApply"; btnApply.Size = new Size(75, 23); @@ -55,9 +55,9 @@ private void InitializeComponent() btnApply.Text = "Apply"; btnApply.UseVisualStyleBackColor = true; btnApply.Click += btnApply_Click; - // + // // btnSave - // + // btnSave.Location = new Point(436, 12); btnSave.Name = "btnSave"; btnSave.Size = new Size(95, 23); @@ -65,9 +65,9 @@ private void InitializeComponent() btnSave.Text = "Save Config"; btnSave.UseVisualStyleBackColor = true; btnSave.Click += btnSave_Click; - // + // // Form1 - // + // AutoScaleDimensions = new SizeF(7F, 15F); AutoScaleMode = AutoScaleMode.Font; AutoSize = true; diff --git a/UI/Form1.resx b/UI/Form1.resx index 4edf53f..cae2060 100644 --- a/UI/Form1.resx +++ b/UI/Form1.resx @@ -117,4 +117,4 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - \ No newline at end of file + diff --git a/tests/Actions/LaunchManagerTests.cs b/tests/Actions/LaunchManagerTests.cs index 63306e8..50d9570 100644 --- a/tests/Actions/LaunchManagerTests.cs +++ b/tests/Actions/LaunchManagerTests.cs @@ -39,8 +39,8 @@ public void ResolveLaunch_ReturnsDefaults_WhenNoRuleMatches() var manager = new LaunchManager( config, title: "default", - choices: new Dictionary { ["Renderer"] = "DX11" }); - + choices: new Dictionary { ["Renderer"] = "DX11" }); + var (exePath, workDir, _, args) = manager.ResolveLaunch(); Assert.Equal(System.IO.Path.Combine(System.Windows.Forms.Application.StartupPath, "Game.exe"), exePath); From 949e76afd9b37bcc6ebae22d20cfa339176a62cc Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:37:09 +0200 Subject: [PATCH 02/77] cargo new --- .gitignore | 2 ++ Cargo.lock | 7 +++++++ Cargo.toml | 10 ++++++++++ src/main.rs | 3 +++ 4 files changed, 22 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore index 47a94ef..7684b67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/target + ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..96499dd --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "mulder-config" +version = "2.0.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6cf4602 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "mulder-config" +version = "2.0.0" +edition = "2024" + +[[bin]] +name = "MulderConfig" +path = "src/main.rs" + +[dependencies] diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} From dba5daf8ba30e951edbbf8dce2fdd8c7848382e6 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:18:16 +0200 Subject: [PATCH 03/77] cargo add serde --features derive; cargo add serde_json --- Cargo.lock | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 ++ 2 files changed, 102 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 96499dd..d993aed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,106 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "mulder-config" version = "2.0.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 6cf4602..0795598 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,5 @@ name = "MulderConfig" path = "src/main.rs" [dependencies] +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" From 02a1c72ce3932e378cb93b0229558ed9ce1a2953 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:21:27 +0200 Subject: [PATCH 04/77] Add config modules + read title --- src/config.rs | 3 ++ src/config/loader.rs | 23 ++++++++++ src/config/model.rs | 96 +++++++++++++++++++++++++++++++++++++++++ src/config/validator.rs | 66 ++++++++++++++++++++++++++++ src/main.rs | 8 +++- 5 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 src/config.rs create mode 100644 src/config/loader.rs create mode 100644 src/config/model.rs create mode 100644 src/config/validator.rs diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8ab3b7e --- /dev/null +++ b/src/config.rs @@ -0,0 +1,3 @@ +pub mod loader; +pub mod model; +pub mod validator; diff --git a/src/config/loader.rs b/src/config/loader.rs new file mode 100644 index 0000000..786f8d8 --- /dev/null +++ b/src/config/loader.rs @@ -0,0 +1,23 @@ +use std::fs; + +use crate::config::model::ConfigModel; +use crate::config::validator::ConfigValidator; + +pub struct ConfigLoader; + +impl ConfigLoader { + pub fn load(path: &str) -> Result { + // Read file + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read config file: {e}"))?; + + // Parse JSON + let config: ConfigModel = + serde_json::from_str(&content).map_err(|e| format!("Invalid JSON: {e}"))?; + + // Validate + ConfigValidator::validate(&config)?; + + Ok(config) + } +} diff --git a/src/config/model.rs b/src/config/model.rs new file mode 100644 index 0000000..c2944db --- /dev/null +++ b/src/config/model.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigModel { + pub game: Game, + pub addons: Option>, + pub option_groups: Vec, + pub actions: ActionRoot, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Game { + pub title: String, + pub original_exe: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Addon { + pub title: String, + pub steam_id: u32, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum OptionGroupType { + RadioGroup, + CheckboxGroup, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OptionGroup { + pub name: String, + + #[serde(alias = "type")] + pub kind: OptionGroupType, + + pub radios: Option>, + pub checkboxes: Option>, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Radio { + pub value: String, + pub disabled_when: Option>, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Checkbox { + pub value: String, + pub disabled_when: Option>, +} + +pub type WhenGroup = HashMap; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionRoot { + pub launch: Vec, + pub operations: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchAction { + pub when: Option>, + pub exec: Option, + pub args: Option>, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecSpec { + pub name: String, + pub work_dir: String, + pub wait: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OperationAction { + pub when: Option>, + pub operation: String, + pub source: Option, + pub target: Option, + pub files: Option>, + pub pattern: Option, + pub search: Option, + pub replacement: Option, +} diff --git a/src/config/validator.rs b/src/config/validator.rs new file mode 100644 index 0000000..f27e255 --- /dev/null +++ b/src/config/validator.rs @@ -0,0 +1,66 @@ +use crate::config::model::{ConfigModel, OptionGroupType}; +use std::collections::HashSet; + +pub struct ConfigValidator; + +impl ConfigValidator { + pub fn validate(config: &ConfigModel) -> Result<(), String> { + // GAME + if config.game.title.trim().is_empty() { + return Err("Game title is empty".into()); + } + + if config.game.original_exe.trim().is_empty() { + return Err("OriginalExe is empty".into()); + } + + // OPTION GROUPS + let mut names = HashSet::new(); + + for g in &config.option_groups { + if g.name.trim().is_empty() { + return Err("OptionGroup name is empty".into()); + } + + if !names.insert(g.name.to_lowercase()) { + return Err("Duplicate OptionGroup name".into()); + } + + match g.kind { + OptionGroupType::RadioGroup => { + let radios = g.radios.as_ref().ok_or("RadioGroup must have radios")?; + + if radios.is_empty() { + return Err("RadioGroup cannot be empty".into()); + } + + if g.checkboxes.is_some() { + return Err("RadioGroup cannot have checkboxes".into()); + } + } + + OptionGroupType::CheckboxGroup => { + let checkboxes = g + .checkboxes + .as_ref() + .ok_or("CheckboxGroup must have checkboxes")?; + + if checkboxes.is_empty() { + return Err("CheckboxGroup cannot be empty".into()); + } + + if g.radios.is_some() { + return Err("CheckboxGroup cannot have radios".into()); + } + } + } + } + + // ACTIONS + if config.actions.launch.is_empty() && config.actions.operations.is_empty() { + return Err("At least one action is required".into()); + } + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..21c7a13 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,9 @@ +mod config; + +use config::loader::ConfigLoader; + fn main() { - println!("Hello, world!"); + let config = ConfigLoader::load("MulderConfig.json").expect("Failed to load config"); + + println!("Game: {}", config.game.title); } From 5a57a061a51c2af00e7f95956b24f785eba6589a Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:47:24 +0200 Subject: [PATCH 05/77] Add Mode Detector --- src/main.rs | 10 +++++++++- src/mode_detector.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/mode_detector.rs diff --git a/src/main.rs b/src/main.rs index 21c7a13..8d0524b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,17 @@ mod config; +mod mode_detector; use config::loader::ConfigLoader; +use mode_detector::{detect_mode, Mode}; fn main() { + let config = ConfigLoader::load("MulderConfig.json").expect("Failed to load config"); + let mode = detect_mode(); - println!("Game: {}", config.game.title); + match mode { + Mode::Config => { println!("Open Config UI for Game: {}", config.game.title); } + Mode::Apply => { println!("Applying conf for Game: {}", config.game.title); } + Mode::Launch => { println!("Launch Game: {}", config.game.title); } + } } diff --git a/src/mode_detector.rs b/src/mode_detector.rs new file mode 100644 index 0000000..54b5b25 --- /dev/null +++ b/src/mode_detector.rs @@ -0,0 +1,34 @@ +use std::env; + +#[derive(Debug, PartialEq)] +pub enum Mode { + Config, + Apply, + Launch, +} + +pub fn detect_mode() -> Mode { + let args: Vec = env::args().collect(); + + let exe_name = std::env::current_exe() + .ok() + .and_then(|p| p.file_name().and_then(|n| n.to_str()).map(|s| s.to_string())) + .unwrap_or_default(); + + let is_mulderconfig_exe = exe_name.eq_ignore_ascii_case("MulderConfig.exe"); + if is_mulderconfig_exe { + let has_apply_flag = args.iter().any(|a| a == "-apply"); + return if has_apply_flag { + Mode::Apply + } else { + Mode::Config + }; + } + + let has_config_flag = args.iter().any(|a| a == "-MulderConfig"); + return if has_config_flag { + Mode::Config + } else { + Mode::Launch + }; +} From 8189c7f58d0b5d1b2aed16dc4de8eefc4fac7fc0 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:54:38 +0200 Subject: [PATCH 06/77] Add windows-sys lib + hello world --- Cargo.lock | 16 ++++++++++++++++ Cargo.toml | 1 + src/main.rs | 3 ++- src/ui.rs | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/ui.rs diff --git a/Cargo.lock b/Cargo.lock index d993aed..4e6c683 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,7 @@ version = "2.0.0" dependencies = [ "serde", "serde_json", + "windows-sys", ] [[package]] @@ -100,6 +101,21 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 0795598..d9e7062 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,4 @@ path = "src/main.rs" [dependencies] serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +windows-sys = { version = "0.61.2", features = ["Win32_UI_WindowsAndMessaging", "Win32_UI_Shell"] } diff --git a/src/main.rs b/src/main.rs index 8d0524b..13f6ed7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod config; mod mode_detector; +mod ui; use config::loader::ConfigLoader; use mode_detector::{detect_mode, Mode}; @@ -10,7 +11,7 @@ fn main() { let mode = detect_mode(); match mode { - Mode::Config => { println!("Open Config UI for Game: {}", config.game.title); } + Mode::Config => { ui::run(); } Mode::Apply => { println!("Applying conf for Game: {}", config.game.title); } Mode::Launch => { println!("Launch Game: {}", config.game.title); } } diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..5a16336 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,15 @@ +use windows_sys::{core::*, Win32::UI::Shell::*, Win32::UI::WindowsAndMessaging::*}; + +pub fn run() { + unsafe { + MessageBoxA(core::ptr::null_mut(), s!("Ansi"), s!("World"), MB_OK); + + ShellMessageBoxW( + core::ptr::null_mut(), + core::ptr::null_mut(), + w!("Wide"), + w!("World"), + MB_ICONERROR, + ); + } +} From e8c7a3b4c6487073b6e9a12f1aca519d457f7e6a Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:14:43 +0200 Subject: [PATCH 07/77] Hide terminal window (release) --- src/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 13f6ed7..a30b517 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + mod config; mod mode_detector; mod ui; @@ -11,7 +13,7 @@ fn main() { let mode = detect_mode(); match mode { - Mode::Config => { ui::run(); } + Mode::Config => { ui::run(config); } Mode::Apply => { println!("Applying conf for Game: {}", config.game.title); } Mode::Launch => { println!("Launch Game: {}", config.game.title); } } From 98a9cc3c4abdfb2202ff094160a7bfd74cea6c5a Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:35:27 +0200 Subject: [PATCH 08/77] Allow launch/operations not set --- src/config/model.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config/model.rs b/src/config/model.rs index c2944db..14963d4 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -62,7 +62,9 @@ pub type WhenGroup = HashMap; #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ActionRoot { + #[serde(default)] pub launch: Vec, + #[serde(default)] pub operations: Vec, } From 379edca11e7180a1f737c086810f62bb406d2b31 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:42:56 +0200 Subject: [PATCH 09/77] First working UI --- Cargo.toml | 2 +- src/ui.rs | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 174 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d9e7062..3a8ef93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,4 +10,4 @@ path = "src/main.rs" [dependencies] serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -windows-sys = { version = "0.61.2", features = ["Win32_UI_WindowsAndMessaging", "Win32_UI_Shell"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_System_LibraryLoader"] } diff --git a/src/ui.rs b/src/ui.rs index 5a16336..5c51a40 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,15 +1,178 @@ -use windows_sys::{core::*, Win32::UI::Shell::*, Win32::UI::WindowsAndMessaging::*}; +use windows_sys::{ + Win32::Foundation::*, + Win32::Graphics::Gdi::*, + Win32::System::LibraryLoader::*, + Win32::UI::WindowsAndMessaging::*, +}; -pub fn run() { +use crate::config::model::{ConfigModel, OptionGroup, OptionGroupType}; + +// ------------------------- +// Layout constants +// ------------------------- +const WIN_W: i32 = 620; +const MARGIN: i32 = 10; +const GROUP_TITLE_H: i32 = 22; +const ITEM_H: i32 = 24; +const GROUP_PAD_BOTTOM: i32 = 8; +const GROUP_GAP: i32 = 8; + +fn to_wstring(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +fn item_count(group: &OptionGroup) -> usize { + match &group.kind { + OptionGroupType::RadioGroup => group.radios.as_ref().map_or(0, |v| v.len()), + OptionGroupType::CheckboxGroup => group.checkboxes.as_ref().map_or(0, |v| v.len()), + } +} + +fn group_items(group: &OptionGroup) -> Vec<&str> { + match &group.kind { + OptionGroupType::RadioGroup => group + .radios + .as_ref() + .map_or(vec![], |v| v.iter().map(|r| r.value.as_str()).collect()), + OptionGroupType::CheckboxGroup => group + .checkboxes + .as_ref() + .map_or(vec![], |v| v.iter().map(|c| c.value.as_str()).collect()), + } +} + +pub fn run(config: ConfigModel) { unsafe { - MessageBoxA(core::ptr::null_mut(), s!("Ansi"), s!("World"), MB_OK); - - ShellMessageBoxW( - core::ptr::null_mut(), - core::ptr::null_mut(), - w!("Wide"), - w!("World"), - MB_ICONERROR, + let h_instance = GetModuleHandleW(std::ptr::null()); + + // ------------------------- + // Register window class + // ------------------------- + let class_name = to_wstring("MulderConfigWnd"); + let wc = WNDCLASSW { + lpfnWndProc: Some(window_proc), + hInstance: h_instance, + lpszClassName: class_name.as_ptr(), + hbrBackground: (COLOR_WINDOW + 1) as HBRUSH, + style: CS_HREDRAW | CS_VREDRAW, + ..std::mem::zeroed() + }; + RegisterClassW(&wc); + + // ------------------------- + // Compute client height + // ------------------------- + let mut client_h = MARGIN; + for group in &config.option_groups { + let n = item_count(group) as i32; + client_h += GROUP_TITLE_H + n * ITEM_H + GROUP_PAD_BOTTOM + GROUP_GAP; + } + client_h += MARGIN; + + // ------------------------- + // Adjust for window chrome + // ------------------------- + let style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; + let mut rect = RECT { left: 0, top: 0, right: WIN_W, bottom: client_h }; + AdjustWindowRect(&mut rect, style, 0); + let win_w = rect.right - rect.left; + let win_h = rect.bottom - rect.top; + + // ------------------------- + // Center on screen + // ------------------------- + let x = (GetSystemMetrics(SM_CXSCREEN) - win_w) / 2; + let y = ((GetSystemMetrics(SM_CYSCREEN) - win_h) / 2).max(0); + + // ------------------------- + // Create main window + // ------------------------- + let title = to_wstring(&config.game.title); + let hwnd = CreateWindowExW( + 0, + class_name.as_ptr(), + title.as_ptr(), + style | WS_VISIBLE, + x, y, win_w, win_h, + std::ptr::null_mut(), std::ptr::null_mut(), h_instance, std::ptr::null(), ); + + // ------------------------- + // Build controls + // ------------------------- + let font = GetStockObject(DEFAULT_GUI_FONT) as usize; + let btn_class = to_wstring("BUTTON"); + let panel_w = WIN_W - MARGIN * 2; + let mut y_pos = MARGIN; + + for group in &config.option_groups { + let items = group_items(group); + let group_h = GROUP_TITLE_H + items.len() as i32 * ITEM_H + GROUP_PAD_BOTTOM; + + // GroupBox frame + let group_label = to_wstring(&group.name); + let hwnd_grp = CreateWindowExW( + 0, + btn_class.as_ptr(), + group_label.as_ptr(), + WS_CHILD | WS_VISIBLE | BS_GROUPBOX as u32, + MARGIN, y_pos, panel_w, group_h, + hwnd, std::ptr::null_mut(), h_instance, std::ptr::null(), + ); + SendMessageW(hwnd_grp, WM_SETFONT, font, 1); + + // Radio buttons or checkboxes + let mut inner_y = GROUP_TITLE_H; + for (i, value) in items.iter().enumerate() { + let btn_style = match &group.kind { + OptionGroupType::RadioGroup => { + let mut s = WS_CHILD | WS_VISIBLE | BS_AUTORADIOBUTTON as u32; + if i == 0 { s |= WS_GROUP; } + s + } + OptionGroupType::CheckboxGroup => WS_CHILD | WS_VISIBLE | BS_AUTOCHECKBOX as u32, + }; + + let item_label = to_wstring(value); + let hwnd_item = CreateWindowExW( + 0, + btn_class.as_ptr(), + item_label.as_ptr(), + btn_style, + MARGIN + 5, inner_y, panel_w - MARGIN * 2 - 10, ITEM_H, + hwnd_grp, std::ptr::null_mut(), h_instance, std::ptr::null(), + ); + SendMessageW(hwnd_item, WM_SETFONT, font, 1); + inner_y += ITEM_H; + } + + y_pos += group_h + GROUP_GAP; + } + + // ------------------------- + // Message loop + // ------------------------- + let mut msg: MSG = std::mem::zeroed(); + while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } +} + +unsafe extern "system" fn window_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + unsafe { + match msg { + WM_DESTROY => { + PostQuitMessage(0); + 0 + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } } } From 9ff10dfe9402ff9e0f2fdb622f5b56d5cea13693 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:51:08 +0200 Subject: [PATCH 10/77] Add toolbar --- src/ui.rs | 94 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 72 insertions(+), 22 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 5c51a40..d229d2f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -11,12 +11,22 @@ use crate::config::model::{ConfigModel, OptionGroup, OptionGroupType}; // Layout constants // ------------------------- const WIN_W: i32 = 620; -const MARGIN: i32 = 10; +const MARGIN: i32 = 12; +const CTRL_H: i32 = 23; // combobox / button height +const TOOLBAR_H: i32 = 47; // MARGIN + CTRL_H + MARGIN +const BTN_APPLY_W: i32 = 80; +const BTN_SAVE_W: i32 = 95; const GROUP_TITLE_H: i32 = 22; const ITEM_H: i32 = 24; const GROUP_PAD_BOTTOM: i32 = 8; const GROUP_GAP: i32 = 8; +// Toolbar positions (right-aligned) +const BTN_APPLY_X: i32 = WIN_W - MARGIN - BTN_APPLY_W; +const BTN_SAVE_X: i32 = BTN_APPLY_X - 5 - BTN_SAVE_W; +const COMBO_X: i32 = MARGIN; +const COMBO_W: i32 = BTN_SAVE_X - 5 - MARGIN; + fn to_wstring(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } @@ -45,9 +55,7 @@ pub fn run(config: ConfigModel) { unsafe { let h_instance = GetModuleHandleW(std::ptr::null()); - // ------------------------- // Register window class - // ------------------------- let class_name = to_wstring("MulderConfigWnd"); let wc = WNDCLASSW { lpfnWndProc: Some(window_proc), @@ -59,34 +67,27 @@ pub fn run(config: ConfigModel) { }; RegisterClassW(&wc); - // ------------------------- // Compute client height - // ------------------------- - let mut client_h = MARGIN; + let mut content_h = MARGIN; for group in &config.option_groups { let n = item_count(group) as i32; - client_h += GROUP_TITLE_H + n * ITEM_H + GROUP_PAD_BOTTOM + GROUP_GAP; + content_h += GROUP_TITLE_H + n * ITEM_H + GROUP_PAD_BOTTOM + GROUP_GAP; } - client_h += MARGIN; + content_h += MARGIN; + let client_h = TOOLBAR_H + content_h; - // ------------------------- // Adjust for window chrome - // ------------------------- let style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; let mut rect = RECT { left: 0, top: 0, right: WIN_W, bottom: client_h }; AdjustWindowRect(&mut rect, style, 0); let win_w = rect.right - rect.left; let win_h = rect.bottom - rect.top; - // ------------------------- // Center on screen - // ------------------------- let x = (GetSystemMetrics(SM_CXSCREEN) - win_w) / 2; let y = ((GetSystemMetrics(SM_CYSCREEN) - win_h) / 2).max(0); - // ------------------------- // Create main window - // ------------------------- let title = to_wstring(&config.game.title); let hwnd = CreateWindowExW( 0, @@ -97,19 +98,71 @@ pub fn run(config: ConfigModel) { std::ptr::null_mut(), std::ptr::null_mut(), h_instance, std::ptr::null(), ); - // ------------------------- - // Build controls - // ------------------------- let font = GetStockObject(DEFAULT_GUI_FONT) as usize; let btn_class = to_wstring("BUTTON"); + let combo_class = to_wstring("COMBOBOX"); + + // ------------------------- + // Toolbar — ComboBox + // ------------------------- + let hwnd_combo = CreateWindowExW( + 0, + combo_class.as_ptr(), + std::ptr::null(), + WS_CHILD | WS_VISIBLE | WS_VSCROLL | CBS_DROPDOWNLIST as u32, + COMBO_X, MARGIN + 2, COMBO_W, 200, // height 200 = taille du dropdown ouvert; +1 aligns visually with buttons + hwnd, std::ptr::null_mut(), h_instance, std::ptr::null(), + ); + SendMessageW(hwnd_combo, WM_SETFONT, font, 1); + + let game_title = to_wstring(&config.game.title); + SendMessageW(hwnd_combo, CB_ADDSTRING, 0, game_title.as_ptr() as LPARAM); + if let Some(addons) = &config.addons { + for addon in addons { + let addon_title = to_wstring(&addon.title); + SendMessageW(hwnd_combo, CB_ADDSTRING, 0, addon_title.as_ptr() as LPARAM); + } + } + SendMessageW(hwnd_combo, CB_SETCURSEL, 0, 0); + + // ------------------------- + // Toolbar — Save button + // ------------------------- + let save_label = to_wstring("Save Config"); + let hwnd_save = CreateWindowExW( + 0, + btn_class.as_ptr(), + save_label.as_ptr(), + WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON as u32, + BTN_SAVE_X, MARGIN, BTN_SAVE_W, CTRL_H, + hwnd, std::ptr::null_mut(), h_instance, std::ptr::null(), + ); + SendMessageW(hwnd_save, WM_SETFONT, font, 1); + + // ------------------------- + // Toolbar — Apply button + // ------------------------- + let apply_label = to_wstring("Apply"); + let hwnd_apply = CreateWindowExW( + 0, + btn_class.as_ptr(), + apply_label.as_ptr(), + WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON as u32, + BTN_APPLY_X, MARGIN, BTN_APPLY_W, CTRL_H, + hwnd, std::ptr::null_mut(), h_instance, std::ptr::null(), + ); + SendMessageW(hwnd_apply, WM_SETFONT, font, 1); + + // ------------------------- + // Groups content + // ------------------------- let panel_w = WIN_W - MARGIN * 2; - let mut y_pos = MARGIN; + let mut y_pos = TOOLBAR_H; for group in &config.option_groups { let items = group_items(group); let group_h = GROUP_TITLE_H + items.len() as i32 * ITEM_H + GROUP_PAD_BOTTOM; - // GroupBox frame let group_label = to_wstring(&group.name); let hwnd_grp = CreateWindowExW( 0, @@ -121,7 +174,6 @@ pub fn run(config: ConfigModel) { ); SendMessageW(hwnd_grp, WM_SETFONT, font, 1); - // Radio buttons or checkboxes let mut inner_y = GROUP_TITLE_H; for (i, value) in items.iter().enumerate() { let btn_style = match &group.kind { @@ -149,9 +201,7 @@ pub fn run(config: ConfigModel) { y_pos += group_h + GROUP_GAP; } - // ------------------------- // Message loop - // ------------------------- let mut msg: MSG = std::mem::zeroed(); while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { TranslateMessage(&msg); From f5cf4d44300ed7910113d28b0d74d667037914b9 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:07:40 +0200 Subject: [PATCH 11/77] Set background color --- src/ui.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index d229d2f..e904016 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,6 +7,13 @@ use windows_sys::{ use crate::config::model::{ConfigModel, OptionGroup, OptionGroupType}; +use std::sync::atomic::{AtomicIsize, Ordering}; + +// Shared background brush — set once at startup, read in window_proc +static BG_BRUSH: AtomicIsize = AtomicIsize::new(0); +// Original GroupBox wndproc — stored once on first subclass +static ORIG_GROUPBOX_PROC: AtomicIsize = AtomicIsize::new(0); + // ------------------------- // Layout constants // ------------------------- @@ -57,11 +64,15 @@ pub fn run(config: ConfigModel) { // Register window class let class_name = to_wstring("MulderConfigWnd"); + // Color.FromArgb(35, 35, 45) — dark Steam blue + let bg_color: u32 = 35 | (35 << 8) | (45 << 16); + let bg_brush = CreateSolidBrush(bg_color); + BG_BRUSH.store(bg_brush as usize as isize, Ordering::Relaxed); let wc = WNDCLASSW { lpfnWndProc: Some(window_proc), hInstance: h_instance, lpszClassName: class_name.as_ptr(), - hbrBackground: (COLOR_WINDOW + 1) as HBRUSH, + hbrBackground: bg_brush, style: CS_HREDRAW | CS_VREDRAW, ..std::mem::zeroed() }; @@ -110,7 +121,7 @@ pub fn run(config: ConfigModel) { combo_class.as_ptr(), std::ptr::null(), WS_CHILD | WS_VISIBLE | WS_VSCROLL | CBS_DROPDOWNLIST as u32, - COMBO_X, MARGIN + 2, COMBO_W, 200, // height 200 = taille du dropdown ouvert; +1 aligns visually with buttons + COMBO_X, MARGIN + 1, COMBO_W, 200, // height 200 = taille du dropdown ouvert; +1 aligns visually with buttons hwnd, std::ptr::null_mut(), h_instance, std::ptr::null(), ); SendMessageW(hwnd_combo, WM_SETFONT, font, 1); @@ -174,6 +185,12 @@ pub fn run(config: ConfigModel) { ); SendMessageW(hwnd_grp, WM_SETFONT, font, 1); + // Subclass the GroupBox so it handles WM_CTLCOLORSTATIC for its children + let orig = SetWindowLongPtrW(hwnd_grp, GWLP_WNDPROC, std::mem::transmute(groupbox_proc as unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT)); + if ORIG_GROUPBOX_PROC.load(Ordering::Relaxed) == 0 { + ORIG_GROUPBOX_PROC.store(orig, Ordering::Relaxed); + } + let mut inner_y = GROUP_TITLE_H; for (i, value) in items.iter().enumerate() { let btn_style = match &group.kind { @@ -210,6 +227,28 @@ pub fn run(config: ConfigModel) { } } +unsafe extern "system" fn groupbox_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + unsafe { + match msg { + WM_CTLCOLORSTATIC => { + let hdc = wparam as HDC; + SetTextColor(hdc, 0x00FFFFFF); + SetBkMode(hdc, TRANSPARENT as i32); + BG_BRUSH.load(Ordering::Relaxed) as usize as *mut std::ffi::c_void as LRESULT + } + _ => { + let orig = ORIG_GROUPBOX_PROC.load(Ordering::Relaxed); + CallWindowProcW(std::mem::transmute(orig), hwnd, msg, wparam, lparam) + } + } + } +} + unsafe extern "system" fn window_proc( hwnd: HWND, msg: u32, @@ -222,6 +261,13 @@ unsafe extern "system" fn window_proc( PostQuitMessage(0); 0 } + // Paint GroupBox, RadioButton and Checkbox backgrounds to match the window + WM_CTLCOLORSTATIC => { + let hdc = wparam as HDC; + SetTextColor(hdc, 0x00FFFFFF); // white text + SetBkMode(hdc, TRANSPARENT as i32); + BG_BRUSH.load(Ordering::Relaxed) as usize as *mut std::ffi::c_void as LRESULT + } _ => DefWindowProcW(hwnd, msg, wparam, lparam), } } From 94c0e3a68154fbb25bc8d4f638100249cdeace4e Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:30:12 +0200 Subject: [PATCH 12/77] add when_resolver --- src/config.rs | 1 + src/config/when_resolver.rs | 335 ++++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 src/config/when_resolver.rs diff --git a/src/config.rs b/src/config.rs index 8ab3b7e..cc2e739 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ pub mod loader; pub mod model; pub mod validator; +pub mod when_resolver; diff --git a/src/config/when_resolver.rs b/src/config/when_resolver.rs new file mode 100644 index 0000000..1176080 --- /dev/null +++ b/src/config/when_resolver.rs @@ -0,0 +1,335 @@ +use std::collections::HashMap; + +use crate::config::model::WhenGroup; + +/// Runtime selection value: single string (radio) or list of strings (checkboxes). +#[derive(Debug, Clone)] +pub enum SelectionValue { + Single(String), + Multiple(Vec), +} + +impl From for SelectionValue { + fn from(s: String) -> Self { + SelectionValue::Single(s) + } +} + +impl From> for SelectionValue { + fn from(v: Vec) -> Self { + SelectionValue::Multiple(v) + } +} + +pub type Selections = HashMap; + +#[derive(Debug, PartialEq)] +enum ConditionOperator { + Equals, + NotEquals, + Contains, + NotContains, +} + +fn parse_key(raw_key: &str) -> (ConditionOperator, &str) { + if raw_key.starts_with("!*") { + (ConditionOperator::NotContains, &raw_key[2..]) + } else if raw_key.starts_with('*') { + (ConditionOperator::Contains, &raw_key[1..]) + } else if raw_key.starts_with('!') { + (ConditionOperator::NotEquals, &raw_key[1..]) + } else { + (ConditionOperator::Equals, raw_key) + } +} + +fn contains_ignore_case(actual: &str, expected: &str) -> bool { + actual.to_lowercase().contains(&expected.to_lowercase()) +} + +fn equals_ignore_case(a: &str, b: &str) -> bool { + a.eq_ignore_ascii_case(b) +} + +fn is_null_or_empty_selection(value: Option<&SelectionValue>) -> bool { + match value { + None => true, + Some(SelectionValue::Multiple(v)) => v.is_empty(), + Some(_) => false, + } +} + +fn is_value_match(value: &SelectionValue, expected: &str, op: &ConditionOperator) -> bool { + match value { + SelectionValue::Multiple(list) => match op { + ConditionOperator::Contains => list.iter().any(|v| contains_ignore_case(v, expected)), + ConditionOperator::NotContains => { + !list.iter().any(|v| contains_ignore_case(v, expected)) + } + ConditionOperator::NotEquals => !list.iter().any(|v| equals_ignore_case(v, expected)), + ConditionOperator::Equals => list.iter().any(|v| equals_ignore_case(v, expected)), + }, + SelectionValue::Single(actual) => match op { + ConditionOperator::Contains => contains_ignore_case(actual, expected), + ConditionOperator::NotContains => !contains_ignore_case(actual, expected), + ConditionOperator::NotEquals => !equals_ignore_case(actual, expected), + ConditionOperator::Equals => equals_ignore_case(actual, expected), + }, + } +} + +fn is_group_match(group: &WhenGroup, selected: &Selections) -> bool { + for (raw_key, expected) in group { + let (op, key) = parse_key(raw_key); + let selected_value = selected.get(key); + + // Special case: expected is "" with Equals => "nothing selected" (empty checkbox list) + if op == ConditionOperator::Equals && expected.is_empty() { + if is_null_or_empty_selection(selected_value) { + continue; + } + return false; + } + + // Missing key: + // - NotEquals / NotContains => considered true + // - Equals / Contains => cannot match + match selected_value { + None => { + if op == ConditionOperator::NotEquals || op == ConditionOperator::NotContains { + continue; + } + return false; + } + Some(value) => { + if !is_value_match(value, expected, &op) { + return false; + } + } + } + } + + true +} + +/// Returns true if any WhenGroup matches the current selections (OR of ANDs). +/// An empty groups slice means "always apply". +pub fn match_when(groups: &[WhenGroup], selected: &Selections) -> bool { + if groups.is_empty() { + return true; + } + + groups.iter().any(|group| is_group_match(group, selected)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sel(items: &[(&str, SelectionValue)]) -> Selections { + items + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() + } + + fn single(s: &str) -> SelectionValue { + SelectionValue::Single(s.to_string()) + } + + fn multi(items: &[&str]) -> SelectionValue { + SelectionValue::Multiple(items.iter().map(|s| s.to_string()).collect()) + } + + fn when(groups: &[&[(&str, &str)]]) -> Vec { + groups + .iter() + .map(|group| { + group + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + }) + .collect() + } + + #[test] + fn empty_when_always_matches() { + assert!(match_when(&[], &Selections::new())); + } + + #[test] + fn and_all_conditions_match_succeeds() { + let groups = when(&[&[("Renderer", "DX9"), ("HDR", "Enabled")]]); + let selected = sel(&[("Renderer", single("DX9")), ("HDR", single("Enabled"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn and_one_condition_miss_fails() { + let groups = when(&[&[("Renderer", "DX9"), ("HDR", "Enabled")]]); + let selected = sel(&[("Renderer", single("DX11")), ("HDR", single("Enabled"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn and_no_condition_matches_fails() { + let groups = when(&[&[("Renderer", "DX9"), ("HDR", "Enabled")]]); + let selected = sel(&[("Renderer", single("DX11")), ("HDR", single("Disabled"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn or_all_groups_match_succeeds() { + let groups = when(&[&[("Resolution", "2560x1440")], &[("Renderer", "DXVK")]]); + let selected = sel(&[ + ("Resolution", single("2560x1440")), + ("Renderer", single("DXVK")), + ]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn or_one_group_matches_succeeds() { + let groups = when(&[&[("Resolution", "2560x1440")], &[("Renderer", "DXVK")]]); + let selected = sel(&[("Renderer", single("DXVK"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn or_no_group_matches_fails() { + let groups = when(&[&[("Resolution", "2560x1440")], &[("Renderer", "DXVK")]]); + let selected = sel(&[ + ("Resolution", single("1920x1080")), + ("Renderer", single("D3D9")), + ]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn or_of_and_groups_mixed_example_succeeds() { + // (*Resolution contains "1920x" AND Renderer == "DXVK") OR (!FOV Modifier != "None") + let groups = when(&[ + &[("*Resolution", "1920x"), ("Renderer", "DXVK")], + &[("!FOV Modifier", "None")], + ]); + // First group fails (Renderer != DXVK), second succeeds => OR => true + let selected = sel(&[ + ("Resolution", single("1920x1080")), + ("Renderer", single("D3D9")), + ("FOV Modifier", single("lower")), + ]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn not_equals_succeeds() { + let groups = when(&[&[("!Renderer", "DXVK")]]); + let selected = sel(&[("Renderer", single("DX9"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn not_equals_fails() { + let groups = when(&[&[("!Resolution", "1920x1080")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn contains_succeeds() { + let groups = when(&[&[("*Resolution", "1920x")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn contains_fails() { + let groups = when(&[&[("*Resolution", "2560x")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn not_contains_succeeds() { + let groups = when(&[&[("!*Renderer", "DXVK")]]); + let selected = sel(&[("Renderer", single("DX9"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn not_contains_fails() { + let groups = when(&[&[("!*Renderer", "DXVK")]]); + let selected = sel(&[("Renderer", single("Vulkan DXVK Wrapper"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn empty_expected_matches_nothing_selected() { + let groups = when(&[&[("Switchable Mods", "")]]); + let selected = sel(&[("Switchable Mods", multi(&[]))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn list_contains_succeeds() { + let groups = when(&[&[("*Switchable Mods", "NV")]]); + let selected = sel(&[("Switchable Mods", multi(&["NVHR", "DXVK"]))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn list_not_contains_fails() { + let groups = when(&[&[("!*Switchable Mods", "Vulkan")]]); + let selected = sel(&[("Switchable Mods", multi(&["NVHR", "Vulkan DXVK Wrapper"]))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn missing_key_equals_fails() { + let groups = when(&[&[("Renderer", "DXVK")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn missing_key_contains_fails() { + let groups = when(&[&[("*Renderer", "DX")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn missing_key_not_equals_succeeds() { + let groups = when(&[&[("!Renderer", "DXVK")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn missing_key_not_contains_succeeds() { + let groups = when(&[&[("!*Renderer", "DXVK")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn case_insensitive_equals_and_contains_work() { + let groups = when(&[&[("Renderer", "dxvk"), ("*Resolution", "1920X")]]); + let selected = sel(&[ + ("Renderer", single("DXVK")), + ("Resolution", single("1920x1080")), + ]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn case_insensitive_not_equals_and_not_contains_work() { + let groups = when(&[&[("!Renderer", "dxvk"), ("!*Resolution", "(21/9)")]]); + let selected = sel(&[ + ("Renderer", single("DX9")), + ("Resolution", single("1920x1080 (16/9)")), + ]); + assert!(match_when(&groups, &selected)); + } +} From 8799eacf5cba37f89d4e007c9a24bd15751a37f0 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:30:29 +0200 Subject: [PATCH 13/77] use when_resolver in ui --- src/ui.rs | 195 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 160 insertions(+), 35 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index e904016..4d13fbc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,7 +6,9 @@ use windows_sys::{ }; use crate::config::model::{ConfigModel, OptionGroup, OptionGroupType}; +use crate::config::when_resolver::{match_when, SelectionValue, Selections}; +use std::collections::HashMap; use std::sync::atomic::{AtomicIsize, Ordering}; // Shared background brush — set once at startup, read in window_proc @@ -38,23 +40,104 @@ fn to_wstring(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } -fn item_count(group: &OptionGroup) -> usize { - match &group.kind { - OptionGroupType::RadioGroup => group.radios.as_ref().map_or(0, |v| v.len()), - OptionGroupType::CheckboxGroup => group.checkboxes.as_ref().map_or(0, |v| v.len()), +/// Enable or disable a Win32 control (replaces EnableWindow absent from windows-sys 0.61). +unsafe fn set_enabled(hwnd: HWND, enabled: bool) { + unsafe { + let style = GetWindowLongPtrW(hwnd, GWL_STYLE); + let new_style = if enabled { + style & !(WS_DISABLED as isize) + } else { + style | WS_DISABLED as isize + }; + SetWindowLongPtrW(hwnd, GWL_STYLE, new_style); + InvalidateRect(hwnd, std::ptr::null(), 1); } } -fn group_items(group: &OptionGroup) -> Vec<&str> { +// ------------------------- +// App state (stored in GWLP_USERDATA) +// ------------------------- +struct AppState { + config: ConfigModel, + /// group_name -> [(value, hwnd)] + radio_hwnds: HashMap>, + /// group_name -> [(value, hwnd)] + checkbox_hwnds: HashMap>, +} + +impl AppState { + fn collect_selections(&self) -> Selections { + let mut sel = Selections::new(); + unsafe { + for (group_name, items) in &self.radio_hwnds { + for (value, hwnd) in items { + if SendMessageW(*hwnd, BM_GETCHECK, 0, 0) == 1 { + sel.insert(group_name.clone(), SelectionValue::Single(value.clone())); + break; + } + } + } + for group in &self.config.option_groups { + if matches!(group.kind, OptionGroupType::CheckboxGroup) { + if let Some(items) = self.checkbox_hwnds.get(&group.name) { + let checked: Vec = items + .iter() + .filter(|(_, h)| SendMessageW(*h, BM_GETCHECK, 0, 0) == 1) + .map(|(v, _)| v.clone()) + .collect(); + sel.insert(group.name.clone(), SelectionValue::Multiple(checked)); + } + } + } + } + sel + } + + unsafe fn apply_when_constraints(&self) { + unsafe { + let sel = self.collect_selections(); + for group in &self.config.option_groups { + match &group.kind { + OptionGroupType::RadioGroup => { + if let (Some(radios), Some(hwnds)) = + (&group.radios, self.radio_hwnds.get(&group.name)) + { + for (radio, (_, hwnd)) in radios.iter().zip(hwnds.iter()) { + if let Some(when) = &radio.disabled_when { + let disabled = match_when(when, &sel); + set_enabled(*hwnd, !disabled); + if disabled { + SendMessageW(*hwnd, BM_SETCHECK, 0, 0); + } + } + } + } + } + OptionGroupType::CheckboxGroup => { + if let (Some(checkboxes), Some(hwnds)) = + (&group.checkboxes, self.checkbox_hwnds.get(&group.name)) + { + for (checkbox, (_, hwnd)) in checkboxes.iter().zip(hwnds.iter()) { + if let Some(when) = &checkbox.disabled_when { + let disabled = match_when(when, &sel); + set_enabled(*hwnd, !disabled); + if disabled { + SendMessageW(*hwnd, BM_SETCHECK, 0, 0); + } + } + } + } + } + } + } + } // unsafe + } +} + +fn item_count(group: &OptionGroup) -> usize { match &group.kind { - OptionGroupType::RadioGroup => group - .radios - .as_ref() - .map_or(vec![], |v| v.iter().map(|r| r.value.as_str()).collect()), - OptionGroupType::CheckboxGroup => group - .checkboxes - .as_ref() - .map_or(vec![], |v| v.iter().map(|c| c.value.as_str()).collect()), + OptionGroupType::RadioGroup => group.radios.as_ref().map_or(0, |v| v.len()), + OptionGroupType::CheckboxGroup => group.checkboxes.as_ref().map_or(0, |v| v.len()), } } @@ -169,10 +252,11 @@ pub fn run(config: ConfigModel) { // ------------------------- let panel_w = WIN_W - MARGIN * 2; let mut y_pos = TOOLBAR_H; + let mut radio_hwnds: HashMap> = HashMap::new(); + let mut checkbox_hwnds: HashMap> = HashMap::new(); for group in &config.option_groups { - let items = group_items(group); - let group_h = GROUP_TITLE_H + items.len() as i32 * ITEM_H + GROUP_PAD_BOTTOM; + let group_h = GROUP_TITLE_H + item_count(group) as i32 * ITEM_H + GROUP_PAD_BOTTOM; let group_label = to_wstring(&group.name); let hwnd_grp = CreateWindowExW( @@ -192,32 +276,54 @@ pub fn run(config: ConfigModel) { } let mut inner_y = GROUP_TITLE_H; - for (i, value) in items.iter().enumerate() { - let btn_style = match &group.kind { - OptionGroupType::RadioGroup => { - let mut s = WS_CHILD | WS_VISIBLE | BS_AUTORADIOBUTTON as u32; - if i == 0 { s |= WS_GROUP; } - s + match &group.kind { + OptionGroupType::RadioGroup => { + if let Some(radios) = &group.radios { + let group_entries = radio_hwnds.entry(group.name.clone()).or_default(); + for (i, radio) in radios.iter().enumerate() { + let mut s = WS_CHILD | WS_VISIBLE | BS_AUTORADIOBUTTON as u32; + if i == 0 { s |= WS_GROUP; } + let item_label = to_wstring(&radio.value); + let hwnd_item = CreateWindowExW( + 0, btn_class.as_ptr(), item_label.as_ptr(), s, + MARGIN + 5, inner_y, panel_w - MARGIN * 2 - 10, ITEM_H, + hwnd_grp, std::ptr::null_mut(), h_instance, std::ptr::null(), + ); + SendMessageW(hwnd_item, WM_SETFONT, font, 1); + group_entries.push((radio.value.clone(), hwnd_item)); + inner_y += ITEM_H; + } } - OptionGroupType::CheckboxGroup => WS_CHILD | WS_VISIBLE | BS_AUTOCHECKBOX as u32, - }; - - let item_label = to_wstring(value); - let hwnd_item = CreateWindowExW( - 0, - btn_class.as_ptr(), - item_label.as_ptr(), - btn_style, - MARGIN + 5, inner_y, panel_w - MARGIN * 2 - 10, ITEM_H, - hwnd_grp, std::ptr::null_mut(), h_instance, std::ptr::null(), - ); - SendMessageW(hwnd_item, WM_SETFONT, font, 1); - inner_y += ITEM_H; + } + OptionGroupType::CheckboxGroup => { + if let Some(checkboxes) = &group.checkboxes { + let group_entries = checkbox_hwnds.entry(group.name.clone()).or_default(); + for checkbox in checkboxes.iter() { + let item_label = to_wstring(&checkbox.value); + let hwnd_item = CreateWindowExW( + 0, btn_class.as_ptr(), item_label.as_ptr(), + WS_CHILD | WS_VISIBLE | BS_AUTOCHECKBOX as u32, + MARGIN + 5, inner_y, panel_w - MARGIN * 2 - 10, ITEM_H, + hwnd_grp, std::ptr::null_mut(), h_instance, std::ptr::null(), + ); + SendMessageW(hwnd_item, WM_SETFONT, font, 1); + group_entries.push((checkbox.value.clone(), hwnd_item)); + inner_y += ITEM_H; + } + } + } } y_pos += group_h + GROUP_GAP; } + // Store app state on the window for use in window_proc + let state = Box::new(AppState { config, radio_hwnds, checkbox_hwnds }); + let state_ptr = Box::into_raw(state); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, state_ptr as isize); + // Apply initial disabled_when constraints + (*state_ptr).apply_when_constraints(); + // Message loop let mut msg: MSG = std::mem::zeroed(); while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { @@ -241,6 +347,12 @@ unsafe extern "system" fn groupbox_proc( SetBkMode(hdc, TRANSPARENT as i32); BG_BRUSH.load(Ordering::Relaxed) as usize as *mut std::ffi::c_void as LRESULT } + WM_COMMAND => { + // Forward to the main window so apply_when_constraints is called + let parent = GetParent(hwnd); + SendMessageW(parent, WM_COMMAND, wparam, lparam); + 0 + } _ => { let orig = ORIG_GROUPBOX_PROC.load(Ordering::Relaxed); CallWindowProcW(std::mem::transmute(orig), hwnd, msg, wparam, lparam) @@ -257,7 +369,20 @@ unsafe extern "system" fn window_proc( ) -> LRESULT { unsafe { match msg { + WM_COMMAND => { + // Re-evaluate disabled_when after any button click + let state_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut AppState; + if !state_ptr.is_null() { + (*state_ptr).apply_when_constraints(); + } + 0 + } WM_DESTROY => { + // Free AppState + let state_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut AppState; + if !state_ptr.is_null() { + drop(Box::from_raw(state_ptr)); + } PostQuitMessage(0); 0 } From 4f7d28fcfe0331f0e4c0cd8bde173669cac84ab2 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:24:06 +0200 Subject: [PATCH 14/77] Add save management --- src/save.rs | 15 ++++++++++ src/save/loader.rs | 17 ++++++++++++ src/save/saver.rs | 17 ++++++++++++ src/save/validator.rs | 64 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 src/save.rs create mode 100644 src/save/loader.rs create mode 100644 src/save/saver.rs create mode 100644 src/save/validator.rs diff --git a/src/save.rs b/src/save.rs new file mode 100644 index 0000000..6c14737 --- /dev/null +++ b/src/save.rs @@ -0,0 +1,15 @@ +pub mod loader; +pub mod saver; +pub mod validator; + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub type SaveData = HashMap>; + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(untagged)] +pub enum SaveValue { + Single(String), + Multiple(Vec), +} diff --git a/src/save/loader.rs b/src/save/loader.rs new file mode 100644 index 0000000..4158ad8 --- /dev/null +++ b/src/save/loader.rs @@ -0,0 +1,17 @@ +use std::fs; + +use crate::save::SaveData; + +pub struct SaveLoader; + +impl SaveLoader { + pub fn load(path: &str) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| format!("Failed to read save file: {e}"))?; + + let save: SaveData = serde_json::from_str(&content) + .map_err(|e| format!("Invalid save JSON: {e}"))?; + + Ok(save) + } +} diff --git a/src/save/saver.rs b/src/save/saver.rs new file mode 100644 index 0000000..dbb960e --- /dev/null +++ b/src/save/saver.rs @@ -0,0 +1,17 @@ +use std::fs; + +use crate::save::SaveData; + +pub struct SaveSaver; + +impl SaveSaver { + pub fn save(path: &str, data: &SaveData) -> Result<(), String> { + let content = serde_json::to_string_pretty(data) + .map_err(|e| format!("Failed to serialize save: {e}"))?; + + fs::write(path, &content) + .map_err(|e| format!("Failed to write save file: {e}"))?; + + Ok(()) + } +} diff --git a/src/save/validator.rs b/src/save/validator.rs new file mode 100644 index 0000000..91a9e95 --- /dev/null +++ b/src/save/validator.rs @@ -0,0 +1,64 @@ +use crate::config::model::{ConfigModel, OptionGroupType}; +use crate::save::{SaveData, SaveValue}; + +pub struct SaveValidator; + +impl SaveValidator { + pub fn validate(save: &SaveData, config: &ConfigModel) -> Result<(), String> { + let game_save = save + .get(&config.game.title) + .ok_or_else(|| format!("No save data found for game '{}'", config.game.title))?; + + for group in &config.option_groups { + let saved = game_save + .get(&group.name) + .ok_or_else(|| format!("Missing save entry for option group '{}'", group.name))?; + + match group.kind { + OptionGroupType::RadioGroup => { + let radios = group.radios.as_ref().unwrap(); + match saved { + SaveValue::Single(val) => { + if !radios.iter().any(|r| &r.value == val) { + return Err(format!( + "Invalid radio value '{}' for group '{}'", + val, group.name + )); + } + } + SaveValue::Multiple(_) => { + return Err(format!( + "Expected a single value for radio group '{}'", + group.name + )); + } + } + } + + OptionGroupType::CheckboxGroup => { + let checkboxes = group.checkboxes.as_ref().unwrap(); + match saved { + SaveValue::Multiple(vals) => { + for val in vals { + if !checkboxes.iter().any(|c| &c.value == val) { + return Err(format!( + "Invalid checkbox value '{}' for group '{}'", + val, group.name + )); + } + } + } + SaveValue::Single(_) => { + return Err(format!( + "Expected multiple values for checkbox group '{}'", + group.name + )); + } + } + } + } + } + + Ok(()) + } +} From c67db14e16692e36e28296296e2782ea53cfb675 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:27:17 +0200 Subject: [PATCH 15/77] Load save on start + show errors on MessageBox --- src/error.rs | 21 +++++++++++++++++++++ src/main.rs | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/error.rs diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..ebdc8d3 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,21 @@ +use windows_sys::Win32::UI::WindowsAndMessaging::{ + MessageBoxW, MB_ICONERROR, MB_ICONWARNING, MB_OK, MB_YESNO, IDYES, +}; + +pub fn fatal(msg: &str) -> ! { + let title: Vec = "MulderConfig\0".encode_utf16().collect(); + let text: Vec = format!("{msg}\0").encode_utf16().collect(); + unsafe { MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), MB_OK | MB_ICONERROR); } + std::process::exit(1); +} + +/// Shows a warning with "Yes" = delete save / "No" = cancel (exit). +/// Returns true if the user chose to delete the save. +pub fn ask_delete_save(msg: &str) -> bool { + let title: Vec = "MulderConfig\0".encode_utf16().collect(); + let text: Vec = format!("{msg}\0").encode_utf16().collect(); + let result = unsafe { + MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), MB_YESNO | MB_ICONWARNING) + }; + result == IDYES as i32 +} diff --git a/src/main.rs b/src/main.rs index a30b517..042d92e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,50 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod config; +mod error; mod mode_detector; +mod save; mod ui; use config::loader::ConfigLoader; +use error::{ask_delete_save, fatal}; use mode_detector::{detect_mode, Mode}; +use save::loader::SaveLoader; +use save::validator::SaveValidator; + +const SAVE_PATH: &str = "MulderConfig.save.json"; fn main() { - let config = ConfigLoader::load("MulderConfig.json").expect("Failed to load config"); + let config = ConfigLoader::load("MulderConfig.json") + .unwrap_or_else(|e| fatal(&format!("Failed to load config:\n{e}"))); + + let save = if std::path::Path::new(SAVE_PATH).exists() { + let data = SaveLoader::load(SAVE_PATH) + .unwrap_or_else(|e| fatal(&format!("Failed to load save file:\n{e}"))); + match SaveValidator::validate(&data, &config) { + Ok(()) => Some(data), + Err(e) => { + let msg = format!( + "Save file is invalid:\n{e}\n\nDelete the save file and continue?\n(Choosing No will close the application)" + ); + if ask_delete_save(&msg) { + std::fs::remove_file(SAVE_PATH) + .unwrap_or_else(|e| fatal(&format!("Failed to delete save file:\n{e}"))); + None + } else { + std::process::exit(0); + } + } + } + } else { + None + }; + let mode = detect_mode(); match mode { - Mode::Config => { ui::run(config); } + Mode::Config => { ui::run(config, save, SAVE_PATH); } Mode::Apply => { println!("Applying conf for Game: {}", config.game.title); } Mode::Launch => { println!("Launch Game: {}", config.game.title); } } From 673040318783a95275bccc5ba2212d26da226c22 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:29:50 +0200 Subject: [PATCH 16/77] UI - restore selection when save loaded --- src/ui.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 4d13fbc..21aa149 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,6 +7,8 @@ use windows_sys::{ use crate::config::model::{ConfigModel, OptionGroup, OptionGroupType}; use crate::config::when_resolver::{match_when, SelectionValue, Selections}; +use crate::save::{SaveData, SaveValue}; +use crate::save::saver::SaveSaver; use std::collections::HashMap; use std::sync::atomic::{AtomicIsize, Ordering}; @@ -28,6 +30,8 @@ const BTN_SAVE_W: i32 = 95; const GROUP_TITLE_H: i32 = 22; const ITEM_H: i32 = 24; const GROUP_PAD_BOTTOM: i32 = 8; + +const ID_BTN_SAVE: usize = 1; const GROUP_GAP: i32 = 8; // Toolbar positions (right-aligned) @@ -63,6 +67,9 @@ struct AppState { radio_hwnds: HashMap>, /// group_name -> [(value, hwnd)] checkbox_hwnds: HashMap>, + save_path: &'static str, + hwnd_save: HWND, + hwnd_apply: HWND, } impl AppState { @@ -93,6 +100,45 @@ impl AppState { sel } + fn is_complete(&self) -> bool { + unsafe { + for group in &self.config.option_groups { + if matches!(group.kind, OptionGroupType::RadioGroup) { + if let Some(items) = self.radio_hwnds.get(&group.name) { + let is_enabled = |h: &HWND| { + GetWindowLongPtrW(*h, GWL_STYLE) & WS_DISABLED as isize == 0 + }; + let any_checked = items.iter().any(|(_, h)| { + is_enabled(h) && SendMessageW(*h, BM_GETCHECK, 0, 0) == 1 + }); + let all_disabled = items.iter().all(|(_, h)| !is_enabled(h)); + if !any_checked && !all_disabled { + return false; + } + } + } + } + } + true + } + + fn save_to_disk(&self) { + let sel = self.collect_selections(); + let mut game_save: HashMap = HashMap::new(); + for (group_name, val) in sel { + let save_val = match val { + SelectionValue::Single(s) => SaveValue::Single(s), + SelectionValue::Multiple(v) => SaveValue::Multiple(v), + }; + game_save.insert(group_name, save_val); + } + let mut data = SaveData::new(); + data.insert(self.config.game.title.clone(), game_save); + if let Err(e) = SaveSaver::save(self.save_path, &data) { + crate::error::fatal(&format!("Failed to save:\n{e}")); + } + } + unsafe fn apply_when_constraints(&self) { unsafe { let sel = self.collect_selections(); @@ -131,6 +177,8 @@ impl AppState { } } } // unsafe + unsafe { set_enabled(self.hwnd_save, self.is_complete()); } + unsafe { set_enabled(self.hwnd_apply, self.is_complete()); } } } @@ -141,7 +189,7 @@ fn item_count(group: &OptionGroup) -> usize { } } -pub fn run(config: ConfigModel) { +pub fn run(config: ConfigModel, save: Option, save_path: &'static str) { unsafe { let h_instance = GetModuleHandleW(std::ptr::null()); @@ -229,7 +277,7 @@ pub fn run(config: ConfigModel) { save_label.as_ptr(), WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON as u32, BTN_SAVE_X, MARGIN, BTN_SAVE_W, CTRL_H, - hwnd, std::ptr::null_mut(), h_instance, std::ptr::null(), + hwnd, ID_BTN_SAVE as _, h_instance, std::ptr::null(), ); SendMessageW(hwnd_save, WM_SETFONT, font, 1); @@ -317,8 +365,32 @@ pub fn run(config: ConfigModel) { y_pos += group_h + GROUP_GAP; } + // Restore saved selections if a save file was loaded + if let Some(ref save_data) = save { + if let Some(game_save) = save_data.get(&config.game.title) { + for (group_name, value) in game_save { + match value { + SaveValue::Single(val) => { + if let Some(items) = radio_hwnds.get(group_name) { + for (item_val, hwnd) in items { + SendMessageW(*hwnd, BM_SETCHECK, (item_val == val) as usize, 0); + } + } + } + SaveValue::Multiple(vals) => { + if let Some(items) = checkbox_hwnds.get(group_name) { + for (item_val, hwnd) in items { + SendMessageW(*hwnd, BM_SETCHECK, vals.contains(item_val) as usize, 0); + } + } + } + } + } + } + } + // Store app state on the window for use in window_proc - let state = Box::new(AppState { config, radio_hwnds, checkbox_hwnds }); + let state = Box::new(AppState { config, radio_hwnds, checkbox_hwnds, save_path, hwnd_save, hwnd_apply }); let state_ptr = Box::into_raw(state); SetWindowLongPtrW(hwnd, GWLP_USERDATA, state_ptr as isize); // Apply initial disabled_when constraints @@ -370,9 +442,11 @@ unsafe extern "system" fn window_proc( unsafe { match msg { WM_COMMAND => { - // Re-evaluate disabled_when after any button click let state_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut AppState; if !state_ptr.is_null() { + if wparam & 0xFFFF == ID_BTN_SAVE { + (*state_ptr).save_to_disk(); + } (*state_ptr).apply_when_constraints(); } 0 From 619519c5fb025c67fb91688a46da368adbdcb343 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:16:09 +0200 Subject: [PATCH 17/77] Add rust native windows gui --- Cargo.lock | 292 +++++++++++++++++++++++++++++++- Cargo.toml | 2 + src/ui.rs | 479 +++-------------------------------------------------- 3 files changed, 317 insertions(+), 456 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e6c683..19ace64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,84 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libm" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc7aa29613bd6a620df431842069224d8bc9011086b1db4c0e0cd47fa03ec9a" + [[package]] name = "memchr" version = "2.8.0" @@ -18,11 +90,98 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" name = "mulder-config" version = "2.0.0" dependencies = [ + "native-windows-derive", + "native-windows-gui", "serde", "serde_json", "windows-sys", ] +[[package]] +name = "native-windows-derive" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76134ae81020d89d154f619fd2495a2cecad204276b1dc21174b55e4d0975edd" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "native-windows-gui" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f7003a669f68deb6b7c57d74fff4f8e533c44a3f0b297492440ef4ff5a28454" +dependencies = [ + "bitflags", + "lazy_static", + "newline-converter", + "plotters", + "plotters-backend", + "stretch", + "winapi", + "winapi-build", +] + +[[package]] +name = "newline-converter" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f71d09d5c87634207f894c6b31b6a2b2c64ea3bdcf71bd5599fdbbe1600c00f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "proc-macro-crate" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" +dependencies = [ + "toml", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -41,6 +200,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "serde" version = "1.0.228" @@ -68,7 +233,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -84,6 +249,33 @@ dependencies = [ "zmij", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "stretch" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0dc6d20ce137f302edf90f9cd3d278866fd7fb139efca6f246161222ad6d87" +dependencies = [ + "lazy_static", + "libm", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -95,12 +287,110 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 3a8ef93..e520d0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,8 @@ name = "MulderConfig" path = "src/main.rs" [dependencies] +native-windows-derive = "1.0.5" +native-windows-gui = "1.0.13" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_System_LibraryLoader"] } diff --git a/src/ui.rs b/src/ui.rs index 21aa149..0c9ca33 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,473 +1,42 @@ -use windows_sys::{ - Win32::Foundation::*, - Win32::Graphics::Gdi::*, - Win32::System::LibraryLoader::*, - Win32::UI::WindowsAndMessaging::*, -}; +extern crate native_windows_gui as nwg; +extern crate native_windows_derive as nwd; -use crate::config::model::{ConfigModel, OptionGroup, OptionGroupType}; -use crate::config::when_resolver::{match_when, SelectionValue, Selections}; -use crate::save::{SaveData, SaveValue}; -use crate::save::saver::SaveSaver; +use nwd::NwgUi; +use nwg::NativeUi; -use std::collections::HashMap; -use std::sync::atomic::{AtomicIsize, Ordering}; +use crate::{config::model::ConfigModel, save::SaveData}; -// Shared background brush — set once at startup, read in window_proc -static BG_BRUSH: AtomicIsize = AtomicIsize::new(0); -// Original GroupBox wndproc — stored once on first subclass -static ORIG_GROUPBOX_PROC: AtomicIsize = AtomicIsize::new(0); +#[derive(Default, NwgUi)] +pub struct BasicApp { + #[nwg_control(size: (300, 115), position: (300, 300), title: "Basic example", flags: "WINDOW|VISIBLE")] + #[nwg_events( OnWindowClose: [BasicApp::say_goodbye] )] + window: nwg::Window, -// ------------------------- -// Layout constants -// ------------------------- -const WIN_W: i32 = 620; -const MARGIN: i32 = 12; -const CTRL_H: i32 = 23; // combobox / button height -const TOOLBAR_H: i32 = 47; // MARGIN + CTRL_H + MARGIN -const BTN_APPLY_W: i32 = 80; -const BTN_SAVE_W: i32 = 95; -const GROUP_TITLE_H: i32 = 22; -const ITEM_H: i32 = 24; -const GROUP_PAD_BOTTOM: i32 = 8; + #[nwg_control(text: "Heisenberg", size: (280, 25), position: (10, 10))] + name_edit: nwg::TextInput, -const ID_BTN_SAVE: usize = 1; -const GROUP_GAP: i32 = 8; - -// Toolbar positions (right-aligned) -const BTN_APPLY_X: i32 = WIN_W - MARGIN - BTN_APPLY_W; -const BTN_SAVE_X: i32 = BTN_APPLY_X - 5 - BTN_SAVE_W; -const COMBO_X: i32 = MARGIN; -const COMBO_W: i32 = BTN_SAVE_X - 5 - MARGIN; - -fn to_wstring(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() -} - -/// Enable or disable a Win32 control (replaces EnableWindow absent from windows-sys 0.61). -unsafe fn set_enabled(hwnd: HWND, enabled: bool) { - unsafe { - let style = GetWindowLongPtrW(hwnd, GWL_STYLE); - let new_style = if enabled { - style & !(WS_DISABLED as isize) - } else { - style | WS_DISABLED as isize - }; - SetWindowLongPtrW(hwnd, GWL_STYLE, new_style); - InvalidateRect(hwnd, std::ptr::null(), 1); - } + #[nwg_control(text: "Say my name", size: (280, 60), position: (10, 40))] + #[nwg_events( OnButtonClick: [BasicApp::say_hello] )] + hello_button: nwg::Button } -// ------------------------- -// App state (stored in GWLP_USERDATA) -// ------------------------- -struct AppState { - config: ConfigModel, - /// group_name -> [(value, hwnd)] - radio_hwnds: HashMap>, - /// group_name -> [(value, hwnd)] - checkbox_hwnds: HashMap>, - save_path: &'static str, - hwnd_save: HWND, - hwnd_apply: HWND, -} - -impl AppState { - fn collect_selections(&self) -> Selections { - let mut sel = Selections::new(); - unsafe { - for (group_name, items) in &self.radio_hwnds { - for (value, hwnd) in items { - if SendMessageW(*hwnd, BM_GETCHECK, 0, 0) == 1 { - sel.insert(group_name.clone(), SelectionValue::Single(value.clone())); - break; - } - } - } - for group in &self.config.option_groups { - if matches!(group.kind, OptionGroupType::CheckboxGroup) { - if let Some(items) = self.checkbox_hwnds.get(&group.name) { - let checked: Vec = items - .iter() - .filter(|(_, h)| SendMessageW(*h, BM_GETCHECK, 0, 0) == 1) - .map(|(v, _)| v.clone()) - .collect(); - sel.insert(group.name.clone(), SelectionValue::Multiple(checked)); - } - } - } - } - sel - } - - fn is_complete(&self) -> bool { - unsafe { - for group in &self.config.option_groups { - if matches!(group.kind, OptionGroupType::RadioGroup) { - if let Some(items) = self.radio_hwnds.get(&group.name) { - let is_enabled = |h: &HWND| { - GetWindowLongPtrW(*h, GWL_STYLE) & WS_DISABLED as isize == 0 - }; - let any_checked = items.iter().any(|(_, h)| { - is_enabled(h) && SendMessageW(*h, BM_GETCHECK, 0, 0) == 1 - }); - let all_disabled = items.iter().all(|(_, h)| !is_enabled(h)); - if !any_checked && !all_disabled { - return false; - } - } - } - } - } - true - } +impl BasicApp { - fn save_to_disk(&self) { - let sel = self.collect_selections(); - let mut game_save: HashMap = HashMap::new(); - for (group_name, val) in sel { - let save_val = match val { - SelectionValue::Single(s) => SaveValue::Single(s), - SelectionValue::Multiple(v) => SaveValue::Multiple(v), - }; - game_save.insert(group_name, save_val); - } - let mut data = SaveData::new(); - data.insert(self.config.game.title.clone(), game_save); - if let Err(e) = SaveSaver::save(self.save_path, &data) { - crate::error::fatal(&format!("Failed to save:\n{e}")); - } + fn say_hello(&self) { + nwg::simple_message("Hello", &format!("Hello {}", self.name_edit.text())); } - unsafe fn apply_when_constraints(&self) { - unsafe { - let sel = self.collect_selections(); - for group in &self.config.option_groups { - match &group.kind { - OptionGroupType::RadioGroup => { - if let (Some(radios), Some(hwnds)) = - (&group.radios, self.radio_hwnds.get(&group.name)) - { - for (radio, (_, hwnd)) in radios.iter().zip(hwnds.iter()) { - if let Some(when) = &radio.disabled_when { - let disabled = match_when(when, &sel); - set_enabled(*hwnd, !disabled); - if disabled { - SendMessageW(*hwnd, BM_SETCHECK, 0, 0); - } - } - } - } - } - OptionGroupType::CheckboxGroup => { - if let (Some(checkboxes), Some(hwnds)) = - (&group.checkboxes, self.checkbox_hwnds.get(&group.name)) - { - for (checkbox, (_, hwnd)) in checkboxes.iter().zip(hwnds.iter()) { - if let Some(when) = &checkbox.disabled_when { - let disabled = match_when(when, &sel); - set_enabled(*hwnd, !disabled); - if disabled { - SendMessageW(*hwnd, BM_SETCHECK, 0, 0); - } - } - } - } - } - } - } - } // unsafe - unsafe { set_enabled(self.hwnd_save, self.is_complete()); } - unsafe { set_enabled(self.hwnd_apply, self.is_complete()); } + fn say_goodbye(&self) { + nwg::simple_message("Goodbye", &format!("Goodbye {}", self.name_edit.text())); + nwg::stop_thread_dispatch(); } -} -fn item_count(group: &OptionGroup) -> usize { - match &group.kind { - OptionGroupType::RadioGroup => group.radios.as_ref().map_or(0, |v| v.len()), - OptionGroupType::CheckboxGroup => group.checkboxes.as_ref().map_or(0, |v| v.len()), - } } pub fn run(config: ConfigModel, save: Option, save_path: &'static str) { - unsafe { - let h_instance = GetModuleHandleW(std::ptr::null()); - - // Register window class - let class_name = to_wstring("MulderConfigWnd"); - // Color.FromArgb(35, 35, 45) — dark Steam blue - let bg_color: u32 = 35 | (35 << 8) | (45 << 16); - let bg_brush = CreateSolidBrush(bg_color); - BG_BRUSH.store(bg_brush as usize as isize, Ordering::Relaxed); - let wc = WNDCLASSW { - lpfnWndProc: Some(window_proc), - hInstance: h_instance, - lpszClassName: class_name.as_ptr(), - hbrBackground: bg_brush, - style: CS_HREDRAW | CS_VREDRAW, - ..std::mem::zeroed() - }; - RegisterClassW(&wc); - - // Compute client height - let mut content_h = MARGIN; - for group in &config.option_groups { - let n = item_count(group) as i32; - content_h += GROUP_TITLE_H + n * ITEM_H + GROUP_PAD_BOTTOM + GROUP_GAP; - } - content_h += MARGIN; - let client_h = TOOLBAR_H + content_h; - - // Adjust for window chrome - let style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; - let mut rect = RECT { left: 0, top: 0, right: WIN_W, bottom: client_h }; - AdjustWindowRect(&mut rect, style, 0); - let win_w = rect.right - rect.left; - let win_h = rect.bottom - rect.top; - - // Center on screen - let x = (GetSystemMetrics(SM_CXSCREEN) - win_w) / 2; - let y = ((GetSystemMetrics(SM_CYSCREEN) - win_h) / 2).max(0); - - // Create main window - let title = to_wstring(&config.game.title); - let hwnd = CreateWindowExW( - 0, - class_name.as_ptr(), - title.as_ptr(), - style | WS_VISIBLE, - x, y, win_w, win_h, - std::ptr::null_mut(), std::ptr::null_mut(), h_instance, std::ptr::null(), - ); - - let font = GetStockObject(DEFAULT_GUI_FONT) as usize; - let btn_class = to_wstring("BUTTON"); - let combo_class = to_wstring("COMBOBOX"); + nwg::init().expect("Failed to init Native Windows GUI"); - // ------------------------- - // Toolbar — ComboBox - // ------------------------- - let hwnd_combo = CreateWindowExW( - 0, - combo_class.as_ptr(), - std::ptr::null(), - WS_CHILD | WS_VISIBLE | WS_VSCROLL | CBS_DROPDOWNLIST as u32, - COMBO_X, MARGIN + 1, COMBO_W, 200, // height 200 = taille du dropdown ouvert; +1 aligns visually with buttons - hwnd, std::ptr::null_mut(), h_instance, std::ptr::null(), - ); - SendMessageW(hwnd_combo, WM_SETFONT, font, 1); + let _app = BasicApp::build_ui(Default::default()).expect("Failed to build UI"); - let game_title = to_wstring(&config.game.title); - SendMessageW(hwnd_combo, CB_ADDSTRING, 0, game_title.as_ptr() as LPARAM); - if let Some(addons) = &config.addons { - for addon in addons { - let addon_title = to_wstring(&addon.title); - SendMessageW(hwnd_combo, CB_ADDSTRING, 0, addon_title.as_ptr() as LPARAM); - } - } - SendMessageW(hwnd_combo, CB_SETCURSEL, 0, 0); - - // ------------------------- - // Toolbar — Save button - // ------------------------- - let save_label = to_wstring("Save Config"); - let hwnd_save = CreateWindowExW( - 0, - btn_class.as_ptr(), - save_label.as_ptr(), - WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON as u32, - BTN_SAVE_X, MARGIN, BTN_SAVE_W, CTRL_H, - hwnd, ID_BTN_SAVE as _, h_instance, std::ptr::null(), - ); - SendMessageW(hwnd_save, WM_SETFONT, font, 1); - - // ------------------------- - // Toolbar — Apply button - // ------------------------- - let apply_label = to_wstring("Apply"); - let hwnd_apply = CreateWindowExW( - 0, - btn_class.as_ptr(), - apply_label.as_ptr(), - WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON as u32, - BTN_APPLY_X, MARGIN, BTN_APPLY_W, CTRL_H, - hwnd, std::ptr::null_mut(), h_instance, std::ptr::null(), - ); - SendMessageW(hwnd_apply, WM_SETFONT, font, 1); - - // ------------------------- - // Groups content - // ------------------------- - let panel_w = WIN_W - MARGIN * 2; - let mut y_pos = TOOLBAR_H; - let mut radio_hwnds: HashMap> = HashMap::new(); - let mut checkbox_hwnds: HashMap> = HashMap::new(); - - for group in &config.option_groups { - let group_h = GROUP_TITLE_H + item_count(group) as i32 * ITEM_H + GROUP_PAD_BOTTOM; - - let group_label = to_wstring(&group.name); - let hwnd_grp = CreateWindowExW( - 0, - btn_class.as_ptr(), - group_label.as_ptr(), - WS_CHILD | WS_VISIBLE | BS_GROUPBOX as u32, - MARGIN, y_pos, panel_w, group_h, - hwnd, std::ptr::null_mut(), h_instance, std::ptr::null(), - ); - SendMessageW(hwnd_grp, WM_SETFONT, font, 1); - - // Subclass the GroupBox so it handles WM_CTLCOLORSTATIC for its children - let orig = SetWindowLongPtrW(hwnd_grp, GWLP_WNDPROC, std::mem::transmute(groupbox_proc as unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT)); - if ORIG_GROUPBOX_PROC.load(Ordering::Relaxed) == 0 { - ORIG_GROUPBOX_PROC.store(orig, Ordering::Relaxed); - } - - let mut inner_y = GROUP_TITLE_H; - match &group.kind { - OptionGroupType::RadioGroup => { - if let Some(radios) = &group.radios { - let group_entries = radio_hwnds.entry(group.name.clone()).or_default(); - for (i, radio) in radios.iter().enumerate() { - let mut s = WS_CHILD | WS_VISIBLE | BS_AUTORADIOBUTTON as u32; - if i == 0 { s |= WS_GROUP; } - let item_label = to_wstring(&radio.value); - let hwnd_item = CreateWindowExW( - 0, btn_class.as_ptr(), item_label.as_ptr(), s, - MARGIN + 5, inner_y, panel_w - MARGIN * 2 - 10, ITEM_H, - hwnd_grp, std::ptr::null_mut(), h_instance, std::ptr::null(), - ); - SendMessageW(hwnd_item, WM_SETFONT, font, 1); - group_entries.push((radio.value.clone(), hwnd_item)); - inner_y += ITEM_H; - } - } - } - OptionGroupType::CheckboxGroup => { - if let Some(checkboxes) = &group.checkboxes { - let group_entries = checkbox_hwnds.entry(group.name.clone()).or_default(); - for checkbox in checkboxes.iter() { - let item_label = to_wstring(&checkbox.value); - let hwnd_item = CreateWindowExW( - 0, btn_class.as_ptr(), item_label.as_ptr(), - WS_CHILD | WS_VISIBLE | BS_AUTOCHECKBOX as u32, - MARGIN + 5, inner_y, panel_w - MARGIN * 2 - 10, ITEM_H, - hwnd_grp, std::ptr::null_mut(), h_instance, std::ptr::null(), - ); - SendMessageW(hwnd_item, WM_SETFONT, font, 1); - group_entries.push((checkbox.value.clone(), hwnd_item)); - inner_y += ITEM_H; - } - } - } - } - - y_pos += group_h + GROUP_GAP; - } - - // Restore saved selections if a save file was loaded - if let Some(ref save_data) = save { - if let Some(game_save) = save_data.get(&config.game.title) { - for (group_name, value) in game_save { - match value { - SaveValue::Single(val) => { - if let Some(items) = radio_hwnds.get(group_name) { - for (item_val, hwnd) in items { - SendMessageW(*hwnd, BM_SETCHECK, (item_val == val) as usize, 0); - } - } - } - SaveValue::Multiple(vals) => { - if let Some(items) = checkbox_hwnds.get(group_name) { - for (item_val, hwnd) in items { - SendMessageW(*hwnd, BM_SETCHECK, vals.contains(item_val) as usize, 0); - } - } - } - } - } - } - } - - // Store app state on the window for use in window_proc - let state = Box::new(AppState { config, radio_hwnds, checkbox_hwnds, save_path, hwnd_save, hwnd_apply }); - let state_ptr = Box::into_raw(state); - SetWindowLongPtrW(hwnd, GWLP_USERDATA, state_ptr as isize); - // Apply initial disabled_when constraints - (*state_ptr).apply_when_constraints(); - - // Message loop - let mut msg: MSG = std::mem::zeroed(); - while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { - TranslateMessage(&msg); - DispatchMessageW(&msg); - } - } -} - -unsafe extern "system" fn groupbox_proc( - hwnd: HWND, - msg: u32, - wparam: WPARAM, - lparam: LPARAM, -) -> LRESULT { - unsafe { - match msg { - WM_CTLCOLORSTATIC => { - let hdc = wparam as HDC; - SetTextColor(hdc, 0x00FFFFFF); - SetBkMode(hdc, TRANSPARENT as i32); - BG_BRUSH.load(Ordering::Relaxed) as usize as *mut std::ffi::c_void as LRESULT - } - WM_COMMAND => { - // Forward to the main window so apply_when_constraints is called - let parent = GetParent(hwnd); - SendMessageW(parent, WM_COMMAND, wparam, lparam); - 0 - } - _ => { - let orig = ORIG_GROUPBOX_PROC.load(Ordering::Relaxed); - CallWindowProcW(std::mem::transmute(orig), hwnd, msg, wparam, lparam) - } - } - } -} - -unsafe extern "system" fn window_proc( - hwnd: HWND, - msg: u32, - wparam: WPARAM, - lparam: LPARAM, -) -> LRESULT { - unsafe { - match msg { - WM_COMMAND => { - let state_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut AppState; - if !state_ptr.is_null() { - if wparam & 0xFFFF == ID_BTN_SAVE { - (*state_ptr).save_to_disk(); - } - (*state_ptr).apply_when_constraints(); - } - 0 - } - WM_DESTROY => { - // Free AppState - let state_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut AppState; - if !state_ptr.is_null() { - drop(Box::from_raw(state_ptr)); - } - PostQuitMessage(0); - 0 - } - // Paint GroupBox, RadioButton and Checkbox backgrounds to match the window - WM_CTLCOLORSTATIC => { - let hdc = wparam as HDC; - SetTextColor(hdc, 0x00FFFFFF); // white text - SetBkMode(hdc, TRANSPARENT as i32); - BG_BRUSH.load(Ordering::Relaxed) as usize as *mut std::ffi::c_void as LRESULT - } - _ => DefWindowProcW(hwnd, msg, wparam, lparam), - } - } + nwg::dispatch_thread_events(); } From ed580516a37c1be3ac8744e85188af13b3fbd58f Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:31:08 +0200 Subject: [PATCH 18/77] add addon comboBox and buttons --- src/ui.rs | 56 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 0c9ca33..5cae1ec 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,36 +7,60 @@ use nwg::NativeUi; use crate::{config::model::ConfigModel, save::SaveData}; #[derive(Default, NwgUi)] -pub struct BasicApp { - #[nwg_control(size: (300, 115), position: (300, 300), title: "Basic example", flags: "WINDOW|VISIBLE")] - #[nwg_events( OnWindowClose: [BasicApp::say_goodbye] )] +pub struct MulderApp { + #[nwg_control(size: (420, 100), position: (300, 300), title: "MulderConfig", flags: "WINDOW|VISIBLE")] + #[nwg_events(OnWindowClose: [MulderApp::on_close])] window: nwg::Window, - #[nwg_control(text: "Heisenberg", size: (280, 25), position: (10, 10))] - name_edit: nwg::TextInput, + #[nwg_control(size: (400, 25), position: (10, 10))] + addon_combo: nwg::ComboBox, - #[nwg_control(text: "Say my name", size: (280, 60), position: (10, 40))] - #[nwg_events( OnButtonClick: [BasicApp::say_hello] )] - hello_button: nwg::Button -} + #[nwg_control(text: "Save", size: (195, 35), position: (10, 45))] + #[nwg_events(OnButtonClick: [MulderApp::on_save])] + save_button: nwg::Button, -impl BasicApp { + #[nwg_control(text: "Apply", size: (195, 35), position: (215, 45))] + #[nwg_events(OnButtonClick: [MulderApp::on_apply])] + apply_button: nwg::Button, +} - fn say_hello(&self) { - nwg::simple_message("Hello", &format!("Hello {}", self.name_edit.text())); +impl MulderApp { + fn on_close(&self) { + nwg::stop_thread_dispatch(); } - fn say_goodbye(&self) { - nwg::simple_message("Goodbye", &format!("Goodbye {}", self.name_edit.text())); - nwg::stop_thread_dispatch(); + fn on_save(&self) { + // TODO } + fn on_apply(&self) { + // TODO + } } pub fn run(config: ConfigModel, save: Option, save_path: &'static str) { nwg::init().expect("Failed to init Native Windows GUI"); - let _app = BasicApp::build_ui(Default::default()).expect("Failed to build UI"); + let app = MulderApp::build_ui(Default::default()).expect("Failed to build UI"); + + app.window.set_text(&config.game.title); + + match &config.addons { + Some(addons) if !addons.is_empty() => { + for addon in addons { + app.addon_combo.push(addon.title.clone()); + } + app.addon_combo.set_selection(Some(0)); + } + _ => { + // No addons: hide the combo and shrink the window + app.addon_combo.set_visible(false); + let (w, _) = app.window.size(); + app.window.set_size(w, 65); + app.save_button.set_position(10, 10); + app.apply_button.set_position(215, 10); + } + } nwg::dispatch_thread_events(); } From d0a9808b006fdc36f3cc3f371336a4a713d01710 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:53:53 +0200 Subject: [PATCH 19/77] Add checkbox and radio groups --- src/ui.rs | 126 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 14 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 5cae1ec..13d1f3b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -4,7 +4,20 @@ extern crate native_windows_derive as nwd; use nwd::NwgUi; use nwg::NativeUi; -use crate::{config::model::ConfigModel, save::SaveData}; +use crate::{ + config::model::{ConfigModel, OptionGroupType}, + save::SaveData, +}; + +// Layout constants (pixels) +const WINDOW_W: i32 = 420; +const MARGIN: i32 = 10; +const COMBO_H: i32 = 25; +const GROUP_TITLE_H: i32 = 22; +const ITEM_H: i32 = 22; +const GROUP_PADDING: i32 = 6; // bottom padding inside frame +const GROUP_GAP: i32 = 5; // gap between groups +const BTN_H: i32 = 35; #[derive(Default, NwgUi)] pub struct MulderApp { @@ -28,39 +41,124 @@ impl MulderApp { fn on_close(&self) { nwg::stop_thread_dispatch(); } - fn on_save(&self) { // TODO } - fn on_apply(&self) { // TODO } } -pub fn run(config: ConfigModel, save: Option, save_path: &'static str) { +pub fn run(config: ConfigModel, _save: Option, _save_path: &'static str) { nwg::init().expect("Failed to init Native Windows GUI"); let app = MulderApp::build_ui(Default::default()).expect("Failed to build UI"); - app.window.set_text(&config.game.title); - match &config.addons { - Some(addons) if !addons.is_empty() => { + // --- Addon combo --- + let has_addons = config.addons.as_ref().map_or(false, |a| !a.is_empty()); + let mut y: i32 = MARGIN; + + if has_addons { + if let Some(addons) = &config.addons { for addon in addons { app.addon_combo.push(addon.title.clone()); } app.addon_combo.set_selection(Some(0)); } - _ => { - // No addons: hide the combo and shrink the window - app.addon_combo.set_visible(false); - let (w, _) = app.window.size(); - app.window.set_size(w, 65); - app.save_button.set_position(10, 10); - app.apply_button.set_position(215, 10); + y += COMBO_H + MARGIN; + } else { + app.addon_combo.set_visible(false); + } + + // --- Option groups (built dynamically; kept alive until dispatch ends) --- + // Each group = a bordered Frame + a Label title inside + radio/checkbox items inside. + // Parenting items to the Frame gives independent radio-button groups automatically. + let mut frames: Vec = Vec::new(); + let mut group_titles: Vec = Vec::new(); + let mut all_radios: Vec> = Vec::new(); + let mut all_checkboxes: Vec> = Vec::new(); + + let inner_w = WINDOW_W - MARGIN * 2 - 8; // usable width inside the frame + + for group_def in &config.option_groups { + let item_count = match &group_def.kind { + OptionGroupType::RadioGroup => group_def.radios.as_ref().map_or(0, |v| v.len()), + OptionGroupType::CheckboxGroup => group_def.checkboxes.as_ref().map_or(0, |v| v.len()), + } as i32; + + let frame_h = GROUP_TITLE_H + item_count * ITEM_H + GROUP_PADDING; + + let mut frame = nwg::Frame::default(); + nwg::Frame::builder() + .size((WINDOW_W - MARGIN * 2, frame_h)) + .position((MARGIN, y)) + .flags(nwg::FrameFlags::VISIBLE | nwg::FrameFlags::BORDER) + .parent(&app.window) + .build(&mut frame) + .expect("Failed to build Frame"); + + let mut title = nwg::Label::default(); + nwg::Label::builder() + .text(&group_def.name) + .size((inner_w, 18)) + .position((4, 2)) + .parent(&frame) + .build(&mut title) + .expect("Failed to build group title"); + + let mut item_y = GROUP_TITLE_H; + + match &group_def.kind { + OptionGroupType::RadioGroup => { + let mut row: Vec = Vec::new(); + for def in group_def.radios.as_deref().unwrap_or(&[]) { + let mut radio = nwg::RadioButton::default(); + nwg::RadioButton::builder() + .text(&def.value) + .size((inner_w, 20)) + .position((5, item_y)) + .parent(&frame) + .build(&mut radio) + .expect("Failed to build RadioButton"); + item_y += ITEM_H; + row.push(radio); + } + all_radios.push(row); + all_checkboxes.push(Vec::new()); + } + OptionGroupType::CheckboxGroup => { + let mut row: Vec = Vec::new(); + for def in group_def.checkboxes.as_deref().unwrap_or(&[]) { + let mut cb = nwg::CheckBox::default(); + nwg::CheckBox::builder() + .text(&def.value) + .size((inner_w, 20)) + .position((5, item_y)) + .parent(&frame) + .build(&mut cb) + .expect("Failed to build CheckBox"); + item_y += ITEM_H; + row.push(cb); + } + all_checkboxes.push(row); + all_radios.push(Vec::new()); + } } + + y += frame_h + GROUP_GAP; + group_titles.push(title); + frames.push(frame); } + // --- Save / Apply buttons at the bottom --- + let btn_w = (WINDOW_W - MARGIN * 3) / 2; + app.save_button.set_position(MARGIN, y); + app.save_button.set_size(btn_w as u32, BTN_H as u32); + app.apply_button.set_position(MARGIN * 2 + btn_w, y); + app.apply_button.set_size(btn_w as u32, BTN_H as u32); + + app.window.set_size(WINDOW_W as u32, (y + BTN_H + MARGIN) as u32); + nwg::dispatch_thread_events(); } From 2968bd540a9a9169cfb3608b1bb0933ee4a0f270 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:12:20 +0200 Subject: [PATCH 20/77] Apply constraints on click --- src/ui.rs | 121 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 15 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 13d1f3b..9d510f2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3,9 +3,12 @@ extern crate native_windows_derive as nwd; use nwd::NwgUi; use nwg::NativeUi; +use std::rc::Rc; +use std::cell::RefCell; use crate::{ - config::model::{ConfigModel, OptionGroupType}, + config::model::{ConfigModel, OptionGroupType, WhenGroup}, + config::when_resolver::{match_when, Selections, SelectionValue}, save::SaveData, }; @@ -19,6 +22,66 @@ const GROUP_PADDING: i32 = 6; // bottom padding inside frame const GROUP_GAP: i32 = 5; // gap between groups const BTN_H: i32 = 35; +// --- Runtime data for dynamic controls --- + +struct RadioItem { + value: String, + disabled_when: Option>, + ctrl: nwg::RadioButton, +} + +struct CheckItem { + value: String, + disabled_when: Option>, + ctrl: nwg::CheckBox, +} + +enum Group { + Radios { name: String, items: Vec }, + Checks { name: String, items: Vec }, +} + +fn apply_constraints(groups: &[Group]) { + // 1. Build current selections from control states + let mut selections: Selections = Selections::new(); + for group in groups { + match group { + Group::Radios { name, items } => { + if let Some(item) = items.iter().find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) { + selections.insert(name.clone(), SelectionValue::Single(item.value.clone())); + } + } + Group::Checks { name, items } => { + let checked: Vec = items.iter() + .filter(|i| i.ctrl.check_state() == nwg::CheckBoxState::Checked) + .map(|i| i.value.clone()) + .collect(); + selections.insert(name.clone(), SelectionValue::Multiple(checked)); + } + } + } + + // 2. Apply disabled_when to each item + for group in groups { + match group { + Group::Radios { items, .. } => { + for item in items { + if let Some(when) = &item.disabled_when { + item.ctrl.set_enabled(!match_when(when, &selections)); + } + } + } + Group::Checks { items, .. } => { + for item in items { + if let Some(when) = &item.disabled_when { + item.ctrl.set_enabled(!match_when(when, &selections)); + } + } + } + } + } +} + #[derive(Default, NwgUi)] pub struct MulderApp { #[nwg_control(size: (420, 100), position: (300, 300), title: "MulderConfig", flags: "WINDOW|VISIBLE")] @@ -71,15 +134,16 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st app.addon_combo.set_visible(false); } - // --- Option groups (built dynamically; kept alive until dispatch ends) --- - // Each group = a bordered Frame + a Label title inside + radio/checkbox items inside. - // Parenting items to the Frame gives independent radio-button groups automatically. + // --- Option groups --- + // Controls are stored inside an Rc> so the event handler closures + // (which must be 'static) can hold a shared reference to them. + let groups: Rc>> = Rc::new(RefCell::new(Vec::new())); + + // Non-item controls kept alive separately (frames + labels have no event logic) let mut frames: Vec = Vec::new(); let mut group_titles: Vec = Vec::new(); - let mut all_radios: Vec> = Vec::new(); - let mut all_checkboxes: Vec> = Vec::new(); - let inner_w = WINDOW_W - MARGIN * 2 - 8; // usable width inside the frame + let inner_w = WINDOW_W - MARGIN * 2 - 8; for group_def in &config.option_groups { let item_count = match &group_def.kind { @@ -111,7 +175,7 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st match &group_def.kind { OptionGroupType::RadioGroup => { - let mut row: Vec = Vec::new(); + let mut items: Vec = Vec::new(); for def in group_def.radios.as_deref().unwrap_or(&[]) { let mut radio = nwg::RadioButton::default(); nwg::RadioButton::builder() @@ -122,13 +186,16 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st .build(&mut radio) .expect("Failed to build RadioButton"); item_y += ITEM_H; - row.push(radio); + items.push(RadioItem { + value: def.value.clone(), + disabled_when: def.disabled_when.clone(), + ctrl: radio, + }); } - all_radios.push(row); - all_checkboxes.push(Vec::new()); + groups.borrow_mut().push(Group::Radios { name: group_def.name.clone(), items }); } OptionGroupType::CheckboxGroup => { - let mut row: Vec = Vec::new(); + let mut items: Vec = Vec::new(); for def in group_def.checkboxes.as_deref().unwrap_or(&[]) { let mut cb = nwg::CheckBox::default(); nwg::CheckBox::builder() @@ -139,10 +206,13 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st .build(&mut cb) .expect("Failed to build CheckBox"); item_y += ITEM_H; - row.push(cb); + items.push(CheckItem { + value: def.value.clone(), + disabled_when: def.disabled_when.clone(), + ctrl: cb, + }); } - all_checkboxes.push(row); - all_radios.push(Vec::new()); + groups.borrow_mut().push(Group::Checks { name: group_def.name.clone(), items }); } } @@ -151,6 +221,27 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st frames.push(frame); } + // --- Bind event handlers --- + // WM_COMMAND (BN_CLICKED) goes from each radio/checkbox to its direct parent (the Frame). + // So we subclass each Frame, not the individual controls. + let frame_handles: Vec = frames.iter().map(|f| f.handle.clone()).collect(); + + let window_handle = app.window.handle.clone(); + let _handlers: Vec = frame_handles + .iter() + .map(|handle| { + let gc = Rc::clone(&groups); + nwg::bind_event_handler(handle, &window_handle, move |evt, _, _| { + if evt == nwg::Event::OnButtonClick { + apply_constraints(&gc.borrow()); + } + }) + }) + .collect(); + + // Apply constraints once for the initial state + apply_constraints(&groups.borrow()); + // --- Save / Apply buttons at the bottom --- let btn_w = (WINDOW_W - MARGIN * 3) / 2; app.save_button.set_position(MARGIN, y); From 08a5a1dc93f1913a153db271ddd86532f22c20ca Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:13:43 +0200 Subject: [PATCH 21/77] Uncheck when Disabled --- src/ui.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui.rs b/src/ui.rs index 9d510f2..b468ba4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -74,7 +74,11 @@ fn apply_constraints(groups: &[Group]) { Group::Checks { items, .. } => { for item in items { if let Some(when) = &item.disabled_when { - item.ctrl.set_enabled(!match_when(when, &selections)); + let should_disable = match_when(when, &selections); + item.ctrl.set_enabled(!should_disable); + if should_disable { + item.ctrl.set_check_state(nwg::CheckBoxState::Unchecked); + } } } } From d693f9f6c35215ada61bb5696dba21e639641811 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:18:29 +0200 Subject: [PATCH 22/77] Add base game in addon_combo --- src/ui.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui.rs b/src/ui.rs index b468ba4..c5c1532 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -128,6 +128,7 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st if has_addons { if let Some(addons) = &config.addons { + app.addon_combo.push(config.game.title.clone()); for addon in addons { app.addon_combo.push(addon.title.clone()); } From b1bb9a4f5abd3451d75e901ac2f4c3c7787989f5 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:37:09 +0200 Subject: [PATCH 23/77] Apply when_constraints when combo change --- src/ui.rs | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index c5c1532..f48c1f2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,6 +6,7 @@ use nwg::NativeUi; use std::rc::Rc; use std::cell::RefCell; +use windows_sys::Win32::UI::WindowsAndMessaging::SendMessageW; use crate::{ config::model::{ConfigModel, OptionGroupType, WhenGroup}, config::when_resolver::{match_when, Selections, SelectionValue}, @@ -41,9 +42,10 @@ enum Group { Checks { name: String, items: Vec }, } -fn apply_constraints(groups: &[Group]) { +fn apply_constraints(title: &str, groups: &[Group]) { // 1. Build current selections from control states let mut selections: Selections = Selections::new(); + selections.insert("Title".to_string(), SelectionValue::Single(title.to_string())); for group in groups { match group { Group::Radios { name, items } => { @@ -231,21 +233,54 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st // So we subclass each Frame, not the individual controls. let frame_handles: Vec = frames.iter().map(|f| f.handle.clone()).collect(); + let title_rc: Rc> = Rc::new(RefCell::new(config.game.title.clone())); let window_handle = app.window.handle.clone(); + + // Frame handlers: radio/checkbox clicks let _handlers: Vec = frame_handles .iter() .map(|handle| { let gc = Rc::clone(&groups); + let tc = Rc::clone(&title_rc); nwg::bind_event_handler(handle, &window_handle, move |evt, _, _| { if evt == nwg::Event::OnButtonClick { - apply_constraints(&gc.borrow()); + apply_constraints(&tc.borrow(), &gc.borrow()); } }) }) .collect(); + // Combo handler: fires when addon selection changes. + // WM_COMMAND (CBN_SELCHANGE) goes to the parent window, so we subclass the window. + // CB_GETCURSEL = 0x0147 gives the selected index via SendMessageW. + const CB_GETCURSEL: u32 = 0x0147; + let _combo_handler: Option = if has_addons { + let mut titles: Vec = vec![config.game.title.clone()]; + if let Some(addons) = &config.addons { + titles.extend(addons.iter().map(|a| a.title.clone())); + } + let titles = Rc::new(titles); + let combo_hwnd: *mut std::ffi::c_void = match app.addon_combo.handle { + nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, + _ => std::ptr::null_mut(), + }; + let gc = Rc::clone(&groups); + let tc = Rc::clone(&title_rc); + Some(nwg::bind_event_handler(&window_handle, &window_handle, move |evt, _, _| { + if evt == nwg::Event::OnComboxBoxSelection { + let idx = unsafe { SendMessageW(combo_hwnd, CB_GETCURSEL, 0, 0) } as usize; + if idx < titles.len() { + *tc.borrow_mut() = titles[idx].clone(); + } + apply_constraints(&tc.borrow(), &gc.borrow()); + } + })) + } else { + None + }; + // Apply constraints once for the initial state - apply_constraints(&groups.borrow()); + apply_constraints(&title_rc.borrow(), &groups.borrow()); // --- Save / Apply buttons at the bottom --- let btn_w = (WINDOW_W - MARGIN * 3) / 2; From 67979614412793bba92aac92da261b52d7d7c472 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:38:35 +0200 Subject: [PATCH 24/77] Uncheck when disabled (radio) --- src/ui.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui.rs b/src/ui.rs index f48c1f2..c8872fd 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -69,7 +69,11 @@ fn apply_constraints(title: &str, groups: &[Group]) { Group::Radios { items, .. } => { for item in items { if let Some(when) = &item.disabled_when { - item.ctrl.set_enabled(!match_when(when, &selections)); + let should_disable = match_when(when, &selections); + item.ctrl.set_enabled(!should_disable); + if should_disable { + item.ctrl.set_check_state(nwg::RadioButtonState::Unchecked); + } } } } From 16b3163f0e6056d755ec612e31e38fbe610ff62f Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:56:58 +0200 Subject: [PATCH 25/77] Enable bottom buttons only when conf complete --- Cargo.toml | 2 +- src/ui.rs | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e520d0c..8fcb4aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,4 +12,4 @@ native-windows-derive = "1.0.5" native-windows-gui = "1.0.13" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_System_LibraryLoader"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse"] } diff --git a/src/ui.rs b/src/ui.rs index c8872fd..3224a30 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,6 +7,7 @@ use std::rc::Rc; use std::cell::RefCell; use windows_sys::Win32::UI::WindowsAndMessaging::SendMessageW; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; use crate::{ config::model::{ConfigModel, OptionGroupType, WhenGroup}, config::when_resolver::{match_when, Selections, SelectionValue}, @@ -42,6 +43,13 @@ enum Group { Checks { name: String, items: Vec }, } +fn is_config_complete(groups: &[Group]) -> bool { + groups.iter().all(|g| match g { + Group::Radios { items, .. } => items.iter().any(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked), + Group::Checks { .. } => true, + }) +} + fn apply_constraints(title: &str, groups: &[Group]) { // 1. Build current selections from control states let mut selections: Selections = Selections::new(); @@ -240,15 +248,22 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st let title_rc: Rc> = Rc::new(RefCell::new(config.game.title.clone())); let window_handle = app.window.handle.clone(); + let save_hwnd: isize = match app.save_button.handle { nwg::ControlHandle::Hwnd(h) => h as isize, _ => 0 }; + let apply_hwnd: isize = match app.apply_button.handle { nwg::ControlHandle::Hwnd(h) => h as isize, _ => 0 }; + let btn_handles = Rc::new((save_hwnd, apply_hwnd)); + // Frame handlers: radio/checkbox clicks let _handlers: Vec = frame_handles .iter() .map(|handle| { let gc = Rc::clone(&groups); let tc = Rc::clone(&title_rc); + let bh = Rc::clone(&btn_handles); nwg::bind_event_handler(handle, &window_handle, move |evt, _, _| { if evt == nwg::Event::OnButtonClick { apply_constraints(&tc.borrow(), &gc.borrow()); + let ok = i32::from(is_config_complete(&gc.borrow())); + unsafe { EnableWindow(bh.0 as _, ok); EnableWindow(bh.1 as _, ok); } } }) }) @@ -270,6 +285,7 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st }; let gc = Rc::clone(&groups); let tc = Rc::clone(&title_rc); + let bh = Rc::clone(&btn_handles); Some(nwg::bind_event_handler(&window_handle, &window_handle, move |evt, _, _| { if evt == nwg::Event::OnComboxBoxSelection { let idx = unsafe { SendMessageW(combo_hwnd, CB_GETCURSEL, 0, 0) } as usize; @@ -277,14 +293,18 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st *tc.borrow_mut() = titles[idx].clone(); } apply_constraints(&tc.borrow(), &gc.borrow()); + let ok = i32::from(is_config_complete(&gc.borrow())); + unsafe { EnableWindow(bh.0 as _, ok); EnableWindow(bh.1 as _, ok); } } })) } else { None }; - // Apply constraints once for the initial state + // Apply constraints and set initial button state apply_constraints(&title_rc.borrow(), &groups.borrow()); + let ok = i32::from(is_config_complete(&groups.borrow())); + unsafe { EnableWindow(save_hwnd as _, ok); EnableWindow(apply_hwnd as _, ok); } // --- Save / Apply buttons at the bottom --- let btn_w = (WINDOW_W - MARGIN * 3) / 2; From c328d9f067a186224bb40d82f1cf5feff942cf20 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:36:12 +0200 Subject: [PATCH 26/77] Save on click --- src/ui.rs | 79 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 3224a30..9f75b50 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -5,13 +5,14 @@ use nwd::NwgUi; use nwg::NativeUi; use std::rc::Rc; use std::cell::RefCell; +use std::collections::HashMap; use windows_sys::Win32::UI::WindowsAndMessaging::SendMessageW; use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; use crate::{ config::model::{ConfigModel, OptionGroupType, WhenGroup}, config::when_resolver::{match_when, Selections, SelectionValue}, - save::SaveData, + save::{SaveData, SaveValue, saver::SaveSaver}, }; // Layout constants (pixels) @@ -50,6 +51,27 @@ fn is_config_complete(groups: &[Group]) -> bool { }) } +fn collect_selections_for_save(groups: &[Group]) -> HashMap { + let mut map = HashMap::new(); + for group in groups { + match group { + Group::Radios { name, items } => { + if let Some(item) = items.iter().find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) { + map.insert(name.clone(), SaveValue::Single(item.value.clone())); + } + } + Group::Checks { name, items } => { + let checked: Vec = items.iter() + .filter(|i| i.ctrl.check_state() == nwg::CheckBoxState::Checked) + .map(|i| i.value.clone()) + .collect(); + map.insert(name.clone(), SaveValue::Multiple(checked)); + } + } + } + map +} + fn apply_constraints(title: &str, groups: &[Group]) { // 1. Build current selections from control states let mut selections: Selections = Selections::new(); @@ -130,7 +152,7 @@ impl MulderApp { } } -pub fn run(config: ConfigModel, _save: Option, _save_path: &'static str) { +pub fn run(config: ConfigModel, save: Option, save_path: &'static str) { nwg::init().expect("Failed to init Native Windows GUI"); let app = MulderApp::build_ui(Default::default()).expect("Failed to build UI"); @@ -248,11 +270,14 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st let title_rc: Rc> = Rc::new(RefCell::new(config.game.title.clone())); let window_handle = app.window.handle.clone(); + // Existing save data wrapped in Rc so the save handler can update it + let save_data: Rc> = Rc::new(RefCell::new(save.unwrap_or_default())); + let save_hwnd: isize = match app.save_button.handle { nwg::ControlHandle::Hwnd(h) => h as isize, _ => 0 }; let apply_hwnd: isize = match app.apply_button.handle { nwg::ControlHandle::Hwnd(h) => h as isize, _ => 0 }; let btn_handles = Rc::new((save_hwnd, apply_hwnd)); - // Frame handlers: radio/checkbox clicks + // Frame handlers: radio/checkbox clicks (BN_CLICKED goes to direct parent = Frame) let _handlers: Vec = frame_handles .iter() .map(|handle| { @@ -269,37 +294,45 @@ pub fn run(config: ConfigModel, _save: Option, _save_path: &'static st }) .collect(); - // Combo handler: fires when addon selection changes. - // WM_COMMAND (CBN_SELCHANGE) goes to the parent window, so we subclass the window. - // CB_GETCURSEL = 0x0147 gives the selected index via SendMessageW. + let save_handle = app.save_button.handle.clone(); + + // Window-level handler: combo selection + save button click const CB_GETCURSEL: u32 = 0x0147; - let _combo_handler: Option = if has_addons { - let mut titles: Vec = vec![config.game.title.clone()]; - if let Some(addons) = &config.addons { - titles.extend(addons.iter().map(|a| a.title.clone())); - } - let titles = Rc::new(titles); - let combo_hwnd: *mut std::ffi::c_void = match app.addon_combo.handle { - nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, - _ => std::ptr::null_mut(), - }; + let combo_hwnd: *mut std::ffi::c_void = match app.addon_combo.handle { + nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, + _ => std::ptr::null_mut(), + }; + let mut addon_titles: Vec = vec![config.game.title.clone()]; + if let Some(addons) = &config.addons { + addon_titles.extend(addons.iter().map(|a| a.title.clone())); + } + let addon_titles = Rc::new(addon_titles); + { let gc = Rc::clone(&groups); let tc = Rc::clone(&title_rc); let bh = Rc::clone(&btn_handles); - Some(nwg::bind_event_handler(&window_handle, &window_handle, move |evt, _, _| { + let sd = Rc::clone(&save_data); + let at = Rc::clone(&addon_titles); + let _window_handler = nwg::bind_event_handler(&window_handle, &window_handle, move |evt, _, handle| { if evt == nwg::Event::OnComboxBoxSelection { let idx = unsafe { SendMessageW(combo_hwnd, CB_GETCURSEL, 0, 0) } as usize; - if idx < titles.len() { - *tc.borrow_mut() = titles[idx].clone(); + if idx < at.len() { + *tc.borrow_mut() = at[idx].clone(); } apply_constraints(&tc.borrow(), &gc.borrow()); let ok = i32::from(is_config_complete(&gc.borrow())); unsafe { EnableWindow(bh.0 as _, ok); EnableWindow(bh.1 as _, ok); } } - })) - } else { - None - }; + if evt == nwg::Event::OnButtonClick && handle == save_handle { + let selections = collect_selections_for_save(&gc.borrow()); + sd.borrow_mut().insert(tc.borrow().clone(), selections); + if let Err(e) = SaveSaver::save(save_path, &sd.borrow()) { + nwg::simple_message("Save error", &e); + } + } + }); + std::mem::forget(_window_handler); + } // Apply constraints and set initial button state apply_constraints(&title_rc.borrow(), &groups.borrow()); From 908f0d482d3333e2006329898f699f70d4bcd962 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:37:08 +0200 Subject: [PATCH 27/77] Save success messagebox --- src/ui.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index 9f75b50..c9958b5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -328,6 +328,8 @@ pub fn run(config: ConfigModel, save: Option, save_path: &'static str) sd.borrow_mut().insert(tc.borrow().clone(), selections); if let Err(e) = SaveSaver::save(save_path, &sd.borrow()) { nwg::simple_message("Save error", &e); + } else { + nwg::simple_message("Saved", "Configuration saved successfully."); } } }); From 5640fd0899e07da646f740cc6576d223e1fbb334 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:05:10 +0200 Subject: [PATCH 28/77] Load save on start --- src/ui.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index c9958b5..21ba60b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -44,6 +44,45 @@ enum Group { Checks { name: String, items: Vec }, } +fn load_saved_state(title: &str, save_data: &SaveData, groups: &[Group]) { + let saved = save_data.get(title); + for group in groups { + match group { + Group::Radios { name, items } => { + for item in items { + item.ctrl.set_check_state(nwg::RadioButtonState::Unchecked); + } + if let Some(SaveValue::Single(val)) = saved.and_then(|s| s.get(name)) { + for item in items { + if item.value.eq_ignore_ascii_case(val) { + item.ctrl.set_check_state(nwg::RadioButtonState::Checked); + break; + } + } + } + } + Group::Checks { name, items } => { + let checked_vals: Vec<&str> = saved + .and_then(|s| s.get(name)) + .and_then(|v| if let SaveValue::Multiple(list) = v { + Some(list.iter().map(|s| s.as_str()).collect()) + } else { + None + }) + .unwrap_or_default(); + for item in items { + let state = if checked_vals.iter().any(|v| v.eq_ignore_ascii_case(&item.value)) { + nwg::CheckBoxState::Checked + } else { + nwg::CheckBoxState::Unchecked + }; + item.ctrl.set_check_state(state); + } + } + } + } +} + fn is_config_complete(groups: &[Group]) -> bool { groups.iter().all(|g| match g { Group::Radios { items, .. } => items.iter().any(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked), @@ -319,6 +358,7 @@ pub fn run(config: ConfigModel, save: Option, save_path: &'static str) if idx < at.len() { *tc.borrow_mut() = at[idx].clone(); } + load_saved_state(&tc.borrow(), &sd.borrow(), &gc.borrow()); apply_constraints(&tc.borrow(), &gc.borrow()); let ok = i32::from(is_config_complete(&gc.borrow())); unsafe { EnableWindow(bh.0 as _, ok); EnableWindow(bh.1 as _, ok); } @@ -337,6 +377,7 @@ pub fn run(config: ConfigModel, save: Option, save_path: &'static str) } // Apply constraints and set initial button state + load_saved_state(&title_rc.borrow(), &save_data.borrow(), &groups.borrow()); apply_constraints(&title_rc.borrow(), &groups.borrow()); let ok = i32::from(is_config_complete(&groups.borrow())); unsafe { EnableWindow(save_hwnd as _, ok); EnableWindow(apply_hwnd as _, ok); } From eefda613ad68dc7e356188ff24141b2722917ea3 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:33:47 +0200 Subject: [PATCH 29/77] Handle (steam) -addon argument --- src/addon_detector.rs | 19 +++++++++++++++++++ src/main.rs | 1 + src/ui.rs | 14 ++++++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 src/addon_detector.rs diff --git a/src/addon_detector.rs b/src/addon_detector.rs new file mode 100644 index 0000000..1ffa20d --- /dev/null +++ b/src/addon_detector.rs @@ -0,0 +1,19 @@ +use std::env; +use crate::config::model::Addon; + +/// Parses `-addon ` from the command-line arguments. +/// Returns the title of the first addon in `addons` whose `steam_id` matches, +/// or `None` if the flag is absent or no addon matches. +pub fn detect_addon(addons: &[Addon]) -> Option { + let args: Vec = env::args().collect(); + + let steam_id = args + .windows(2) + .find(|w| w[0] == "-addon") + .and_then(|w| w[1].parse::().ok())?; + + addons + .iter() + .find(|a| a.steam_id == steam_id) + .map(|a| a.title.clone()) +} diff --git a/src/main.rs b/src/main.rs index 042d92e..a3f5782 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod addon_detector; mod config; mod error; mod mode_detector; diff --git a/src/ui.rs b/src/ui.rs index 21ba60b..9f08945 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use windows_sys::Win32::UI::WindowsAndMessaging::SendMessageW; use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; use crate::{ + addon_detector::detect_addon, config::model::{ConfigModel, OptionGroupType, WhenGroup}, config::when_resolver::{match_when, Selections, SelectionValue}, save::{SaveData, SaveValue, saver::SaveSaver}, @@ -201,13 +202,20 @@ pub fn run(config: ConfigModel, save: Option, save_path: &'static str) let has_addons = config.addons.as_ref().map_or(false, |a| !a.is_empty()); let mut y: i32 = MARGIN; + // Detect -addon CLI argument + let detected_title: Option = config.addons.as_deref() + .and_then(|addons| detect_addon(addons)); + if has_addons { if let Some(addons) = &config.addons { app.addon_combo.push(config.game.title.clone()); for addon in addons { app.addon_combo.push(addon.title.clone()); } - app.addon_combo.set_selection(Some(0)); + let initial_idx = detected_title.as_deref() + .and_then(|dt| addons.iter().position(|a| a.title == dt).map(|i| i + 1)) + .unwrap_or(0); + app.addon_combo.set_selection(Some(initial_idx)); } y += COMBO_H + MARGIN; } else { @@ -306,7 +314,9 @@ pub fn run(config: ConfigModel, save: Option, save_path: &'static str) // So we subclass each Frame, not the individual controls. let frame_handles: Vec = frames.iter().map(|f| f.handle.clone()).collect(); - let title_rc: Rc> = Rc::new(RefCell::new(config.game.title.clone())); + let title_rc: Rc> = Rc::new(RefCell::new( + detected_title.unwrap_or_else(|| config.game.title.clone()) + )); let window_handle = app.window.handle.clone(); // Existing save data wrapped in Rc so the save handler can update it From 19a0bb365d3d2f722cad27b41182ea24f1fb814e Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:40:53 +0200 Subject: [PATCH 30/77] Add context --- src/context.rs | 30 +++++++++++++++++++ src/main.rs | 23 ++++++++++----- src/ui.rs | 79 ++++++++++++++++++++++---------------------------- 3 files changed, 80 insertions(+), 52 deletions(-) create mode 100644 src/context.rs diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..2f7ce4a --- /dev/null +++ b/src/context.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; +use crate::config::model::ConfigModel; +use crate::save::{SaveData, SaveValue}; + +pub struct AppContext { + pub config: ConfigModel, + pub save_data: SaveData, + pub save_path: String, + pub active_title: String, + pub selections: HashMap, +} + +impl AppContext { + pub fn new(config: ConfigModel, save_data: SaveData, save_path: String, active_title: String) -> Self { + let selections = save_data.get(&active_title).cloned().unwrap_or_default(); + AppContext { config, save_data, save_path, active_title, selections } + } + + /// Switch the active addon/game and resolve the matching saved selections. + pub fn switch_addon(&mut self, title: String) { + self.selections = self.save_data.get(&title).cloned().unwrap_or_default(); + self.active_title = title; + } + + /// Persist the current UI selections into save_data under active_title. + pub fn save_selections(&mut self, selections: HashMap) { + self.selections = selections.clone(); + self.save_data.insert(self.active_title.clone(), selections); + } +} diff --git a/src/main.rs b/src/main.rs index a3f5782..b451065 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,12 +2,15 @@ mod addon_detector; mod config; +mod context; mod error; mod mode_detector; mod save; mod ui; +use addon_detector::detect_addon; use config::loader::ConfigLoader; +use context::AppContext; use error::{ask_delete_save, fatal}; use mode_detector::{detect_mode, Mode}; use save::loader::SaveLoader; @@ -20,11 +23,11 @@ fn main() { let config = ConfigLoader::load("MulderConfig.json") .unwrap_or_else(|e| fatal(&format!("Failed to load config:\n{e}"))); - let save = if std::path::Path::new(SAVE_PATH).exists() { + let save_data = if std::path::Path::new(SAVE_PATH).exists() { let data = SaveLoader::load(SAVE_PATH) .unwrap_or_else(|e| fatal(&format!("Failed to load save file:\n{e}"))); match SaveValidator::validate(&data, &config) { - Ok(()) => Some(data), + Ok(()) => data, Err(e) => { let msg = format!( "Save file is invalid:\n{e}\n\nDelete the save file and continue?\n(Choosing No will close the application)" @@ -32,21 +35,27 @@ fn main() { if ask_delete_save(&msg) { std::fs::remove_file(SAVE_PATH) .unwrap_or_else(|e| fatal(&format!("Failed to delete save file:\n{e}"))); - None + Default::default() } else { std::process::exit(0); } } } } else { - None + Default::default() }; + let active_title = config.addons.as_deref() + .and_then(|addons| detect_addon(addons)) + .unwrap_or_else(|| config.game.title.clone()); + + let ctx = AppContext::new(config, save_data, SAVE_PATH.to_string(), active_title); + let mode = detect_mode(); match mode { - Mode::Config => { ui::run(config, save, SAVE_PATH); } - Mode::Apply => { println!("Applying conf for Game: {}", config.game.title); } - Mode::Launch => { println!("Launch Game: {}", config.game.title); } + Mode::Config => { ui::run(ctx); } + Mode::Apply => { println!("Applying conf for Game: {}", ctx.config.game.title); } + Mode::Launch => { println!("Launch Game: {}", ctx.config.game.title); } } } diff --git a/src/ui.rs b/src/ui.rs index 9f08945..cb48bb1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,10 +10,10 @@ use std::collections::HashMap; use windows_sys::Win32::UI::WindowsAndMessaging::SendMessageW; use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; use crate::{ - addon_detector::detect_addon, - config::model::{ConfigModel, OptionGroupType, WhenGroup}, + context::AppContext, + config::model::{OptionGroupType, WhenGroup}, config::when_resolver::{match_when, Selections, SelectionValue}, - save::{SaveData, SaveValue, saver::SaveSaver}, + save::{SaveValue, saver::SaveSaver}, }; // Layout constants (pixels) @@ -45,15 +45,14 @@ enum Group { Checks { name: String, items: Vec }, } -fn load_saved_state(title: &str, save_data: &SaveData, groups: &[Group]) { - let saved = save_data.get(title); +fn load_saved_state(selections: &HashMap, groups: &[Group]) { for group in groups { match group { Group::Radios { name, items } => { for item in items { item.ctrl.set_check_state(nwg::RadioButtonState::Unchecked); } - if let Some(SaveValue::Single(val)) = saved.and_then(|s| s.get(name)) { + if let Some(SaveValue::Single(val)) = selections.get(name) { for item in items { if item.value.eq_ignore_ascii_case(val) { item.ctrl.set_check_state(nwg::RadioButtonState::Checked); @@ -63,8 +62,7 @@ fn load_saved_state(title: &str, save_data: &SaveData, groups: &[Group]) { } } Group::Checks { name, items } => { - let checked_vals: Vec<&str> = saved - .and_then(|s| s.get(name)) + let checked_vals: Vec<&str> = selections.get(name) .and_then(|v| if let SaveValue::Multiple(list) = v { Some(list.iter().map(|s| s.as_str()).collect()) } else { @@ -192,31 +190,30 @@ impl MulderApp { } } -pub fn run(config: ConfigModel, save: Option, save_path: &'static str) { +pub fn run(ctx: AppContext) { nwg::init().expect("Failed to init Native Windows GUI"); let app = MulderApp::build_ui(Default::default()).expect("Failed to build UI"); - app.window.set_text(&config.game.title); + app.window.set_text(&ctx.config.game.title); // --- Addon combo --- - let has_addons = config.addons.as_ref().map_or(false, |a| !a.is_empty()); + let has_addons = ctx.config.addons.as_ref().map_or(false, |a| !a.is_empty()); let mut y: i32 = MARGIN; - // Detect -addon CLI argument - let detected_title: Option = config.addons.as_deref() - .and_then(|addons| detect_addon(addons)); + let mut addon_titles: Vec = vec![ctx.config.game.title.clone()]; + if let Some(addons) = &ctx.config.addons { + addon_titles.extend(addons.iter().map(|a| a.title.clone())); + } if has_addons { - if let Some(addons) = &config.addons { - app.addon_combo.push(config.game.title.clone()); - for addon in addons { - app.addon_combo.push(addon.title.clone()); - } - let initial_idx = detected_title.as_deref() - .and_then(|dt| addons.iter().position(|a| a.title == dt).map(|i| i + 1)) - .unwrap_or(0); - app.addon_combo.set_selection(Some(initial_idx)); + for title in &addon_titles { + app.addon_combo.push(title.clone()); } + let initial_idx = ctx.config.addons.as_deref().unwrap_or(&[]) + .iter().position(|a| a.title == ctx.active_title) + .map(|i| i + 1) + .unwrap_or(0); + app.addon_combo.set_selection(Some(initial_idx)); y += COMBO_H + MARGIN; } else { app.addon_combo.set_visible(false); @@ -233,7 +230,7 @@ pub fn run(config: ConfigModel, save: Option, save_path: &'static str) let inner_w = WINDOW_W - MARGIN * 2 - 8; - for group_def in &config.option_groups { + for group_def in &ctx.config.option_groups { let item_count = match &group_def.kind { OptionGroupType::RadioGroup => group_def.radios.as_ref().map_or(0, |v| v.len()), OptionGroupType::CheckboxGroup => group_def.checkboxes.as_ref().map_or(0, |v| v.len()), @@ -313,14 +310,10 @@ pub fn run(config: ConfigModel, save: Option, save_path: &'static str) // WM_COMMAND (BN_CLICKED) goes from each radio/checkbox to its direct parent (the Frame). // So we subclass each Frame, not the individual controls. let frame_handles: Vec = frames.iter().map(|f| f.handle.clone()).collect(); - - let title_rc: Rc> = Rc::new(RefCell::new( - detected_title.unwrap_or_else(|| config.game.title.clone()) - )); let window_handle = app.window.handle.clone(); - // Existing save data wrapped in Rc so the save handler can update it - let save_data: Rc> = Rc::new(RefCell::new(save.unwrap_or_default())); + // Wrap AppContext in Rc for shared mutation across closures + let ctx_rc: Rc> = Rc::new(RefCell::new(ctx)); let save_hwnd: isize = match app.save_button.handle { nwg::ControlHandle::Hwnd(h) => h as isize, _ => 0 }; let apply_hwnd: isize = match app.apply_button.handle { nwg::ControlHandle::Hwnd(h) => h as isize, _ => 0 }; @@ -331,11 +324,11 @@ pub fn run(config: ConfigModel, save: Option, save_path: &'static str) .iter() .map(|handle| { let gc = Rc::clone(&groups); - let tc = Rc::clone(&title_rc); + let cx = Rc::clone(&ctx_rc); let bh = Rc::clone(&btn_handles); nwg::bind_event_handler(handle, &window_handle, move |evt, _, _| { if evt == nwg::Event::OnButtonClick { - apply_constraints(&tc.borrow(), &gc.borrow()); + apply_constraints(&cx.borrow().active_title, &gc.borrow()); let ok = i32::from(is_config_complete(&gc.borrow())); unsafe { EnableWindow(bh.0 as _, ok); EnableWindow(bh.1 as _, ok); } } @@ -351,32 +344,28 @@ pub fn run(config: ConfigModel, save: Option, save_path: &'static str) nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, _ => std::ptr::null_mut(), }; - let mut addon_titles: Vec = vec![config.game.title.clone()]; - if let Some(addons) = &config.addons { - addon_titles.extend(addons.iter().map(|a| a.title.clone())); - } let addon_titles = Rc::new(addon_titles); { let gc = Rc::clone(&groups); - let tc = Rc::clone(&title_rc); + let cx = Rc::clone(&ctx_rc); let bh = Rc::clone(&btn_handles); - let sd = Rc::clone(&save_data); let at = Rc::clone(&addon_titles); let _window_handler = nwg::bind_event_handler(&window_handle, &window_handle, move |evt, _, handle| { if evt == nwg::Event::OnComboxBoxSelection { let idx = unsafe { SendMessageW(combo_hwnd, CB_GETCURSEL, 0, 0) } as usize; if idx < at.len() { - *tc.borrow_mut() = at[idx].clone(); + cx.borrow_mut().switch_addon(at[idx].clone()); } - load_saved_state(&tc.borrow(), &sd.borrow(), &gc.borrow()); - apply_constraints(&tc.borrow(), &gc.borrow()); + load_saved_state(&cx.borrow().selections, &gc.borrow()); + apply_constraints(&cx.borrow().active_title, &gc.borrow()); let ok = i32::from(is_config_complete(&gc.borrow())); unsafe { EnableWindow(bh.0 as _, ok); EnableWindow(bh.1 as _, ok); } } if evt == nwg::Event::OnButtonClick && handle == save_handle { let selections = collect_selections_for_save(&gc.borrow()); - sd.borrow_mut().insert(tc.borrow().clone(), selections); - if let Err(e) = SaveSaver::save(save_path, &sd.borrow()) { + cx.borrow_mut().save_selections(selections); + let ctx = cx.borrow(); + if let Err(e) = SaveSaver::save(&ctx.save_path, &ctx.save_data) { nwg::simple_message("Save error", &e); } else { nwg::simple_message("Saved", "Configuration saved successfully."); @@ -387,8 +376,8 @@ pub fn run(config: ConfigModel, save: Option, save_path: &'static str) } // Apply constraints and set initial button state - load_saved_state(&title_rc.borrow(), &save_data.borrow(), &groups.borrow()); - apply_constraints(&title_rc.borrow(), &groups.borrow()); + load_saved_state(&ctx_rc.borrow().selections, &groups.borrow()); + apply_constraints(&ctx_rc.borrow().active_title, &groups.borrow()); let ok = i32::from(is_config_complete(&groups.borrow())); unsafe { EnableWindow(save_hwnd as _, ok); EnableWindow(apply_hwnd as _, ok); } From 4a60313c561ed7119bc5371e430230a12562d011 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:49:42 +0200 Subject: [PATCH 31/77] remove useless stubs --- src/ui.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index cb48bb1..b1dc1e9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -170,11 +170,9 @@ pub struct MulderApp { addon_combo: nwg::ComboBox, #[nwg_control(text: "Save", size: (195, 35), position: (10, 45))] - #[nwg_events(OnButtonClick: [MulderApp::on_save])] save_button: nwg::Button, #[nwg_control(text: "Apply", size: (195, 35), position: (215, 45))] - #[nwg_events(OnButtonClick: [MulderApp::on_apply])] apply_button: nwg::Button, } @@ -182,12 +180,6 @@ impl MulderApp { fn on_close(&self) { nwg::stop_thread_dispatch(); } - fn on_save(&self) { - // TODO - } - fn on_apply(&self) { - // TODO - } } pub fn run(ctx: AppContext) { From 5494a42b6d2d020a99fa3c8e14ef15cdffd7e3c3 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:00:02 +0200 Subject: [PATCH 32/77] Center window --- src/ui.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index b1dc1e9..0c1b220 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,7 +7,7 @@ use std::rc::Rc; use std::cell::RefCell; use std::collections::HashMap; -use windows_sys::Win32::UI::WindowsAndMessaging::SendMessageW; +use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN}; use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; use crate::{ context::AppContext, @@ -380,7 +380,13 @@ pub fn run(ctx: AppContext) { app.apply_button.set_position(MARGIN * 2 + btn_w, y); app.apply_button.set_size(btn_w as u32, BTN_H as u32); - app.window.set_size(WINDOW_W as u32, (y + BTN_H + MARGIN) as u32); + let win_h = y + BTN_H + MARGIN; + app.window.set_size(WINDOW_W as u32, win_h as u32); + + // Center on screen + let screen_w = unsafe { GetSystemMetrics(SM_CXSCREEN) }; + let screen_h = unsafe { GetSystemMetrics(SM_CYSCREEN) }; + app.window.set_position((screen_w - WINDOW_W) / 2, ((screen_h - win_h) / 2).max(0)); nwg::dispatch_thread_events(); } From bbda774397cbecc57e48a25587d5658f10495e30 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:11:04 +0200 Subject: [PATCH 33/77] hide window before center --- src/ui.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui.rs b/src/ui.rs index 0c1b220..7d6ee6a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -162,7 +162,7 @@ fn apply_constraints(title: &str, groups: &[Group]) { #[derive(Default, NwgUi)] pub struct MulderApp { - #[nwg_control(size: (420, 100), position: (300, 300), title: "MulderConfig", flags: "WINDOW|VISIBLE")] + #[nwg_control(size: (420, 100), position: (0, 0), title: "MulderConfig", flags: "WINDOW")] #[nwg_events(OnWindowClose: [MulderApp::on_close])] window: nwg::Window, @@ -387,6 +387,7 @@ pub fn run(ctx: AppContext) { let screen_w = unsafe { GetSystemMetrics(SM_CXSCREEN) }; let screen_h = unsafe { GetSystemMetrics(SM_CYSCREEN) }; app.window.set_position((screen_w - WINDOW_W) / 2, ((screen_h - win_h) / 2).max(0)); + app.window.set_visible(true); nwg::dispatch_thread_events(); } From 0dc4138ea206a0fb327e6b1412455762f66bd07d Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:45:48 +0200 Subject: [PATCH 34/77] Add icon --- Cargo.lock | 10 ++++++++++ Cargo.toml | 3 +++ build.rs | 7 +++++++ src/ui.rs | 14 +++++++++++++- 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 build.rs diff --git a/Cargo.lock b/Cargo.lock index 19ace64..74b1b49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,7 @@ dependencies = [ "serde", "serde_json", "windows-sys", + "winres", ] [[package]] @@ -406,6 +407,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winres" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" +dependencies = [ + "toml", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 8fcb4aa..5dc394c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,6 @@ native-windows-gui = "1.0.13" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse"] } + +[build-dependencies] +winres = "0.1" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..4bfa982 --- /dev/null +++ b/build.rs @@ -0,0 +1,7 @@ +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + let mut res = winres::WindowsResource::new(); + res.set_icon("favicon.ico"); + res.compile().expect("Failed to compile Windows resources"); + } +} diff --git a/src/ui.rs b/src/ui.rs index 7d6ee6a..8c998ff 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,7 +7,8 @@ use std::rc::Rc; use std::cell::RefCell; use std::collections::HashMap; -use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN}; +use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN, LoadIconW, WM_SETICON}; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; use crate::{ context::AppContext, @@ -188,6 +189,17 @@ pub fn run(ctx: AppContext) { let app = MulderApp::build_ui(Default::default()).expect("Failed to build UI"); app.window.set_text(&ctx.config.game.title); + // Apply embedded app icon (resource ID 1, set by build.rs/winres) to the window + if let nwg::ControlHandle::Hwnd(hwnd) = app.window.handle { + let hicon = unsafe { LoadIconW(GetModuleHandleW(std::ptr::null()), 1 as *const u16) }; + if !hicon.is_null() { + unsafe { + SendMessageW(hwnd as _, WM_SETICON, 1, hicon as _); // ICON_BIG + SendMessageW(hwnd as _, WM_SETICON, 0, hicon as _); // ICON_SMALL + } + } + } + // --- Addon combo --- let has_addons = ctx.config.addons.as_ref().map_or(false, |a| !a.is_empty()); let mut y: i32 = MARGIN; From 015ee3c1b69116f12aa1a6bc05650f9e338db754 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:29:54 +0200 Subject: [PATCH 35/77] Add colors --- Cargo.toml | 2 +- src/ui.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5dc394c..0d0abce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ native-windows-derive = "1.0.5" native-windows-gui = "1.0.13" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_UI_Controls", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse"] } [build-dependencies] winres = "0.1" diff --git a/src/ui.rs b/src/ui.rs index 8c998ff..ea73d8d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,9 +7,12 @@ use std::rc::Rc; use std::cell::RefCell; use std::collections::HashMap; -use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN, LoadIconW, WM_SETICON}; +use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, GetSystemMetrics, GetClientRect, GetClassNameW, SystemParametersInfoW, NONCLIENTMETRICSW, SM_CXSCREEN, SM_CYSCREEN, LoadIconW, WM_SETICON}; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; -use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{EnableWindow, IsWindowEnabled}; +use windows_sys::Win32::UI::Controls::SetWindowTheme; +use windows_sys::Win32::Graphics::Gdi::{CreateSolidBrush, CreateFontIndirectW, SetTextColor, SetBkMode, FillRect}; +use windows_sys::Win32::Foundation::RECT; use crate::{ context::AppContext, config::model::{OptionGroupType, WhenGroup}, @@ -27,6 +30,18 @@ const GROUP_PADDING: i32 = 6; // bottom padding inside frame const GROUP_GAP: i32 = 5; // gap between groups const BTN_H: i32 = 35; +// Color scheme (Steam-like) +const COLOR_BG: u32 = 0x002E2825; // #25282E +const COLOR_BG_DARK: u32 = 0x002E2623; // #23262E +const COLOR_BG_LIGHT: u32 = 0x003F3530; // #30353F +const COLOR_TEXT_TITLES: u32 = 0x00DFDED1; // #D1DEDF — group titles +const COLOR_TEXT_LABELS: u32 = 0x00EDECEC; // #ECECED — radio/checkbox labels + +const WM_ERASEBKGND: u32 = 0x0014; +const WM_CTLCOLORSTATIC: u32 = 0x0138; +const WM_SETFONT: u32 = 0x0030; +const SPI_GETNONCLIENTMETRICS: u32 = 0x0029; + // --- Runtime data for dynamic controls --- struct RadioItem { @@ -186,6 +201,17 @@ impl MulderApp { pub fn run(ctx: AppContext) { nwg::init().expect("Failed to init Native Windows GUI"); + // Build normal + bold system fonts (Segoe UI from NCM metrics) + let (hfont_normal, hfont_bold) = unsafe { + let mut ncm: NONCLIENTMETRICSW = std::mem::zeroed(); + ncm.cbSize = std::mem::size_of::() as u32; + SystemParametersInfoW(SPI_GETNONCLIENTMETRICS, ncm.cbSize, &mut ncm as *mut _ as *mut _, 0); + let lf_normal = ncm.lfMessageFont; + let mut lf_bold = lf_normal; + lf_bold.lfWeight = 700; + (CreateFontIndirectW(&lf_normal), CreateFontIndirectW(&lf_bold)) + }; + let app = MulderApp::build_ui(Default::default()).expect("Failed to build UI"); app.window.set_text(&ctx.config.game.title); @@ -259,6 +285,9 @@ pub fn run(ctx: AppContext) { .parent(&frame) .build(&mut title) .expect("Failed to build group title"); + if let nwg::ControlHandle::Hwnd(h) = title.handle { + unsafe { SendMessageW(h as _, WM_SETFONT, hfont_bold as usize, 1); } + } let mut item_y = GROUP_TITLE_H; @@ -274,6 +303,11 @@ pub fn run(ctx: AppContext) { .parent(&frame) .build(&mut radio) .expect("Failed to build RadioButton"); + if let nwg::ControlHandle::Hwnd(h) = radio.handle { + let empty: [u16; 1] = [0]; + unsafe { SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); } + unsafe { SendMessageW(h as _, WM_SETFONT, hfont_normal as usize, 1); } + } item_y += ITEM_H; items.push(RadioItem { value: def.value.clone(), @@ -294,6 +328,11 @@ pub fn run(ctx: AppContext) { .parent(&frame) .build(&mut cb) .expect("Failed to build CheckBox"); + if let nwg::ControlHandle::Hwnd(h) = cb.handle { + let empty: [u16; 1] = [0]; + unsafe { SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); } + unsafe { SendMessageW(h as _, WM_SETFONT, hfont_normal as usize, 1); } + } item_y += ITEM_H; items.push(CheckItem { value: def.value.clone(), @@ -379,6 +418,60 @@ pub fn run(ctx: AppContext) { std::mem::forget(_window_handler); } + // --- Theming (Steam-like colors) --- + let brush_dark = unsafe { CreateSolidBrush(COLOR_BG_DARK) } as isize; + let brush_bg = unsafe { CreateSolidBrush(COLOR_BG) } as isize; + let brush_light = unsafe { CreateSolidBrush(COLOR_BG_LIGHT) } as isize; + + // Window background + let _theme_win = nwg::bind_raw_event_handler(&app.window.handle, 0x10001, move |hwnd, msg, w, _| { + if msg == WM_ERASEBKGND { + let hdc = w as *mut std::ffi::c_void; + let mut rect: RECT = unsafe { std::mem::zeroed() }; + unsafe { GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); } + unsafe { FillRect(hdc, &rect, brush_dark as usize as *mut std::ffi::c_void); } + return Some(1); + } + if msg == WM_CTLCOLORSTATIC { + let hdc = w as *mut std::ffi::c_void; + unsafe { SetTextColor(hdc, COLOR_TEXT_TITLES); SetBkMode(hdc, 1); } + return Some(brush_bg); + } + None + }).ok(); + std::mem::forget(_theme_win); + + // Frame backgrounds + labels/radios/checkboxes colors + let _theme_frames: Vec<_> = frames.iter().enumerate().map(|(i, frame)| { + nwg::bind_raw_event_handler(&frame.handle, 0x10002 + i, move |hwnd, msg, w, l| { + match msg { + WM_ERASEBKGND => { + let hdc = w as *mut std::ffi::c_void; + let mut rect: RECT = unsafe { std::mem::zeroed() }; + unsafe { GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); } + unsafe { FillRect(hdc, &rect, brush_light as usize as *mut std::ffi::c_void); } + Some(1) + } + WM_CTLCOLORSTATIC => { + let hdc = w as *mut std::ffi::c_void; + let ctrl_hwnd = l as usize as *mut std::ffi::c_void; + let is_enabled = unsafe { IsWindowEnabled(ctrl_hwnd) } != 0; + unsafe { SetBkMode(hdc, 1); } + if is_enabled { + let mut class: [u16; 16] = [0; 16]; + let n = unsafe { GetClassNameW(ctrl_hwnd, class.as_mut_ptr(), 16) }; + let is_button = n > 0 && class[0] == b'B' as u16; + let color = if is_button { COLOR_TEXT_LABELS } else { COLOR_TEXT_TITLES }; + unsafe { SetTextColor(hdc, color); } + } + Some(brush_light) + } + _ => None, + } + }).ok() + }).collect(); + std::mem::forget(_theme_frames); + // Apply constraints and set initial button state load_saved_state(&ctx_rc.borrow().selections, &groups.borrow()); apply_constraints(&ctx_rc.borrow().active_title, &groups.borrow()); From 91eb48b9ddfe471f54874f4e94c6244d55debe7f Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:29:04 +0200 Subject: [PATCH 36/77] Add Mulderland Watermark --- src/ui.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 6 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index ea73d8d..238f6b9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,12 +7,13 @@ use std::rc::Rc; use std::cell::RefCell; use std::collections::HashMap; -use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, GetSystemMetrics, GetClientRect, GetClassNameW, SystemParametersInfoW, NONCLIENTMETRICSW, SM_CXSCREEN, SM_CYSCREEN, LoadIconW, WM_SETICON}; +use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, GetSystemMetrics, GetClientRect, GetClassNameW, SystemParametersInfoW, NONCLIENTMETRICSW, SM_CXSCREEN, SM_CYSCREEN, LoadIconW, WM_SETICON, CreateWindowExW, WS_CHILD, WS_VISIBLE, LoadCursorW, SetCursor}; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; use windows_sys::Win32::UI::Input::KeyboardAndMouse::{EnableWindow, IsWindowEnabled}; use windows_sys::Win32::UI::Controls::SetWindowTheme; use windows_sys::Win32::Graphics::Gdi::{CreateSolidBrush, CreateFontIndirectW, SetTextColor, SetBkMode, FillRect}; use windows_sys::Win32::Foundation::RECT; +use windows_sys::Win32::UI::Shell::ShellExecuteW; use crate::{ context::AppContext, config::model::{OptionGroupType, WhenGroup}, @@ -29,6 +30,9 @@ const ITEM_H: i32 = 22; const GROUP_PADDING: i32 = 6; // bottom padding inside frame const GROUP_GAP: i32 = 5; // gap between groups const BTN_H: i32 = 35; +const FOOTER_LABEL_H: i32 = 12; +const FOOTER_LINK_H: i32 = 12; +const FOOTER_GAP: i32 = 4; // Color scheme (Steam-like) const COLOR_BG: u32 = 0x002E2825; // #25282E @@ -37,9 +41,12 @@ const COLOR_BG_LIGHT: u32 = 0x003F3530; // #30353F const COLOR_TEXT_TITLES: u32 = 0x00DFDED1; // #D1DEDF — group titles const COLOR_TEXT_LABELS: u32 = 0x00EDECEC; // #ECECED — radio/checkbox labels -const WM_ERASEBKGND: u32 = 0x0014; +const WM_ERASEBKGND: u32 = 0x0014; const WM_CTLCOLORSTATIC: u32 = 0x0138; const WM_SETFONT: u32 = 0x0030; +const WM_LBUTTONUP: u32 = 0x0202; +const WM_SETCURSOR: u32 = 0x0020; +const SS_ETCHEDHORZ: u32 = 0x0010; const SPI_GETNONCLIENTMETRICS: u32 = 0x0029; // --- Runtime data for dynamic controls --- @@ -201,15 +208,19 @@ impl MulderApp { pub fn run(ctx: AppContext) { nwg::init().expect("Failed to init Native Windows GUI"); - // Build normal + bold system fonts (Segoe UI from NCM metrics) - let (hfont_normal, hfont_bold) = unsafe { + // Build normal + bold + small + link system fonts (Segoe UI from NCM metrics) + let (hfont_normal, hfont_bold, hfont_small, hfont_link) = unsafe { let mut ncm: NONCLIENTMETRICSW = std::mem::zeroed(); ncm.cbSize = std::mem::size_of::() as u32; SystemParametersInfoW(SPI_GETNONCLIENTMETRICS, ncm.cbSize, &mut ncm as *mut _ as *mut _, 0); let lf_normal = ncm.lfMessageFont; let mut lf_bold = lf_normal; lf_bold.lfWeight = 700; - (CreateFontIndirectW(&lf_normal), CreateFontIndirectW(&lf_bold)) + let mut lf_small = lf_normal; + lf_small.lfHeight = lf_normal.lfHeight + 2; // less negative = smaller (lfHeight is negative) + let mut lf_link = lf_small; + lf_link.lfUnderline = 1; + (CreateFontIndirectW(&lf_normal), CreateFontIndirectW(&lf_bold), CreateFontIndirectW(&lf_small), CreateFontIndirectW(&lf_link)) }; let app = MulderApp::build_ui(Default::default()).expect("Failed to build UI"); @@ -485,7 +496,63 @@ pub fn run(ctx: AppContext) { app.apply_button.set_position(MARGIN * 2 + btn_w, y); app.apply_button.set_size(btn_w as u32, BTN_H as u32); - let win_h = y + BTN_H + MARGIN; + let y_sep = y + BTN_H + 6; + let y_footer = y_sep + 8; + + // Horizontal separator line + let sep_class: Vec = "STATIC\0".encode_utf16().collect(); + let win_hwnd = match app.window.handle { nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, _ => std::ptr::null_mut() }; + unsafe { + CreateWindowExW(0, sep_class.as_ptr(), std::ptr::null(), WS_CHILD | WS_VISIBLE | SS_ETCHEDHORZ, + MARGIN, y_sep, WINDOW_W - MARGIN * 2, 2, + win_hwnd, std::ptr::null_mut(), GetModuleHandleW(std::ptr::null()), std::ptr::null()); + } + + let mut foot_text = nwg::Label::default(); + nwg::Label::builder() + .text("MulderConfig is part of the Mulderland project") + .size((WINDOW_W - MARGIN * 2, FOOTER_LABEL_H)) + .position((MARGIN, y_footer)) + .h_align(nwg::HTextAlign::Center) + .parent(&app.window) + .build(&mut foot_text) + .expect("Failed to build footer label"); + if let nwg::ControlHandle::Hwnd(h) = foot_text.handle { + unsafe { SendMessageW(h as _, WM_SETFONT, hfont_small as usize, 1); } + } + + let mut foot_link = nwg::Label::default(); + nwg::Label::builder() + .text("www.mulderland.com") + .size((WINDOW_W - MARGIN * 2, FOOTER_LINK_H)) + .position((MARGIN, y_footer + FOOTER_LABEL_H + FOOTER_GAP)) + .h_align(nwg::HTextAlign::Center) + .parent(&app.window) + .build(&mut foot_link) + .expect("Failed to build footer link"); + if let nwg::ControlHandle::Hwnd(h) = foot_link.handle { + unsafe { SendMessageW(h as _, WM_SETFONT, hfont_link as usize, 1); } + } + + let _foot_link_handler = nwg::bind_raw_event_handler(&foot_link.handle, 0x10011, move |_, msg, _, _| { + match msg { + WM_LBUTTONUP => { + let url: Vec = "https://www.mulderland.com?utm_source=MulderConfig\0".encode_utf16().collect(); + let op: Vec = "open\0".encode_utf16().collect(); + unsafe { ShellExecuteW(std::ptr::null_mut(), op.as_ptr(), url.as_ptr(), std::ptr::null(), std::ptr::null(), 1); } + None + } + WM_SETCURSOR => { + let hcur = unsafe { LoadCursorW(std::ptr::null_mut(), 32649 as *const u16) }; + unsafe { SetCursor(hcur); } + Some(1) + } + _ => None, + } + }); + std::mem::forget(_foot_link_handler); + + let win_h = y_footer + FOOTER_LABEL_H + FOOTER_GAP + FOOTER_LINK_H + MARGIN; app.window.set_size(WINDOW_W as u32, win_h as u32); // Center on screen From 634c7c5c9da4934902dfc3e4bbb95fc9b2c551b4 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:20:19 +0200 Subject: [PATCH 37/77] Split UI code --- src/ui.rs | 516 +++++++++++---------------------------------- src/ui/chrome.rs | 200 ++++++++++++++++++ src/ui/controls.rs | 139 ++++++++++++ 3 files changed, 461 insertions(+), 394 deletions(-) create mode 100644 src/ui/chrome.rs create mode 100644 src/ui/controls.rs diff --git a/src/ui.rs b/src/ui.rs index 238f6b9..4f99b14 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,24 +1,22 @@ extern crate native_windows_gui as nwg; extern crate native_windows_derive as nwd; +pub mod controls; +pub mod chrome; +use controls::{RadioItem, CheckItem, Group, load_saved_state, is_config_complete, collect_selections_for_save, apply_constraints}; + use nwd::NwgUi; use nwg::NativeUi; use std::rc::Rc; use std::cell::RefCell; -use std::collections::HashMap; -use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, GetSystemMetrics, GetClientRect, GetClassNameW, SystemParametersInfoW, NONCLIENTMETRICSW, SM_CXSCREEN, SM_CYSCREEN, LoadIconW, WM_SETICON, CreateWindowExW, WS_CHILD, WS_VISIBLE, LoadCursorW, SetCursor}; -use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; -use windows_sys::Win32::UI::Input::KeyboardAndMouse::{EnableWindow, IsWindowEnabled}; +use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN}; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; use windows_sys::Win32::UI::Controls::SetWindowTheme; -use windows_sys::Win32::Graphics::Gdi::{CreateSolidBrush, CreateFontIndirectW, SetTextColor, SetBkMode, FillRect}; -use windows_sys::Win32::Foundation::RECT; -use windows_sys::Win32::UI::Shell::ShellExecuteW; use crate::{ context::AppContext, - config::model::{OptionGroupType, WhenGroup}, - config::when_resolver::{match_when, Selections, SelectionValue}, - save::{SaveValue, saver::SaveSaver}, + config::model::OptionGroupType, + save::saver::SaveSaver, }; // Layout constants (pixels) @@ -30,158 +28,8 @@ const ITEM_H: i32 = 22; const GROUP_PADDING: i32 = 6; // bottom padding inside frame const GROUP_GAP: i32 = 5; // gap between groups const BTN_H: i32 = 35; -const FOOTER_LABEL_H: i32 = 12; -const FOOTER_LINK_H: i32 = 12; -const FOOTER_GAP: i32 = 4; - -// Color scheme (Steam-like) -const COLOR_BG: u32 = 0x002E2825; // #25282E -const COLOR_BG_DARK: u32 = 0x002E2623; // #23262E -const COLOR_BG_LIGHT: u32 = 0x003F3530; // #30353F -const COLOR_TEXT_TITLES: u32 = 0x00DFDED1; // #D1DEDF — group titles -const COLOR_TEXT_LABELS: u32 = 0x00EDECEC; // #ECECED — radio/checkbox labels - -const WM_ERASEBKGND: u32 = 0x0014; -const WM_CTLCOLORSTATIC: u32 = 0x0138; -const WM_SETFONT: u32 = 0x0030; -const WM_LBUTTONUP: u32 = 0x0202; -const WM_SETCURSOR: u32 = 0x0020; -const SS_ETCHEDHORZ: u32 = 0x0010; -const SPI_GETNONCLIENTMETRICS: u32 = 0x0029; - -// --- Runtime data for dynamic controls --- - -struct RadioItem { - value: String, - disabled_when: Option>, - ctrl: nwg::RadioButton, -} - -struct CheckItem { - value: String, - disabled_when: Option>, - ctrl: nwg::CheckBox, -} - -enum Group { - Radios { name: String, items: Vec }, - Checks { name: String, items: Vec }, -} - -fn load_saved_state(selections: &HashMap, groups: &[Group]) { - for group in groups { - match group { - Group::Radios { name, items } => { - for item in items { - item.ctrl.set_check_state(nwg::RadioButtonState::Unchecked); - } - if let Some(SaveValue::Single(val)) = selections.get(name) { - for item in items { - if item.value.eq_ignore_ascii_case(val) { - item.ctrl.set_check_state(nwg::RadioButtonState::Checked); - break; - } - } - } - } - Group::Checks { name, items } => { - let checked_vals: Vec<&str> = selections.get(name) - .and_then(|v| if let SaveValue::Multiple(list) = v { - Some(list.iter().map(|s| s.as_str()).collect()) - } else { - None - }) - .unwrap_or_default(); - for item in items { - let state = if checked_vals.iter().any(|v| v.eq_ignore_ascii_case(&item.value)) { - nwg::CheckBoxState::Checked - } else { - nwg::CheckBoxState::Unchecked - }; - item.ctrl.set_check_state(state); - } - } - } - } -} - -fn is_config_complete(groups: &[Group]) -> bool { - groups.iter().all(|g| match g { - Group::Radios { items, .. } => items.iter().any(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked), - Group::Checks { .. } => true, - }) -} -fn collect_selections_for_save(groups: &[Group]) -> HashMap { - let mut map = HashMap::new(); - for group in groups { - match group { - Group::Radios { name, items } => { - if let Some(item) = items.iter().find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) { - map.insert(name.clone(), SaveValue::Single(item.value.clone())); - } - } - Group::Checks { name, items } => { - let checked: Vec = items.iter() - .filter(|i| i.ctrl.check_state() == nwg::CheckBoxState::Checked) - .map(|i| i.value.clone()) - .collect(); - map.insert(name.clone(), SaveValue::Multiple(checked)); - } - } - } - map -} - -fn apply_constraints(title: &str, groups: &[Group]) { - // 1. Build current selections from control states - let mut selections: Selections = Selections::new(); - selections.insert("Title".to_string(), SelectionValue::Single(title.to_string())); - for group in groups { - match group { - Group::Radios { name, items } => { - if let Some(item) = items.iter().find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) { - selections.insert(name.clone(), SelectionValue::Single(item.value.clone())); - } - } - Group::Checks { name, items } => { - let checked: Vec = items.iter() - .filter(|i| i.ctrl.check_state() == nwg::CheckBoxState::Checked) - .map(|i| i.value.clone()) - .collect(); - selections.insert(name.clone(), SelectionValue::Multiple(checked)); - } - } - } - - // 2. Apply disabled_when to each item - for group in groups { - match group { - Group::Radios { items, .. } => { - for item in items { - if let Some(when) = &item.disabled_when { - let should_disable = match_when(when, &selections); - item.ctrl.set_enabled(!should_disable); - if should_disable { - item.ctrl.set_check_state(nwg::RadioButtonState::Unchecked); - } - } - } - } - Group::Checks { items, .. } => { - for item in items { - if let Some(when) = &item.disabled_when { - let should_disable = match_when(when, &selections); - item.ctrl.set_enabled(!should_disable); - if should_disable { - item.ctrl.set_check_state(nwg::CheckBoxState::Unchecked); - } - } - } - } - } - } -} +const WM_SETFONT: u32 = 0x0030; #[derive(Default, NwgUi)] pub struct MulderApp { @@ -208,34 +56,11 @@ impl MulderApp { pub fn run(ctx: AppContext) { nwg::init().expect("Failed to init Native Windows GUI"); - // Build normal + bold + small + link system fonts (Segoe UI from NCM metrics) - let (hfont_normal, hfont_bold, hfont_small, hfont_link) = unsafe { - let mut ncm: NONCLIENTMETRICSW = std::mem::zeroed(); - ncm.cbSize = std::mem::size_of::() as u32; - SystemParametersInfoW(SPI_GETNONCLIENTMETRICS, ncm.cbSize, &mut ncm as *mut _ as *mut _, 0); - let lf_normal = ncm.lfMessageFont; - let mut lf_bold = lf_normal; - lf_bold.lfWeight = 700; - let mut lf_small = lf_normal; - lf_small.lfHeight = lf_normal.lfHeight + 2; // less negative = smaller (lfHeight is negative) - let mut lf_link = lf_small; - lf_link.lfUnderline = 1; - (CreateFontIndirectW(&lf_normal), CreateFontIndirectW(&lf_bold), CreateFontIndirectW(&lf_small), CreateFontIndirectW(&lf_link)) - }; + let fonts = chrome::create_fonts(); let app = MulderApp::build_ui(Default::default()).expect("Failed to build UI"); app.window.set_text(&ctx.config.game.title); - - // Apply embedded app icon (resource ID 1, set by build.rs/winres) to the window - if let nwg::ControlHandle::Hwnd(hwnd) = app.window.handle { - let hicon = unsafe { LoadIconW(GetModuleHandleW(std::ptr::null()), 1 as *const u16) }; - if !hicon.is_null() { - unsafe { - SendMessageW(hwnd as _, WM_SETICON, 1, hicon as _); // ICON_BIG - SendMessageW(hwnd as _, WM_SETICON, 0, hicon as _); // ICON_SMALL - } - } - } + chrome::apply_icon(&app.window); // --- Addon combo --- let has_addons = ctx.config.addons.as_ref().map_or(false, |a| !a.is_empty()); @@ -261,104 +86,10 @@ pub fn run(ctx: AppContext) { } // --- Option groups --- - // Controls are stored inside an Rc> so the event handler closures - // (which must be 'static) can hold a shared reference to them. - let groups: Rc>> = Rc::new(RefCell::new(Vec::new())); - - // Non-item controls kept alive separately (frames + labels have no event logic) - let mut frames: Vec = Vec::new(); - let mut group_titles: Vec = Vec::new(); - - let inner_w = WINDOW_W - MARGIN * 2 - 8; - - for group_def in &ctx.config.option_groups { - let item_count = match &group_def.kind { - OptionGroupType::RadioGroup => group_def.radios.as_ref().map_or(0, |v| v.len()), - OptionGroupType::CheckboxGroup => group_def.checkboxes.as_ref().map_or(0, |v| v.len()), - } as i32; - - let frame_h = GROUP_TITLE_H + item_count * ITEM_H + GROUP_PADDING; - - let mut frame = nwg::Frame::default(); - nwg::Frame::builder() - .size((WINDOW_W - MARGIN * 2, frame_h)) - .position((MARGIN, y)) - .flags(nwg::FrameFlags::VISIBLE | nwg::FrameFlags::BORDER) - .parent(&app.window) - .build(&mut frame) - .expect("Failed to build Frame"); - - let mut title = nwg::Label::default(); - nwg::Label::builder() - .text(&group_def.name) - .size((inner_w, 18)) - .position((4, 2)) - .parent(&frame) - .build(&mut title) - .expect("Failed to build group title"); - if let nwg::ControlHandle::Hwnd(h) = title.handle { - unsafe { SendMessageW(h as _, WM_SETFONT, hfont_bold as usize, 1); } - } - - let mut item_y = GROUP_TITLE_H; - - match &group_def.kind { - OptionGroupType::RadioGroup => { - let mut items: Vec = Vec::new(); - for def in group_def.radios.as_deref().unwrap_or(&[]) { - let mut radio = nwg::RadioButton::default(); - nwg::RadioButton::builder() - .text(&def.value) - .size((inner_w, 20)) - .position((5, item_y)) - .parent(&frame) - .build(&mut radio) - .expect("Failed to build RadioButton"); - if let nwg::ControlHandle::Hwnd(h) = radio.handle { - let empty: [u16; 1] = [0]; - unsafe { SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); } - unsafe { SendMessageW(h as _, WM_SETFONT, hfont_normal as usize, 1); } - } - item_y += ITEM_H; - items.push(RadioItem { - value: def.value.clone(), - disabled_when: def.disabled_when.clone(), - ctrl: radio, - }); - } - groups.borrow_mut().push(Group::Radios { name: group_def.name.clone(), items }); - } - OptionGroupType::CheckboxGroup => { - let mut items: Vec = Vec::new(); - for def in group_def.checkboxes.as_deref().unwrap_or(&[]) { - let mut cb = nwg::CheckBox::default(); - nwg::CheckBox::builder() - .text(&def.value) - .size((inner_w, 20)) - .position((5, item_y)) - .parent(&frame) - .build(&mut cb) - .expect("Failed to build CheckBox"); - if let nwg::ControlHandle::Hwnd(h) = cb.handle { - let empty: [u16; 1] = [0]; - unsafe { SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); } - unsafe { SendMessageW(h as _, WM_SETFONT, hfont_normal as usize, 1); } - } - item_y += ITEM_H; - items.push(CheckItem { - value: def.value.clone(), - disabled_when: def.disabled_when.clone(), - ctrl: cb, - }); - } - groups.borrow_mut().push(Group::Checks { name: group_def.name.clone(), items }); - } - } - - y += frame_h + GROUP_GAP; - group_titles.push(title); - frames.push(frame); - } + let (groups_vec, frames, _group_titles, y) = build_option_groups( + &ctx.config.option_groups, &app.window, y, &fonts, + ); + let groups: Rc>> = Rc::new(RefCell::new(groups_vec)); // --- Bind event handlers --- // WM_COMMAND (BN_CLICKED) goes from each radio/checkbox to its direct parent (the Frame). @@ -429,59 +160,7 @@ pub fn run(ctx: AppContext) { std::mem::forget(_window_handler); } - // --- Theming (Steam-like colors) --- - let brush_dark = unsafe { CreateSolidBrush(COLOR_BG_DARK) } as isize; - let brush_bg = unsafe { CreateSolidBrush(COLOR_BG) } as isize; - let brush_light = unsafe { CreateSolidBrush(COLOR_BG_LIGHT) } as isize; - - // Window background - let _theme_win = nwg::bind_raw_event_handler(&app.window.handle, 0x10001, move |hwnd, msg, w, _| { - if msg == WM_ERASEBKGND { - let hdc = w as *mut std::ffi::c_void; - let mut rect: RECT = unsafe { std::mem::zeroed() }; - unsafe { GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); } - unsafe { FillRect(hdc, &rect, brush_dark as usize as *mut std::ffi::c_void); } - return Some(1); - } - if msg == WM_CTLCOLORSTATIC { - let hdc = w as *mut std::ffi::c_void; - unsafe { SetTextColor(hdc, COLOR_TEXT_TITLES); SetBkMode(hdc, 1); } - return Some(brush_bg); - } - None - }).ok(); - std::mem::forget(_theme_win); - - // Frame backgrounds + labels/radios/checkboxes colors - let _theme_frames: Vec<_> = frames.iter().enumerate().map(|(i, frame)| { - nwg::bind_raw_event_handler(&frame.handle, 0x10002 + i, move |hwnd, msg, w, l| { - match msg { - WM_ERASEBKGND => { - let hdc = w as *mut std::ffi::c_void; - let mut rect: RECT = unsafe { std::mem::zeroed() }; - unsafe { GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); } - unsafe { FillRect(hdc, &rect, brush_light as usize as *mut std::ffi::c_void); } - Some(1) - } - WM_CTLCOLORSTATIC => { - let hdc = w as *mut std::ffi::c_void; - let ctrl_hwnd = l as usize as *mut std::ffi::c_void; - let is_enabled = unsafe { IsWindowEnabled(ctrl_hwnd) } != 0; - unsafe { SetBkMode(hdc, 1); } - if is_enabled { - let mut class: [u16; 16] = [0; 16]; - let n = unsafe { GetClassNameW(ctrl_hwnd, class.as_mut_ptr(), 16) }; - let is_button = n > 0 && class[0] == b'B' as u16; - let color = if is_button { COLOR_TEXT_LABELS } else { COLOR_TEXT_TITLES }; - unsafe { SetTextColor(hdc, color); } - } - Some(brush_light) - } - _ => None, - } - }).ok() - }).collect(); - std::mem::forget(_theme_frames); + chrome::apply_theme(&app.window, &frames); // Apply constraints and set initial button state load_saved_state(&ctx_rc.borrow().selections, &groups.borrow()); @@ -496,63 +175,8 @@ pub fn run(ctx: AppContext) { app.apply_button.set_position(MARGIN * 2 + btn_w, y); app.apply_button.set_size(btn_w as u32, BTN_H as u32); - let y_sep = y + BTN_H + 6; - let y_footer = y_sep + 8; - - // Horizontal separator line - let sep_class: Vec = "STATIC\0".encode_utf16().collect(); - let win_hwnd = match app.window.handle { nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, _ => std::ptr::null_mut() }; - unsafe { - CreateWindowExW(0, sep_class.as_ptr(), std::ptr::null(), WS_CHILD | WS_VISIBLE | SS_ETCHEDHORZ, - MARGIN, y_sep, WINDOW_W - MARGIN * 2, 2, - win_hwnd, std::ptr::null_mut(), GetModuleHandleW(std::ptr::null()), std::ptr::null()); - } - - let mut foot_text = nwg::Label::default(); - nwg::Label::builder() - .text("MulderConfig is part of the Mulderland project") - .size((WINDOW_W - MARGIN * 2, FOOTER_LABEL_H)) - .position((MARGIN, y_footer)) - .h_align(nwg::HTextAlign::Center) - .parent(&app.window) - .build(&mut foot_text) - .expect("Failed to build footer label"); - if let nwg::ControlHandle::Hwnd(h) = foot_text.handle { - unsafe { SendMessageW(h as _, WM_SETFONT, hfont_small as usize, 1); } - } - - let mut foot_link = nwg::Label::default(); - nwg::Label::builder() - .text("www.mulderland.com") - .size((WINDOW_W - MARGIN * 2, FOOTER_LINK_H)) - .position((MARGIN, y_footer + FOOTER_LABEL_H + FOOTER_GAP)) - .h_align(nwg::HTextAlign::Center) - .parent(&app.window) - .build(&mut foot_link) - .expect("Failed to build footer link"); - if let nwg::ControlHandle::Hwnd(h) = foot_link.handle { - unsafe { SendMessageW(h as _, WM_SETFONT, hfont_link as usize, 1); } - } - - let _foot_link_handler = nwg::bind_raw_event_handler(&foot_link.handle, 0x10011, move |_, msg, _, _| { - match msg { - WM_LBUTTONUP => { - let url: Vec = "https://www.mulderland.com?utm_source=MulderConfig\0".encode_utf16().collect(); - let op: Vec = "open\0".encode_utf16().collect(); - unsafe { ShellExecuteW(std::ptr::null_mut(), op.as_ptr(), url.as_ptr(), std::ptr::null(), std::ptr::null(), 1); } - None - } - WM_SETCURSOR => { - let hcur = unsafe { LoadCursorW(std::ptr::null_mut(), 32649 as *const u16) }; - unsafe { SetCursor(hcur); } - Some(1) - } - _ => None, - } - }); - std::mem::forget(_foot_link_handler); - - let win_h = y_footer + FOOTER_LABEL_H + FOOTER_GAP + FOOTER_LINK_H + MARGIN; + let content_bottom = chrome::build_footer(&app.window, y + BTN_H, WINDOW_W, MARGIN, &fonts); + let win_h = content_bottom + MARGIN; app.window.set_size(WINDOW_W as u32, win_h as u32); // Center on screen @@ -563,3 +187,107 @@ pub fn run(ctx: AppContext) { nwg::dispatch_thread_events(); } + +fn build_option_groups( + group_defs: &[crate::config::model::OptionGroup], + window: &nwg::Window, + y_start: i32, + fonts: &chrome::AppFonts, +) -> (Vec, Vec, Vec, i32) { + let mut groups: Vec = Vec::new(); + let mut frames: Vec = Vec::new(); + let mut group_titles: Vec = Vec::new(); + let mut y = y_start; + let inner_w = WINDOW_W - MARGIN * 2 - 8; + + for group_def in group_defs { + let item_count = match &group_def.kind { + OptionGroupType::RadioGroup => group_def.radios.as_ref().map_or(0, |v: &Vec<_>| v.len()), + OptionGroupType::CheckboxGroup => group_def.checkboxes.as_ref().map_or(0, |v: &Vec<_>| v.len()), + } as i32; + + let frame_h = GROUP_TITLE_H + item_count * ITEM_H + GROUP_PADDING; + + let mut frame = nwg::Frame::default(); + nwg::Frame::builder() + .size((WINDOW_W - MARGIN * 2, frame_h)) + .position((MARGIN, y)) + .flags(nwg::FrameFlags::VISIBLE | nwg::FrameFlags::BORDER) + .parent(window) + .build(&mut frame) + .expect("Failed to build Frame"); + + let mut title = nwg::Label::default(); + nwg::Label::builder() + .text(&group_def.name) + .size((inner_w, 18)) + .position((4, 2)) + .parent(&frame) + .build(&mut title) + .expect("Failed to build group title"); + if let nwg::ControlHandle::Hwnd(h) = title.handle { + unsafe { SendMessageW(h as _, WM_SETFONT, fonts.bold as usize, 1); } + } + + let mut item_y = GROUP_TITLE_H; + + match &group_def.kind { + OptionGroupType::RadioGroup => { + let mut items: Vec = Vec::new(); + for def in group_def.radios.as_deref().unwrap_or(&[]) { + let mut radio = nwg::RadioButton::default(); + nwg::RadioButton::builder() + .text(&def.value) + .size((inner_w, 20)) + .position((5, item_y)) + .parent(&frame) + .build(&mut radio) + .expect("Failed to build RadioButton"); + if let nwg::ControlHandle::Hwnd(h) = radio.handle { + let empty: [u16; 1] = [0]; + unsafe { SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); } + unsafe { SendMessageW(h as _, WM_SETFONT, fonts.normal as usize, 1); } + } + item_y += ITEM_H; + items.push(RadioItem { + value: def.value.clone(), + disabled_when: def.disabled_when.clone(), + ctrl: radio, + }); + } + groups.push(Group::Radios { name: group_def.name.clone(), items }); + } + OptionGroupType::CheckboxGroup => { + let mut items: Vec = Vec::new(); + for def in group_def.checkboxes.as_deref().unwrap_or(&[]) { + let mut cb = nwg::CheckBox::default(); + nwg::CheckBox::builder() + .text(&def.value) + .size((inner_w, 20)) + .position((5, item_y)) + .parent(&frame) + .build(&mut cb) + .expect("Failed to build CheckBox"); + if let nwg::ControlHandle::Hwnd(h) = cb.handle { + let empty: [u16; 1] = [0]; + unsafe { SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); } + unsafe { SendMessageW(h as _, WM_SETFONT, fonts.normal as usize, 1); } + } + item_y += ITEM_H; + items.push(CheckItem { + value: def.value.clone(), + disabled_when: def.disabled_when.clone(), + ctrl: cb, + }); + } + groups.push(Group::Checks { name: group_def.name.clone(), items }); + } + } + + y += frame_h + GROUP_GAP; + group_titles.push(title); + frames.push(frame); + } + + (groups, frames, group_titles, y) +} diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs new file mode 100644 index 0000000..da2740e --- /dev/null +++ b/src/ui/chrome.rs @@ -0,0 +1,200 @@ +use native_windows_gui as nwg; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + SendMessageW, CreateWindowExW, WS_CHILD, WS_VISIBLE, + LoadIconW, WM_SETICON, LoadCursorW, SetCursor, + SystemParametersInfoW, NONCLIENTMETRICSW, + GetClientRect, GetClassNameW, +}; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::IsWindowEnabled; +use windows_sys::Win32::Graphics::Gdi::{CreateSolidBrush, CreateFontIndirectW, SetTextColor, SetBkMode, FillRect}; +use windows_sys::Win32::Foundation::RECT; +use windows_sys::Win32::UI::Shell::ShellExecuteW; + +// Color scheme (Steam-like) +pub const COLOR_BG: u32 = 0x002E2825; // #25282E +pub const COLOR_BG_DARK: u32 = 0x002E2623; // #23262E +pub const COLOR_BG_LIGHT: u32 = 0x003F3530; // #30353F +pub const COLOR_TEXT_TITLES: u32 = 0x00DFDED1; // #D1DEDF — group titles +pub const COLOR_TEXT_LABELS: u32 = 0x00EDECEC; // #ECECED — radio/checkbox labels + +const WM_ERASEBKGND: u32 = 0x0014; +const WM_CTLCOLORSTATIC: u32 = 0x0138; +const WM_SETFONT: u32 = 0x0030; +const WM_LBUTTONUP: u32 = 0x0202; +const WM_SETCURSOR: u32 = 0x0020; +const SS_ETCHEDHORZ: u32 = 0x0010; +const SPI_GETNONCLIENTMETRICS: u32 = 0x0029; + +const FOOTER_LABEL_H: i32 = 12; +const FOOTER_LINK_H: i32 = 12; +const FOOTER_GAP: i32 = 4; + +// --- Fonts --- + +pub struct AppFonts { + pub normal: isize, + pub bold: isize, + pub small: isize, + pub link: isize, +} + +pub fn create_fonts() -> AppFonts { + unsafe { + let mut ncm: NONCLIENTMETRICSW = std::mem::zeroed(); + ncm.cbSize = std::mem::size_of::() as u32; + SystemParametersInfoW(SPI_GETNONCLIENTMETRICS, ncm.cbSize, &mut ncm as *mut _ as *mut _, 0); + let lf_normal = ncm.lfMessageFont; + let mut lf_bold = lf_normal; + lf_bold.lfWeight = 700; + let mut lf_small = lf_normal; + lf_small.lfHeight = lf_normal.lfHeight + 2; // less negative = smaller + let mut lf_link = lf_small; + lf_link.lfUnderline = 1; + AppFonts { + normal: CreateFontIndirectW(&lf_normal) as isize, + bold: CreateFontIndirectW(&lf_bold) as isize, + small: CreateFontIndirectW(&lf_small) as isize, + link: CreateFontIndirectW(&lf_link) as isize, + } + } +} + +// --- Icon --- + +pub fn apply_icon(window: &nwg::Window) { + if let nwg::ControlHandle::Hwnd(hwnd) = window.handle { + let hicon = unsafe { LoadIconW(GetModuleHandleW(std::ptr::null()), 1 as *const u16) }; + if !hicon.is_null() { + unsafe { + SendMessageW(hwnd as _, WM_SETICON, 1, hicon as _); // ICON_BIG + SendMessageW(hwnd as _, WM_SETICON, 0, hicon as _); // ICON_SMALL + } + } + } +} + +// --- Theming --- + +pub fn apply_theme(window: &nwg::Window, frames: &[nwg::Frame]) { + let brush_dark = unsafe { CreateSolidBrush(COLOR_BG_DARK) } as isize; + let brush_bg = unsafe { CreateSolidBrush(COLOR_BG) } as isize; + let brush_light = unsafe { CreateSolidBrush(COLOR_BG_LIGHT) } as isize; + + // Window background + static labels + let _theme_win = nwg::bind_raw_event_handler(&window.handle, 0x10001, move |hwnd, msg, w, _| { + if msg == WM_ERASEBKGND { + let hdc = w as *mut std::ffi::c_void; + let mut rect: RECT = unsafe { std::mem::zeroed() }; + unsafe { GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); } + unsafe { FillRect(hdc, &rect, brush_dark as usize as *mut std::ffi::c_void); } + return Some(1); + } + if msg == WM_CTLCOLORSTATIC { + let hdc = w as *mut std::ffi::c_void; + unsafe { SetTextColor(hdc, COLOR_TEXT_TITLES); SetBkMode(hdc, 1); } + return Some(brush_bg); + } + None + }).ok(); + std::mem::forget(_theme_win); + + // Frame backgrounds + radio/checkbox colors + let _theme_frames: Vec<_> = frames.iter().enumerate().map(|(i, frame)| { + nwg::bind_raw_event_handler(&frame.handle, 0x10002 + i, move |hwnd, msg, w, l| { + match msg { + WM_ERASEBKGND => { + let hdc = w as *mut std::ffi::c_void; + let mut rect: RECT = unsafe { std::mem::zeroed() }; + unsafe { GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); } + unsafe { FillRect(hdc, &rect, brush_light as usize as *mut std::ffi::c_void); } + Some(1) + } + WM_CTLCOLORSTATIC => { + let hdc = w as *mut std::ffi::c_void; + let ctrl_hwnd = l as usize as *mut std::ffi::c_void; + let is_enabled = unsafe { IsWindowEnabled(ctrl_hwnd) } != 0; + unsafe { SetBkMode(hdc, 1); } + if is_enabled { + let mut class: [u16; 16] = [0; 16]; + let n = unsafe { GetClassNameW(ctrl_hwnd, class.as_mut_ptr(), 16) }; + let is_button = n > 0 && class[0] == b'B' as u16; + let color = if is_button { COLOR_TEXT_LABELS } else { COLOR_TEXT_TITLES }; + unsafe { SetTextColor(hdc, color); } + } + Some(brush_light) + } + _ => None, + } + }).ok() + }).collect(); + std::mem::forget(_theme_frames); +} + +// --- Footer --- + +/// Builds the separator + footer labels below the buttons. +/// `y_after_btns` = y + BTN_H (top of the footer zone). +/// Returns `content_bottom` — add MARGIN to get `win_h`. +pub fn build_footer(window: &nwg::Window, y_after_btns: i32, window_w: i32, margin: i32, fonts: &AppFonts) -> i32 { + let y_sep = y_after_btns + 6; + let y_footer = y_sep + 8; + + // Horizontal separator line + let sep_class: Vec = "STATIC\0".encode_utf16().collect(); + let win_hwnd = match window.handle { nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, _ => std::ptr::null_mut() }; + unsafe { + CreateWindowExW(0, sep_class.as_ptr(), std::ptr::null(), WS_CHILD | WS_VISIBLE | SS_ETCHEDHORZ, + margin, y_sep, window_w - margin * 2, 2, + win_hwnd, std::ptr::null_mut(), GetModuleHandleW(std::ptr::null()), std::ptr::null()); + } + + let mut foot_text = nwg::Label::default(); + nwg::Label::builder() + .text("MulderConfig is part of the Mulderland project") + .size((window_w - margin * 2, FOOTER_LABEL_H)) + .position((margin, y_footer)) + .h_align(nwg::HTextAlign::Center) + .parent(window) + .build(&mut foot_text) + .expect("Failed to build footer label"); + if let nwg::ControlHandle::Hwnd(h) = foot_text.handle { + unsafe { SendMessageW(h as _, WM_SETFONT, fonts.small as usize, 1); } + } + + let mut foot_link = nwg::Label::default(); + nwg::Label::builder() + .text("www.mulderland.com") + .size((window_w - margin * 2, FOOTER_LINK_H)) + .position((margin, y_footer + FOOTER_LABEL_H + FOOTER_GAP)) + .h_align(nwg::HTextAlign::Center) + .parent(window) + .build(&mut foot_link) + .expect("Failed to build footer link"); + if let nwg::ControlHandle::Hwnd(h) = foot_link.handle { + unsafe { SendMessageW(h as _, WM_SETFONT, fonts.link as usize, 1); } + } + + let _foot_link_handler = nwg::bind_raw_event_handler(&foot_link.handle, 0x10011, move |_, msg, _, _| { + match msg { + WM_LBUTTONUP => { + let url: Vec = "https://www.mulderland.com?utm_source=MulderConfig\0".encode_utf16().collect(); + let op: Vec = "open\0".encode_utf16().collect(); + unsafe { ShellExecuteW(std::ptr::null_mut(), op.as_ptr(), url.as_ptr(), std::ptr::null(), std::ptr::null(), 1); } + None + } + WM_SETCURSOR => { + let hcur = unsafe { LoadCursorW(std::ptr::null_mut(), 32649 as *const u16) }; + unsafe { SetCursor(hcur); } + Some(1) + } + _ => None, + } + }); + // Keep controls and handler alive for the duration of the program + std::mem::forget(_foot_link_handler); + std::mem::forget(foot_text); + std::mem::forget(foot_link); + + y_footer + FOOTER_LABEL_H + FOOTER_GAP + FOOTER_LINK_H +} diff --git a/src/ui/controls.rs b/src/ui/controls.rs new file mode 100644 index 0000000..fe3d4d3 --- /dev/null +++ b/src/ui/controls.rs @@ -0,0 +1,139 @@ +use native_windows_gui as nwg; +use std::collections::HashMap; +use crate::{ + config::model::WhenGroup, + config::when_resolver::{match_when, Selections, SelectionValue}, + save::SaveValue, +}; + +pub struct RadioItem { + pub value: String, + pub disabled_when: Option>, + pub ctrl: nwg::RadioButton, +} + +pub struct CheckItem { + pub value: String, + pub disabled_when: Option>, + pub ctrl: nwg::CheckBox, +} + +pub enum Group { + Radios { name: String, items: Vec }, + Checks { name: String, items: Vec }, +} + +pub fn load_saved_state(selections: &HashMap, groups: &[Group]) { + for group in groups { + match group { + Group::Radios { name, items } => { + for item in items { + item.ctrl.set_check_state(nwg::RadioButtonState::Unchecked); + } + if let Some(SaveValue::Single(val)) = selections.get(name) { + for item in items { + if item.value.eq_ignore_ascii_case(val) { + item.ctrl.set_check_state(nwg::RadioButtonState::Checked); + break; + } + } + } + } + Group::Checks { name, items } => { + let checked_vals: Vec<&str> = selections.get(name) + .and_then(|v| if let SaveValue::Multiple(list) = v { + Some(list.iter().map(|s| s.as_str()).collect()) + } else { + None + }) + .unwrap_or_default(); + for item in items { + let state = if checked_vals.iter().any(|v| v.eq_ignore_ascii_case(&item.value)) { + nwg::CheckBoxState::Checked + } else { + nwg::CheckBoxState::Unchecked + }; + item.ctrl.set_check_state(state); + } + } + } + } +} + +pub fn is_config_complete(groups: &[Group]) -> bool { + groups.iter().all(|g| match g { + Group::Radios { items, .. } => items.iter().any(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked), + Group::Checks { .. } => true, + }) +} + +pub fn collect_selections_for_save(groups: &[Group]) -> HashMap { + let mut map = HashMap::new(); + for group in groups { + match group { + Group::Radios { name, items } => { + if let Some(item) = items.iter().find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) { + map.insert(name.clone(), SaveValue::Single(item.value.clone())); + } + } + Group::Checks { name, items } => { + let checked: Vec = items.iter() + .filter(|i| i.ctrl.check_state() == nwg::CheckBoxState::Checked) + .map(|i| i.value.clone()) + .collect(); + map.insert(name.clone(), SaveValue::Multiple(checked)); + } + } + } + map +} + +pub fn apply_constraints(title: &str, groups: &[Group]) { + // 1. Build current selections from control states + let mut selections: Selections = Selections::new(); + selections.insert("Title".to_string(), SelectionValue::Single(title.to_string())); + for group in groups { + match group { + Group::Radios { name, items } => { + if let Some(item) = items.iter().find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) { + selections.insert(name.clone(), SelectionValue::Single(item.value.clone())); + } + } + Group::Checks { name, items } => { + let checked: Vec = items.iter() + .filter(|i| i.ctrl.check_state() == nwg::CheckBoxState::Checked) + .map(|i| i.value.clone()) + .collect(); + selections.insert(name.clone(), SelectionValue::Multiple(checked)); + } + } + } + + // 2. Apply disabled_when to each item + for group in groups { + match group { + Group::Radios { items, .. } => { + for item in items { + if let Some(when) = &item.disabled_when { + let should_disable = match_when(when, &selections); + item.ctrl.set_enabled(!should_disable); + if should_disable { + item.ctrl.set_check_state(nwg::RadioButtonState::Unchecked); + } + } + } + } + Group::Checks { items, .. } => { + for item in items { + if let Some(when) = &item.disabled_when { + let should_disable = match_when(when, &selections); + item.ctrl.set_enabled(!should_disable); + if should_disable { + item.ctrl.set_check_state(nwg::CheckBoxState::Unchecked); + } + } + } + } + } + } +} From 66f93fa21f0501178796eb975bf87341e246b317 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:26:41 +0200 Subject: [PATCH 38/77] move error to ui/ --- src/main.rs | 3 +-- src/ui.rs | 1 + src/{ => ui}/error.rs | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/{ => ui}/error.rs (100%) diff --git a/src/main.rs b/src/main.rs index b451065..855ae72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,6 @@ mod addon_detector; mod config; mod context; -mod error; mod mode_detector; mod save; mod ui; @@ -11,7 +10,7 @@ mod ui; use addon_detector::detect_addon; use config::loader::ConfigLoader; use context::AppContext; -use error::{ask_delete_save, fatal}; +use ui::error::{ask_delete_save, fatal}; use mode_detector::{detect_mode, Mode}; use save::loader::SaveLoader; use save::validator::SaveValidator; diff --git a/src/ui.rs b/src/ui.rs index 4f99b14..b3d73f3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3,6 +3,7 @@ extern crate native_windows_derive as nwd; pub mod controls; pub mod chrome; +pub mod error; use controls::{RadioItem, CheckItem, Group, load_saved_state, is_config_complete, collect_selections_for_save, apply_constraints}; use nwd::NwgUi; diff --git a/src/error.rs b/src/ui/error.rs similarity index 100% rename from src/error.rs rename to src/ui/error.rs From 9a44176664c136bb8e452ace3e4a83327aa4d5fc Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:24:24 +0200 Subject: [PATCH 39/77] Apply --- Cargo.toml | 2 +- src/apply.rs | 42 +++++++ src/apply/exe_replacer.rs | 97 ++++++++++++++++ src/apply/file_ops.rs | 239 ++++++++++++++++++++++++++++++++++++++ src/apply/launcher.rs | 67 +++++++++++ src/main.rs | 5 +- src/ui.rs | 4 + src/ui/error.rs | 6 + 8 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 src/apply.rs create mode 100644 src/apply/exe_replacer.rs create mode 100644 src/apply/file_ops.rs create mode 100644 src/apply/launcher.rs diff --git a/Cargo.toml b/Cargo.toml index 0d0abce..28345ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ native-windows-derive = "1.0.5" native-windows-gui = "1.0.13" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_UI_Controls", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_UI_Controls", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_Storage_FileSystem"] } [build-dependencies] winres = "0.1" diff --git a/src/apply.rs b/src/apply.rs new file mode 100644 index 0000000..f4e77aa --- /dev/null +++ b/src/apply.rs @@ -0,0 +1,42 @@ +pub mod exe_replacer; +pub mod file_ops; +pub mod launcher; + +use crate::context::AppContext; +use crate::config::when_resolver::{SelectionValue, Selections}; +use crate::save::SaveValue; + +/// Converts the save-format selections (+ injects "Title") into WhenResolver selections. +fn to_selections(ctx: &AppContext) -> Selections { + let mut sel = Selections::new(); + sel.insert("Title".to_string(), SelectionValue::Single(ctx.active_title.clone())); + for (k, v) in &ctx.selections { + let sv = match v { + SaveValue::Single(s) => SelectionValue::Single(s.clone()), + SaveValue::Multiple(v) => SelectionValue::Multiple(v.clone()), + }; + sel.insert(k.clone(), sv); + } + sel +} + +/// Runs file operations and (if needed) replaces the exe. +/// Called by both the Apply button and the `-apply` CLI argument. +pub fn apply(ctx: &AppContext) { + let selected = to_selections(ctx); + file_ops::execute_operations(&ctx.config.actions.operations, &selected); + if !ctx.config.actions.launch.is_empty() && !exe_replacer::is_replaced(&ctx.config) { + if let Err(e) = exe_replacer::replace(&ctx.config) { + crate::ui::error::warn(&e); + } + } +} + +/// Resolves launch rules and starts the game process. +/// Called by `Mode::Launch` (when Steam launches the replaced exe). +pub fn launch(ctx: &AppContext) { + let selected = to_selections(ctx); + if let Err(e) = launcher::launch(&ctx.config, &selected) { + crate::ui::error::warn(&e); + } +} diff --git a/src/apply/exe_replacer.rs b/src/apply/exe_replacer.rs new file mode 100644 index 0000000..08a7d4b --- /dev/null +++ b/src/apply/exe_replacer.rs @@ -0,0 +1,97 @@ +use std::os::windows::io::AsRawHandle; +use windows_sys::Win32::Storage::FileSystem::{GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION}; + +use std::path::{Path, PathBuf}; + +use crate::config::model::ConfigModel; + +pub struct ExePaths { + pub original: PathBuf, // Game.exe (may be the hard link after replacement) + pub backup: PathBuf, // Game_o.exe + pub launcher: PathBuf, // MulderConfig.exe +} + +pub fn get_paths(config: &ConfigModel) -> ExePaths { + let base = exe_dir(); + let original = base.join(&config.game.original_exe); + let stem = Path::new(&config.game.original_exe) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let ext = Path::new(&config.game.original_exe) + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + let backup = base.join(format!("{stem}_o{ext}")); + let launcher = base.join("MulderConfig.exe"); + ExePaths { original, backup, launcher } +} + +/// Returns true when Game.exe and MulderConfig.exe are the same file (hard link). +pub fn is_replaced(config: &ConfigModel) -> bool { + let paths = get_paths(config); + if !paths.original.exists() || !paths.launcher.exists() { + return false; + } + same_file(&paths.original, &paths.launcher).unwrap_or(false) +} + +fn same_file(a: &Path, b: &Path) -> std::io::Result { + let fa = std::fs::File::open(a)?; + let fb = std::fs::File::open(b)?; + unsafe { + let mut ia: BY_HANDLE_FILE_INFORMATION = std::mem::zeroed(); + let mut ib: BY_HANDLE_FILE_INFORMATION = std::mem::zeroed(); + if GetFileInformationByHandle(fa.as_raw_handle() as _, &mut ia) == 0 + || GetFileInformationByHandle(fb.as_raw_handle() as _, &mut ib) == 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(ia.dwVolumeSerialNumber == ib.dwVolumeSerialNumber + && ia.nFileIndexHigh == ib.nFileIndexHigh + && ia.nFileIndexLow == ib.nFileIndexLow) + } +} + +/// Replaces Game.exe with a hard link to MulderConfig.exe. +/// Original Game.exe is renamed to Game_o.exe. +pub fn replace(config: &ConfigModel) -> Result<(), String> { + let paths = get_paths(config); + if !paths.original.exists() { + return Err(format!("Original exe not found: {}", paths.original.display())); + } + if !paths.launcher.exists() { + return Err(format!("Launcher not found: {}", paths.launcher.display())); + } + // Step 1: rename original → backup + std::fs::rename(&paths.original, &paths.backup) + .map_err(|e| format!("Failed to rename '{}' to '{}': {e}", + paths.original.display(), paths.backup.display()))?; + // Step 2: hard link MulderConfig.exe → original slot + std::fs::hard_link(&paths.launcher, &paths.original) + .map_err(|e| { + // Try to restore original on failure + let _ = std::fs::rename(&paths.backup, &paths.original); + format!("Failed to create hard link: {e}") + })?; + Ok(()) +} + +/// Returns the path of the actual game executable to launch. +/// After replacement: Game_o.exe; otherwise: Game.exe. +pub fn get_default_launch_exe(config: &ConfigModel) -> PathBuf { + let paths = get_paths(config); + if is_replaced(config) && paths.backup.exists() { + paths.backup + } else { + paths.original + } +} + +fn exe_dir() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")) +} diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs new file mode 100644 index 0000000..63b37d9 --- /dev/null +++ b/src/apply/file_ops.rs @@ -0,0 +1,239 @@ +use std::path::{Path, PathBuf}; + +use crate::config::model::OperationAction; +use crate::config::when_resolver::{match_when, Selections}; + +pub fn execute_operations(operations: &[OperationAction], selected: &Selections) { + for action in operations { + if let Some(when) = &action.when { + if !match_when(when, selected) { + continue; + } + } + let result = match action.operation.to_lowercase().as_str() { + "setreadonly" => exec_set_readonly(action, true), + "removereadonly" => exec_set_readonly(action, false), + "rename" | "move" => exec_move(action), + "copy" => exec_copy(action), + "delete" => exec_delete(action), + "replaceline" => exec_replace_line(action), + "removeline" => exec_remove_line(action), + "replacetext" => exec_replace_text(action), + other => Err(format!("Unknown operation: {other}")), + }; + if let Err(e) = result { + crate::ui::error::warn(&format!("Operation '{}' failed:\n{e}", action.operation)); + } + } +} + +// ── Path helpers ───────────────────────────────────────────────────────────── + +fn exe_dir() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Expands %VAR% style environment variables and resolves the path. +/// Relative paths are resolved against the exe directory. +fn resolve_path(path: &str) -> PathBuf { + let expanded = expand_env_vars(path); + let p = Path::new(&expanded); + if p.is_absolute() { + p.to_path_buf() + } else { + exe_dir().join(p) + } +} + +fn expand_env_vars(s: &str) -> String { + let mut result = String::new(); + let mut rest = s; + while let Some(start) = rest.find('%') { + result.push_str(&rest[..start]); + rest = &rest[start + 1..]; + if let Some(end) = rest.find('%') { + let var_name = &rest[..end]; + match std::env::var(var_name) { + Ok(val) => result.push_str(&val), + Err(_) => { + result.push('%'); + result.push_str(var_name); + result.push('%'); + } + } + rest = &rest[end + 1..]; + } else { + result.push('%'); + } + } + result.push_str(rest); + result +} + +fn resolve_files(files: &Option>) -> Vec { + let Some(list) = files else { return Vec::new(); }; + let mut out = Vec::new(); + for f in list { + if f.is_empty() { continue; } + let p = resolve_path(f); + if p.exists() { + out.push(p); + } else { + crate::ui::error::warn(&format!("File not found: {}", p.display())); + } + } + out +} + +// ── Operations ──────────────────────────────────────────────────────────────── + +fn exec_set_readonly(action: &OperationAction, read_only: bool) -> Result<(), String> { + let files = action.files.as_ref() + .filter(|v| !v.is_empty()) + .ok_or("Missing 'files' for setReadOnly/removeReadOnly.")?; + for f in files { + if f.is_empty() { continue; } + let p = resolve_path(f); + if !p.exists() { continue; } // idempotent + let meta = std::fs::metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?; + let mut perms = meta.permissions(); + perms.set_readonly(read_only); + std::fs::set_permissions(&p, perms).map_err(|e| format!("{}: {e}", p.display()))?; + } + Ok(()) +} + +fn exec_move(action: &OperationAction) -> Result<(), String> { + let src = action.source.as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'source' for rename/move.")?; + let dst = action.target.as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'target' for rename/move.")?; + let src = resolve_path(src); + let dst = resolve_path(dst); + if !src.exists() { return Ok(()); } // idempotent + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?; + } + // Remove existing destination + if dst.is_file() { std::fs::remove_file(&dst).map_err(|e| format!("{e}"))?; } + else if dst.is_dir() { std::fs::remove_dir_all(&dst).map_err(|e| format!("{e}"))?; } + std::fs::rename(&src, &dst).map_err(|e| format!("{e}")) +} + +fn exec_copy(action: &OperationAction) -> Result<(), String> { + let src = action.source.as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'source' for copy.")?; + let dst = action.target.as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'target' for copy.")?; + let src = resolve_path(src); + let dst = resolve_path(dst); + if !src.exists() { return Ok(()); } // idempotent + std::fs::copy(&src, &dst) + .map(|_| ()) + .map_err(|e| format!("{e}")) +} + +fn exec_delete(action: &OperationAction) -> Result<(), String> { + let src = action.source.as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'source' for delete.")?; + let p = resolve_path(src); + if p.exists() { + std::fs::remove_file(&p).map_err(|e| format!("{e}"))?; + } + Ok(()) +} + +fn exec_replace_line(action: &OperationAction) -> Result<(), String> { + let pattern = action.pattern.as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'pattern' for replaceLine.")?; + let replacement = action.replacement.as_deref() + .ok_or("Missing 'replacement' for replaceLine.")?; + let pattern_lower = pattern.to_lowercase(); + let replacement_str = unescape(replacement); + let replacement_lines: Vec<&str> = replacement_str + .split('\n') + .filter(|l| !l.is_empty()) + .collect(); + for path in resolve_files(&action.files) { + filter_lines_in_file(&path, &pattern_lower, &replacement_lines) + .map_err(|e| format!("{}: {e}", path.display()))?; + } + Ok(()) +} + +fn exec_remove_line(action: &OperationAction) -> Result<(), String> { + let pattern = action.pattern.as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'pattern' for removeLine.")?; + let pattern_lower = pattern.to_lowercase(); + for path in resolve_files(&action.files) { + filter_lines_in_file(&path, &pattern_lower, &[]) + .map_err(|e| format!("{}: {e}", path.display()))?; + } + Ok(()) +} + +fn exec_replace_text(action: &OperationAction) -> Result<(), String> { + let search = action.search.as_deref() + .ok_or("Missing 'search' for replaceText.")?; + let replacement = action.replacement.as_deref() + .ok_or("Missing 'replacement' for replaceText.")?; + for path in resolve_files(&action.files) { + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("{}: {e}", path.display()))?; + let new_content = content.replace(search, replacement); + if new_content != content { + std::fs::write(&path, new_content) + .map_err(|e| format!("{}: {e}", path.display()))?; + } + } + Ok(()) +} + +// ── File helpers ────────────────────────────────────────────────────────────── + +fn filter_lines_in_file(path: &Path, pattern_lower: &str, replacement_lines: &[&str]) -> Result<(), String> { + let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + let mut modified = false; + let mut new_lines: Vec<&str> = Vec::new(); + for line in content.lines() { + if line.to_lowercase().contains(pattern_lower) { + modified = true; + new_lines.extend_from_slice(replacement_lines); + } else { + new_lines.push(line); + } + } + if modified { + std::fs::write(path, new_lines.join("\n")).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('r') => out.push('\r'), + Some(other) => { out.push('\\'); out.push(other); } + None => out.push('\\'), + } + } else { + out.push(c); + } + } + out +} diff --git a/src/apply/launcher.rs b/src/apply/launcher.rs new file mode 100644 index 0000000..d25a454 --- /dev/null +++ b/src/apply/launcher.rs @@ -0,0 +1,67 @@ +use std::path::{Path, PathBuf}; + +use crate::config::model::ConfigModel; +use crate::config::when_resolver::{match_when, Selections}; + +use super::exe_replacer; + +pub fn launch(config: &ConfigModel, selected: &Selections) -> Result<(), String> { + let mut exe = exe_replacer::get_default_launch_exe(config); + let mut work_dir = exe_dir(); + let mut wait = false; + let mut args: Vec = Vec::new(); + + for rule in &config.actions.launch { + if let Some(when) = &rule.when { + if !match_when(when, selected) { + continue; + } + } + // Last match wins for exec + if let Some(exec) = &rule.exec { + exe = resolve_path(&exec.name); + work_dir = resolve_path(&exec.work_dir); + wait = exec.wait.unwrap_or(false); + } + // Cumulative args + if let Some(rule_args) = &rule.args { + for a in rule_args { + if !a.is_empty() { + args.push(a.clone()); + } + } + } + } + + if !exe.exists() { + return Err(format!("Executable not found: {}", exe.display())); + } + + let mut child = std::process::Command::new(&exe) + .current_dir(&work_dir) + .args(&args) + .spawn() + .map_err(|e| format!("Failed to launch '{}': {e}", exe.display()))?; + + if wait { + child.wait().map_err(|e| format!("Process wait failed: {e}"))?; + } + + Ok(()) +} + +fn resolve_path(path: &str) -> PathBuf { + let p = Path::new(path); + if p.is_absolute() { + p.to_path_buf() + } else { + exe_dir().join(p) + } +} + +fn exe_dir() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")) +} diff --git a/src/main.rs b/src/main.rs index 855ae72..7425d87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod addon_detector; +mod apply; mod config; mod context; mod mode_detector; @@ -54,7 +55,7 @@ fn main() { match mode { Mode::Config => { ui::run(ctx); } - Mode::Apply => { println!("Applying conf for Game: {}", ctx.config.game.title); } - Mode::Launch => { println!("Launch Game: {}", ctx.config.game.title); } + Mode::Apply => { apply::apply(&ctx); } + Mode::Launch => { apply::launch(&ctx); } } } diff --git a/src/ui.rs b/src/ui.rs index b3d73f3..191cd60 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -123,6 +123,7 @@ pub fn run(ctx: AppContext) { .collect(); let save_handle = app.save_button.handle.clone(); + let apply_handle = app.apply_button.handle.clone(); // Window-level handler: combo selection + save button click const CB_GETCURSEL: u32 = 0x0147; @@ -157,6 +158,9 @@ pub fn run(ctx: AppContext) { nwg::simple_message("Saved", "Configuration saved successfully."); } } + if evt == nwg::Event::OnButtonClick && handle == apply_handle { + crate::apply::apply(&cx.borrow()); + } }); std::mem::forget(_window_handler); } diff --git a/src/ui/error.rs b/src/ui/error.rs index ebdc8d3..2bd3643 100644 --- a/src/ui/error.rs +++ b/src/ui/error.rs @@ -2,6 +2,12 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ MessageBoxW, MB_ICONERROR, MB_ICONWARNING, MB_OK, MB_YESNO, IDYES, }; +pub fn warn(msg: &str) { + let title: Vec = "MulderConfig\0".encode_utf16().collect(); + let text: Vec = format!("{msg}\0").encode_utf16().collect(); + unsafe { MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), MB_OK | MB_ICONWARNING); } +} + pub fn fatal(msg: &str) -> ! { let title: Vec = "MulderConfig\0".encode_utf16().collect(); let text: Vec = format!("{msg}\0").encode_utf16().collect(); From 9ab36a72b8d4379fe5e1d808853f0d44f7114611 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:41:23 +0200 Subject: [PATCH 40/77] Show errors on apply --- src/apply.rs | 8 +++++--- src/apply/file_ops.rs | 6 ++++-- src/main.rs | 10 +++++++++- src/ui.rs | 9 ++++++++- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/apply.rs b/src/apply.rs index f4e77aa..fb76393 100644 --- a/src/apply.rs +++ b/src/apply.rs @@ -21,15 +21,17 @@ fn to_selections(ctx: &AppContext) -> Selections { } /// Runs file operations and (if needed) replaces the exe. +/// Returns a list of error messages (empty = success). /// Called by both the Apply button and the `-apply` CLI argument. -pub fn apply(ctx: &AppContext) { +pub fn apply(ctx: &AppContext) -> Vec { let selected = to_selections(ctx); - file_ops::execute_operations(&ctx.config.actions.operations, &selected); + let mut errors = file_ops::execute_operations(&ctx.config.actions.operations, &selected); if !ctx.config.actions.launch.is_empty() && !exe_replacer::is_replaced(&ctx.config) { if let Err(e) = exe_replacer::replace(&ctx.config) { - crate::ui::error::warn(&e); + errors.push(e); } } + errors } /// Resolves launch rules and starts the game process. diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index 63b37d9..a188a67 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -3,7 +3,8 @@ use std::path::{Path, PathBuf}; use crate::config::model::OperationAction; use crate::config::when_resolver::{match_when, Selections}; -pub fn execute_operations(operations: &[OperationAction], selected: &Selections) { +pub fn execute_operations(operations: &[OperationAction], selected: &Selections) -> Vec { + let mut errors = Vec::new(); for action in operations { if let Some(when) = &action.when { if !match_when(when, selected) { @@ -22,9 +23,10 @@ pub fn execute_operations(operations: &[OperationAction], selected: &Selections) other => Err(format!("Unknown operation: {other}")), }; if let Err(e) = result { - crate::ui::error::warn(&format!("Operation '{}' failed:\n{e}", action.operation)); + errors.push(format!("Operation '{}' failed:\n{e}", action.operation)); } } + errors } // ── Path helpers ───────────────────────────────────────────────────────────── diff --git a/src/main.rs b/src/main.rs index 7425d87..1edfcd4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,7 +55,15 @@ fn main() { match mode { Mode::Config => { ui::run(ctx); } - Mode::Apply => { apply::apply(&ctx); } + Mode::Apply => { + let errors = apply::apply(&ctx); + if !errors.is_empty() { + for e in &errors { + eprintln!("Error: {e}"); + } + std::process::exit(1); + } + } Mode::Launch => { apply::launch(&ctx); } } } diff --git a/src/ui.rs b/src/ui.rs index 191cd60..6a72f80 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -159,7 +159,14 @@ pub fn run(ctx: AppContext) { } } if evt == nwg::Event::OnButtonClick && handle == apply_handle { - crate::apply::apply(&cx.borrow()); + let errors = crate::apply::apply(&cx.borrow()); + if errors.is_empty() { + nwg::simple_message("Applied", "Configuration applied successfully."); + } else { + for e in &errors { + crate::ui::error::warn(e); + } + } } }); std::mem::forget(_window_handler); From 1f120a220caa95d5cd0cd7c078fa58161bf0ead7 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:27:33 +0200 Subject: [PATCH 41/77] Fix separator right alignment --- src/ui/chrome.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index da2740e..4698f04 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -145,7 +145,7 @@ pub fn build_footer(window: &nwg::Window, y_after_btns: i32, window_w: i32, marg let win_hwnd = match window.handle { nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, _ => std::ptr::null_mut() }; unsafe { CreateWindowExW(0, sep_class.as_ptr(), std::ptr::null(), WS_CHILD | WS_VISIBLE | SS_ETCHEDHORZ, - margin, y_sep, window_w - margin * 2, 2, + margin, y_sep, window_w - margin * 2 + 2, 2, win_hwnd, std::ptr::null_mut(), GetModuleHandleW(std::ptr::null()), std::ptr::null()); } From 7cedfbc575cb11c1320cb702d412c13ae54fa907 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:13:27 +0200 Subject: [PATCH 42/77] Port missing tests from C# --- src/apply/file_ops.rs | 45 ++++++++++ src/apply/launcher.rs | 116 ++++++++++++++++++++++-- src/config/loader.rs | 190 ++++++++++++++++++++++++++++++++++++++++ src/config/validator.rs | 174 ++++++++++++++++++++++++++++++++++++ src/save/validator.rs | 127 +++++++++++++++++++++++++++ 5 files changed, 644 insertions(+), 8 deletions(-) diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index a188a67..3db62ca 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -239,3 +239,48 @@ fn unescape(s: &str) -> String { } out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::model::OperationAction; + use crate::config::when_resolver::Selections; + + fn op_move(source: &str, target: &str) -> OperationAction { + OperationAction { + when: None, + operation: "move".into(), + source: Some(source.into()), + target: Some(target.into()), + files: None, + pattern: None, + search: None, + replacement: None, + } + } + + #[test] + fn move_directory_moves_contents() { + let root = std::env::temp_dir() + .join(format!("mulderconfig_test_{}", std::process::id())); + let source_dir = root.join("srcDir"); + let target_dir = root.join("dstDir"); + std::fs::create_dir_all(&source_dir).unwrap(); + std::fs::write(source_dir.join("a.txt"), "hello").unwrap(); + + let errors = execute_operations( + &[op_move( + source_dir.to_str().unwrap(), + target_dir.to_str().unwrap(), + )], + &Selections::new(), + ); + + assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); + assert!(!source_dir.exists(), "Source should not exist after move"); + assert!(target_dir.exists(), "Target should exist after move"); + assert!(target_dir.join("a.txt").exists(), "File should be in target"); + + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/src/apply/launcher.rs b/src/apply/launcher.rs index d25a454..74b94ec 100644 --- a/src/apply/launcher.rs +++ b/src/apply/launcher.rs @@ -5,7 +5,14 @@ use crate::config::when_resolver::{match_when, Selections}; use super::exe_replacer; -pub fn launch(config: &ConfigModel, selected: &Selections) -> Result<(), String> { +pub struct ResolvedLaunch { + pub exe: PathBuf, + pub work_dir: PathBuf, + pub wait: bool, + pub args: Vec, +} + +fn resolve_launch(config: &ConfigModel, selected: &Selections) -> ResolvedLaunch { let mut exe = exe_replacer::get_default_launch_exe(config); let mut work_dir = exe_dir(); let mut wait = false; @@ -33,17 +40,23 @@ pub fn launch(config: &ConfigModel, selected: &Selections) -> Result<(), String> } } - if !exe.exists() { - return Err(format!("Executable not found: {}", exe.display())); + ResolvedLaunch { exe, work_dir, wait, args } +} + +pub fn launch(config: &ConfigModel, selected: &Selections) -> Result<(), String> { + let resolved = resolve_launch(config, selected); + + if !resolved.exe.exists() { + return Err(format!("Executable not found: {}", resolved.exe.display())); } - let mut child = std::process::Command::new(&exe) - .current_dir(&work_dir) - .args(&args) + let mut child = std::process::Command::new(&resolved.exe) + .current_dir(&resolved.work_dir) + .args(&resolved.args) .spawn() - .map_err(|e| format!("Failed to launch '{}': {e}", exe.display()))?; + .map_err(|e| format!("Failed to launch '{}': {e}", resolved.exe.display()))?; - if wait { + if resolved.wait { child.wait().map_err(|e| format!("Process wait failed: {e}"))?; } @@ -65,3 +78,90 @@ fn exe_dir() -> PathBuf { .and_then(|p| p.parent().map(|d| d.to_path_buf())) .unwrap_or_else(|| PathBuf::from(".")) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::model::*; + use crate::config::when_resolver::SelectionValue; + + fn parse_config(json: &str) -> ConfigModel { + serde_json::from_str(json).expect("JSON parse failed") + } + + fn sel(items: &[(&str, &str)]) -> Selections { + items + .iter() + .map(|(k, v)| (k.to_string(), SelectionValue::Single(v.to_string()))) + .collect() + } + + #[test] + fn returns_default_exe_when_no_rule_matches() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9.exe", "workDir": ".\\" }, + "args": ["-a"] + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let selected = sel(&[("Renderer", "DX11")]); + + let resolved = resolve_launch(&config, &selected); + + assert_eq!( + resolved.exe.file_name().unwrap(), + "Game.exe", + "Default exe should be Game.exe when no rule matches" + ); + assert_eq!(resolved.work_dir, exe_dir()); + assert!(resolved.args.is_empty()); + } + + #[test] + fn args_append_and_last_exec_wins() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9.exe", "workDir": ".\\" }, + "args": ["-nosetup"] + }, + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9_alt.exe", "workDir": "C:\\tmp" }, + "args": ["-novsync", "-borderless"] + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let selected = sel(&[("Renderer", "DX9")]); + + let resolved = resolve_launch(&config, &selected); + + assert_eq!( + resolved.exe.file_name().unwrap(), + "dx9_alt.exe", + "Last matching exec should win" + ); + assert_eq!(resolved.work_dir, PathBuf::from("C:\\tmp")); + assert_eq!(resolved.args, vec!["-nosetup", "-novsync", "-borderless"]); + } +} diff --git a/src/config/loader.rs b/src/config/loader.rs index 786f8d8..d5c2d8a 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -21,3 +21,193 @@ impl ConfigLoader { Ok(config) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::when_resolver::{match_when, SelectionValue, Selections}; + + fn parse(json: &str) -> ConfigModel { + serde_json::from_str(json).expect("JSON parse failed") + } + + fn sel(items: &[(&str, &str)]) -> Selections { + items + .iter() + .map(|(k, v)| (k.to_string(), SelectionValue::Single(v.to_string()))) + .collect() + } + + // === ConfigJsonTests === + + #[test] + fn partial_json_deserializes_launch_and_operations() { + let json = r#" + { + "game": { "title": "Test Game", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9.exe", "workDir": ".\\" }, + "args": ["-a"] + } + ], + "operations": [ + { + "when": [ { "Renderer": "DX9" } ], + "operation": "rename", + "source": "a.dll", + "target": "b.dll" + }, + { + "operation": "replaceLine", + "files": ["FalloutPrefs.ini"], + "pattern": "^iSize W=.*$", + "replacement": "iSize W=1920" + } + ] + } + }"#; + + let config = parse(json); + + assert_eq!("Test Game", config.game.title); + assert_eq!("Game.exe", config.game.original_exe); + + assert_eq!(1, config.actions.launch.len()); + let launch = &config.actions.launch[0]; + assert_eq!("dx9.exe", launch.exec.as_ref().unwrap().name); + assert_eq!(r".\", launch.exec.as_ref().unwrap().work_dir); + assert_eq!(vec!["-a"], *launch.args.as_ref().unwrap()); + + assert_eq!(2, config.actions.operations.len()); + assert_eq!("rename", config.actions.operations[0].operation); + assert_eq!(Some("a.dll".to_string()), config.actions.operations[0].source); + assert_eq!(Some("b.dll".to_string()), config.actions.operations[0].target); + assert_eq!("replaceLine", config.actions.operations[1].operation); + assert_eq!( + Some(vec!["FalloutPrefs.ini".to_string()]), + config.actions.operations[1].files + ); + } + + #[test] + fn launch_rules_args_append_and_last_exec_wins() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9.exe", "workDir": ".\\" }, + "args": ["-nosetup"] + }, + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9_alt.exe", "workDir": "C:\\tmp" }, + "args": ["-novsync", "-borderless"] + } + ], + "operations": [] + } + }"#; + + let config = parse(json); + let selected = sel(&[("Renderer", "DX9")]); + + // Manually apply resolution logic (mirrors LaunchManager / ConfigJsonTests helper) + let mut exe = config.game.original_exe.clone(); + let mut work_dir = String::from(".\\"); + let mut args: Vec = Vec::new(); + for rule in &config.actions.launch { + if let Some(when) = &rule.when { + if !match_when(when, &selected) { + continue; + } + } + if let Some(exec) = &rule.exec { + exe = exec.name.clone(); + work_dir = exec.work_dir.clone(); + } + if let Some(rule_args) = &rule.args { + args.extend(rule_args.clone()); + } + } + + assert_eq!("dx9_alt.exe", exe); + assert_eq!("C:\\tmp", work_dir); + assert_eq!(vec!["-nosetup", "-novsync", "-borderless"], args); + } + + #[test] + fn null_when_is_treated_as_always_apply() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ { "args": ["-a"] } ], + "operations": [ { "operation": "removeLine", "files": ["a.ini"], "pattern": "^x=.*$" } ] + } + }"#; + + let config = parse(json); + let selected = sel(&[("Renderer", "Anything")]); + + // when: None in JSON → None in Rust → empty slice → always applies + assert!(config.actions.launch[0].when.is_none()); + let empty: &[crate::config::model::WhenGroup] = &[]; + assert!(match_when( + config.actions.launch[0].when.as_deref().unwrap_or(empty), + &selected + )); + + assert!(config.actions.operations[0].when.is_none()); + assert!(match_when( + config.actions.operations[0].when.as_deref().unwrap_or(empty), + &selected + )); + } + + #[test] + fn missing_launch_section_defaults_to_empty_and_config_is_valid() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "addons": [ { "title": "default", "steamId": 1 } ], + "optionGroups": [], + "actions": { + "operations": [ { "operation": "delete", "source": "tmp.txt" } ] + } + }"#; + + let config = parse(json); + + assert!(ConfigValidator::validate(&config).is_ok()); + assert!(config.actions.launch.is_empty()); + assert_eq!(1, config.actions.operations.len()); + } + + #[test] + fn missing_operations_section_defaults_to_empty_and_config_is_valid() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "addons": [ { "title": "default", "steamId": 1 } ], + "optionGroups": [], + "actions": { + "launch": [ { "args": ["-a"] } ] + } + }"#; + + let config = parse(json); + + assert!(ConfigValidator::validate(&config).is_ok()); + assert!(config.actions.operations.is_empty()); + assert_eq!(1, config.actions.launch.len()); + } +} diff --git a/src/config/validator.rs b/src/config/validator.rs index f27e255..d8c46e4 100644 --- a/src/config/validator.rs +++ b/src/config/validator.rs @@ -61,6 +61,180 @@ impl ConfigValidator { return Err("At least one action is required".into()); } + for rule in &config.actions.launch { + if let Some(exec) = &rule.exec { + if exec.name.trim().is_empty() { + return Err("Exec name is empty".into()); + } + if exec.work_dir.trim().is_empty() { + return Err("Exec workDir is empty".into()); + } + } + } + + for op in &config.actions.operations { + let operation = op.operation.trim().to_lowercase(); + if operation.is_empty() { + return Err("Operation name is empty".into()); + } + match operation.as_str() { + "rename" | "move" | "copy" => { + let src_ok = op.source.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false); + let dst_ok = op.target.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false); + if !src_ok || !dst_ok { + return Err(format!("Operation '{operation}' requires source and target")); + } + } + "delete" => { + if op.source.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) { + return Err("Operation 'delete' requires source".into()); + } + } + "setreadonly" | "removereadonly" => { + if op.files.as_ref().map(|f| f.is_empty()).unwrap_or(true) { + return Err(format!("Operation '{operation}' requires files")); + } + } + "replaceline" | "removeline" => { + if op.files.as_ref().map(|f| f.is_empty()).unwrap_or(true) { + return Err(format!("Operation '{operation}' requires files")); + } + if op.pattern.as_ref().map(|p| p.trim().is_empty()).unwrap_or(true) { + return Err(format!("Operation '{operation}' requires pattern")); + } + if operation == "replaceline" && op.replacement.is_none() { + return Err("Operation 'replaceLine' requires replacement".into()); + } + } + "replacetext" => { + if op.files.as_ref().map(|f| f.is_empty()).unwrap_or(true) { + return Err("Operation 'replaceText' requires files".into()); + } + if op.search.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) { + return Err("Operation 'replaceText' requires search".into()); + } + if op.replacement.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) { + return Err("Operation 'replaceText' requires replacement".into()); + } + } + _ => {} + } + } + Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::model::*; + + fn minimal_valid() -> ConfigModel { + ConfigModel { + game: Game { title: "Test".into(), original_exe: "Game.exe".into() }, + addons: Some(vec![Addon { title: "default".into(), steam_id: 1 }]), + option_groups: vec![OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![Radio { value: "DX9".into(), disabled_when: None }]), + checkboxes: None, + }], + actions: ActionRoot { + launch: vec![LaunchAction { + when: None, + exec: Some(ExecSpec { name: "Game.exe".into(), work_dir: ".\\".into(), wait: None }), + args: Some(vec!["-a".into()]), + }], + operations: vec![OperationAction { + when: None, + operation: "delete".into(), + source: Some("tmp.txt".into()), + target: None, + files: None, + pattern: None, + search: None, + replacement: None, + }], + }, + } + } + + #[test] + fn valid_for_minimal_valid_config() { + assert!(ConfigValidator::validate(&minimal_valid()).is_ok()); + } + + #[test] + fn valid_when_addon_list_missing() { + let mut cfg = minimal_valid(); + cfg.addons = None; + assert!(ConfigValidator::validate(&cfg).is_ok()); + } + + #[test] + fn err_when_radio_group_has_no_radios() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].radios = Some(vec![]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_launch_exec_name_is_empty() { + let mut cfg = minimal_valid(); + cfg.actions.launch[0].exec = Some(ExecSpec { + name: "".into(), + work_dir: ".\\".into(), + wait: None, + }); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_rename_missing_target() { + let mut cfg = minimal_valid(); + cfg.actions.operations = vec![OperationAction { + when: None, + operation: "rename".into(), + source: Some("a.dll".into()), + target: None, + files: None, + pattern: None, + search: None, + replacement: None, + }]; + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_set_readonly_has_no_files() { + let mut cfg = minimal_valid(); + cfg.actions.operations = vec![OperationAction { + when: None, + operation: "setReadOnly".into(), + source: None, + target: None, + files: Some(vec![]), + pattern: None, + search: None, + replacement: None, + }]; + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn valid_when_set_readonly_has_files() { + let mut cfg = minimal_valid(); + cfg.actions.operations = vec![OperationAction { + when: None, + operation: "setReadOnly".into(), + source: None, + target: None, + files: Some(vec!["Fallout.ini".into()]), + pattern: None, + search: None, + replacement: None, + }]; + assert!(ConfigValidator::validate(&cfg).is_ok()); + } +} diff --git a/src/save/validator.rs b/src/save/validator.rs index 91a9e95..5a1d576 100644 --- a/src/save/validator.rs +++ b/src/save/validator.rs @@ -62,3 +62,130 @@ impl SaveValidator { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::model::*; + use crate::save::SaveValue; + use std::collections::HashMap; + + fn make_config(groups: Vec) -> ConfigModel { + ConfigModel { + game: Game { title: "Test".into(), original_exe: "Game.exe".into() }, + addons: None, + option_groups: groups, + actions: ActionRoot { + launch: vec![], + operations: vec![], + }, + } + } + + fn make_save(entries: &[(&str, SaveValue)]) -> SaveData { + let inner: HashMap = entries + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + let mut outer = SaveData::new(); + outer.insert("Test".into(), inner); + outer + } + + #[test] + fn valid_for_valid_radio_choice() { + let config = make_config(vec![OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![ + Radio { value: "DX9".into(), disabled_when: None }, + Radio { value: "DX11".into(), disabled_when: None }, + ]), + checkboxes: None, + }]); + let save = make_save(&[("Renderer", SaveValue::Single("DX9".into()))]); + assert!(SaveValidator::validate(&save, &config).is_ok()); + } + + #[test] + fn err_when_group_does_not_exist_in_save() { + // NOTE: The C# validator iterates save entries and fails when a key is not in config. + // The Rust validator iterates config groups and fails when a group is missing from save. + // Both return an error, but for symmetric reasons. + let config = make_config(vec![OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![Radio { value: "DX9".into(), disabled_when: None }]), + checkboxes: None, + }]); + let save = make_save(&[("OldGroup", SaveValue::Single("Whatever".into()))]); + assert!(SaveValidator::validate(&save, &config).is_err()); + } + + #[test] + fn err_when_radio_value_does_not_exist() { + let config = make_config(vec![OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![Radio { value: "DX9".into(), disabled_when: None }]), + checkboxes: None, + }]); + let save = make_save(&[("Renderer", SaveValue::Single("DX12".into()))]); + assert!(SaveValidator::validate(&save, &config).is_err()); + } + + #[test] + fn valid_for_valid_checkbox_choices() { + let config = make_config(vec![OptionGroup { + name: "Mods".into(), + kind: OptionGroupType::CheckboxGroup, + radios: None, + checkboxes: Some(vec![ + Checkbox { value: "A".into(), disabled_when: None }, + Checkbox { value: "B".into(), disabled_when: None }, + Checkbox { value: "C".into(), disabled_when: None }, + ]), + }]); + let save = make_save(&[("Mods", SaveValue::Multiple(vec!["A".into(), "C".into()]))]); + assert!(SaveValidator::validate(&save, &config).is_ok()); + } + + #[test] + fn err_when_checkbox_value_does_not_exist() { + let config = make_config(vec![OptionGroup { + name: "Mods".into(), + kind: OptionGroupType::CheckboxGroup, + radios: None, + checkboxes: Some(vec![Checkbox { value: "A".into(), disabled_when: None }]), + }]); + let save = make_save(&[("Mods", SaveValue::Multiple(vec!["A".into(), "B".into()]))]); + assert!(SaveValidator::validate(&save, &config).is_err()); + } + + #[test] + fn err_for_wrong_types() { + let config = make_config(vec![ + OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![Radio { value: "DX9".into(), disabled_when: None }]), + checkboxes: None, + }, + OptionGroup { + name: "Mods".into(), + kind: OptionGroupType::CheckboxGroup, + radios: None, + checkboxes: Some(vec![Checkbox { value: "A".into(), disabled_when: None }]), + }, + ]); + + // Multiple for radioGroup → error + let save1 = + make_save(&[("Renderer", SaveValue::Multiple(vec!["DX9".into()]))]); + assert!(SaveValidator::validate(&save1, &config).is_err()); + + // Single for checkboxGroup → error + let save2 = make_save(&[("Mods", SaveValue::Single("A".into()))]); + assert!(SaveValidator::validate(&save2, &config).is_err()); + } +} From ad00aa8af48feb2b5821ff223ac3aa4f81ecf1ff Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:09:44 +0200 Subject: [PATCH 43/77] Add constraints cascading --- src/ui/controls.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ui/controls.rs b/src/ui/controls.rs index fe3d4d3..56c1eca 100644 --- a/src/ui/controls.rs +++ b/src/ui/controls.rs @@ -109,27 +109,34 @@ pub fn apply_constraints(title: &str, groups: &[Group]) { } } - // 2. Apply disabled_when to each item + // 2. Apply disabled_when to each item, updating selections in place + // so cascading constraints (e.g. MSAA depends on dgVoodoo2) work correctly. for group in groups { match group { - Group::Radios { items, .. } => { + Group::Radios { name, items } => { for item in items { if let Some(when) = &item.disabled_when { let should_disable = match_when(when, &selections); item.ctrl.set_enabled(!should_disable); if should_disable { item.ctrl.set_check_state(nwg::RadioButtonState::Unchecked); + // Remove from selections so downstream constraints see the updated state + selections.remove(name); } } } } - Group::Checks { items, .. } => { + Group::Checks { name, items } => { for item in items { if let Some(when) = &item.disabled_when { let should_disable = match_when(when, &selections); item.ctrl.set_enabled(!should_disable); if should_disable { item.ctrl.set_check_state(nwg::CheckBoxState::Unchecked); + // Remove this value from the Multiple list in selections + if let Some(SelectionValue::Multiple(list)) = selections.get_mut(name) { + list.retain(|v| !v.eq_ignore_ascii_case(&item.value)); + } } } } From ef2295f94ee74c049f107ec6e873af44aceccce7 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:42:24 +0200 Subject: [PATCH 44/77] add regex-lite for str replacement + keep EOL format --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + src/apply/file_ops.rs | 43 ++++++++++++++++++++++++++++++++----------- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74b1b49..043eb64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,6 +92,7 @@ version = "2.0.0" dependencies = [ "native-windows-derive", "native-windows-gui", + "regex-lite", "serde", "serde_json", "windows-sys", @@ -201,6 +202,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "rustversion" version = "1.0.22" diff --git a/Cargo.toml b/Cargo.toml index 28345ab..ca7be18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ path = "src/main.rs" [dependencies] native-windows-derive = "1.0.5" native-windows-gui = "1.0.13" +regex-lite = "0.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_UI_Controls", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_Storage_FileSystem"] } diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index 3db62ca..a84ec11 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +use regex_lite::Regex; use crate::config::model::OperationAction; use crate::config::when_resolver::{match_when, Selections}; @@ -159,14 +160,15 @@ fn exec_replace_line(action: &OperationAction) -> Result<(), String> { .ok_or("Missing 'pattern' for replaceLine.")?; let replacement = action.replacement.as_deref() .ok_or("Missing 'replacement' for replaceLine.")?; - let pattern_lower = pattern.to_lowercase(); + let re = Regex::new(&format!("(?i){pattern}")) + .map_err(|e| format!("Invalid regex pattern '{pattern}': {e}"))?; let replacement_str = unescape(replacement); let replacement_lines: Vec<&str> = replacement_str .split('\n') .filter(|l| !l.is_empty()) .collect(); for path in resolve_files(&action.files) { - filter_lines_in_file(&path, &pattern_lower, &replacement_lines) + filter_lines_in_file(&path, &re, &replacement_lines) .map_err(|e| format!("{}: {e}", path.display()))?; } Ok(()) @@ -176,9 +178,10 @@ fn exec_remove_line(action: &OperationAction) -> Result<(), String> { let pattern = action.pattern.as_deref() .filter(|s| !s.is_empty()) .ok_or("Missing 'pattern' for removeLine.")?; - let pattern_lower = pattern.to_lowercase(); + let re = Regex::new(&format!("(?i){pattern}")) + .map_err(|e| format!("Invalid regex pattern '{pattern}': {e}"))?; for path in resolve_files(&action.files) { - filter_lines_in_file(&path, &pattern_lower, &[]) + filter_lines_in_file(&path, &re, &[]) .map_err(|e| format!("{}: {e}", path.display()))?; } Ok(()) @@ -203,20 +206,38 @@ fn exec_replace_text(action: &OperationAction) -> Result<(), String> { // ── File helpers ────────────────────────────────────────────────────────────── -fn filter_lines_in_file(path: &Path, pattern_lower: &str, replacement_lines: &[&str]) -> Result<(), String> { +fn filter_lines_in_file(path: &Path, re: &Regex, replacement_lines: &[&str]) -> Result<(), String> { let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + + // Detect the dominant line ending so we can preserve it. + let eol = if content.contains("\r\n") { "\r\n" } else { "\n" }; + let mut modified = false; - let mut new_lines: Vec<&str> = Vec::new(); - for line in content.lines() { - if line.to_lowercase().contains(pattern_lower) { + let mut out = String::with_capacity(content.len()); + + for raw_line in content.split('\n') { + // Strip the \r so the regex pattern doesn't have to account for it. + let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); + if re.is_match(line) { modified = true; - new_lines.extend_from_slice(replacement_lines); + for (i, rep) in replacement_lines.iter().enumerate() { + if i > 0 { out.push_str(eol); } + out.push_str(rep); + } } else { - new_lines.push(line); + out.push_str(line); } + out.push_str(eol); } + + // split('\n') always produces a trailing empty element for files that end + // with a newline — remove its trailing eol to avoid adding a blank line. + if content.ends_with('\n') { + out.truncate(out.len() - eol.len()); + } + if modified { - std::fs::write(path, new_lines.join("\n")).map_err(|e| e.to_string())?; + std::fs::write(path, out.as_bytes()).map_err(|e| e.to_string())?; } Ok(()) } From d5e9897e5d7b3cf3894af0e687cab5f03acfde28 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:44:12 +0200 Subject: [PATCH 45/77] Fix apply should apply selection even without saving first --- src/ui.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index 6a72f80..8b7c627 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -159,6 +159,9 @@ pub fn run(ctx: AppContext) { } } if evt == nwg::Event::OnButtonClick && handle == apply_handle { + // Collect current UI state before applying (no disk write) + let selections = collect_selections_for_save(&gc.borrow()); + cx.borrow_mut().save_selections(selections); let errors = crate::apply::apply(&cx.borrow()); if errors.is_empty() { nwg::simple_message("Applied", "Configuration applied successfully."); From a81c426126b8c482bfcf87c5941cb4e665fe308a Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:05:15 +0200 Subject: [PATCH 46/77] Mode Detector - better + add tests --- src/main.rs | 2 +- src/mode_detector.rs | 110 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1edfcd4..86112f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,7 +51,7 @@ fn main() { let ctx = AppContext::new(config, save_data, SAVE_PATH.to_string(), active_title); - let mode = detect_mode(); + let mode = detect_mode(&ctx.config.game.original_exe); match mode { Mode::Config => { ui::run(ctx); } diff --git a/src/mode_detector.rs b/src/mode_detector.rs index 54b5b25..b5fa0c8 100644 --- a/src/mode_detector.rs +++ b/src/mode_detector.rs @@ -7,28 +7,112 @@ pub enum Mode { Launch, } -pub fn detect_mode() -> Mode { +/// If process = originalExe (case insensitive): LAUNCH by default, CONFIG if -MulderConfig flag is present. +/// Else (MulderConfig.exe, or any rename of it): CONFIG by default, APPLY if -apply flag is present. +pub fn detect_mode(original_exe: &str) -> Mode { let args: Vec = env::args().collect(); let exe_name = std::env::current_exe() .ok() - .and_then(|p| p.file_name().and_then(|n| n.to_str()).map(|s| s.to_string())) + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) .unwrap_or_default(); - let is_mulderconfig_exe = exe_name.eq_ignore_ascii_case("MulderConfig.exe"); - if is_mulderconfig_exe { - let has_apply_flag = args.iter().any(|a| a == "-apply"); - return if has_apply_flag { + resolve_mode(original_exe, &exe_name, &args) +} + +/// Pure logic, separated from environment access for testability. +fn resolve_mode(original_exe: &str, exe_name: &str, args: &[String]) -> Mode { + let original_exe_filename = std::path::Path::new(original_exe) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + + let is_original_exe = exe_name.eq_ignore_ascii_case(&original_exe_filename); + + if is_original_exe { + // Running as the game exe (Steam launch after replacement). + // -MulderConfig flag lets the user open the config UI from here. + if args.iter().any(|a| a.eq_ignore_ascii_case("-MulderConfig")) { + Mode::Config + } else { + Mode::Launch + } + } else { + // Running as MulderConfig.exe (or any rename of it). + // -apply flag triggers headless apply. + if args.iter().any(|a| a.eq_ignore_ascii_case("-apply")) { Mode::Apply } else { Mode::Config - }; + } } +} - let has_config_flag = args.iter().any(|a| a == "-MulderConfig"); - return if has_config_flag { - Mode::Config - } else { - Mode::Launch - }; +#[cfg(test)] +mod tests { + use super::*; + + fn args(s: &[&str]) -> Vec { + s.iter().map(|s| s.to_string()).collect() + } + + // --- Running as originalExe --- + + #[test] + fn launch_when_exe_matches_original_exe() { + assert_eq!(resolve_mode("Game.exe", "Game.exe", &args(&[])), Mode::Launch); + } + + #[test] + fn launch_is_case_insensitive() { + assert_eq!(resolve_mode("game.exe", "GAME.EXE", &args(&[])), Mode::Launch); + assert_eq!(resolve_mode("GAME.EXE", "game.exe", &args(&[])), Mode::Launch); + } + + #[test] + fn launch_works_with_path_in_original_exe() { + // original_exe may include a subdirectory: only the filename is compared + assert_eq!(resolve_mode("rebirth\\Biohazard.exe", "Biohazard.exe", &args(&[])), Mode::Launch); + } + + #[test] + fn mulderconfig_flag_on_original_exe_forces_config() { + assert_eq!(resolve_mode("Game.exe", "Game.exe", &args(&["-MulderConfig"])), Mode::Config); + } + + #[test] + fn mulderconfig_flag_is_case_insensitive() { + assert_eq!(resolve_mode("Game.exe", "Game.exe", &args(&["-mulderconfig"])), Mode::Config); + assert_eq!(resolve_mode("Game.exe", "Game.exe", &args(&["-MULDERCONFIG"])), Mode::Config); + } + + #[test] + fn apply_flag_ignored_on_original_exe() { + // -apply only makes sense on MulderConfig.exe, not on the game exe + assert_eq!(resolve_mode("Game.exe", "Game.exe", &args(&["-apply"])), Mode::Launch); + } + + // --- Running as MulderConfig.exe (or renamed) --- + + #[test] + fn config_when_exe_does_not_match_original() { + assert_eq!(resolve_mode("Game.exe", "MulderConfig.exe", &args(&[])), Mode::Config); + } + + #[test] + fn apply_flag_on_mulderconfig_exe_gives_apply() { + assert_eq!(resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-apply"])), Mode::Apply); + } + + #[test] + fn apply_flag_is_case_insensitive() { + assert_eq!(resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-Apply"])), Mode::Apply); + assert_eq!(resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-APPLY"])), Mode::Apply); + } + + #[test] + fn mulderconfig_flag_ignored_on_mulderconfig_exe() { + // -MulderConfig only makes sense on the game exe, so ignored here (config anyway) + assert_eq!(resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-MulderConfig"])), Mode::Config); + } } From 7c6b8eeb1b4a08e6badec47217b678d7ac15d01c Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:29:45 +0200 Subject: [PATCH 47/77] Remove hardcode of MulderConfig.exe for hardlink creation --- src/apply/exe_replacer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apply/exe_replacer.rs b/src/apply/exe_replacer.rs index 08a7d4b..6412428 100644 --- a/src/apply/exe_replacer.rs +++ b/src/apply/exe_replacer.rs @@ -24,7 +24,7 @@ pub fn get_paths(config: &ConfigModel) -> ExePaths { .map(|e| format!(".{}", e.to_string_lossy())) .unwrap_or_default(); let backup = base.join(format!("{stem}_o{ext}")); - let launcher = base.join("MulderConfig.exe"); + let launcher = std::env::current_exe().unwrap(); ExePaths { original, backup, launcher } } From b861415eae3d84b1b6d6e9e7d451ebc5d7771ef9 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:37:04 +0200 Subject: [PATCH 48/77] Config - prevent duplicate names (like in C#) + add tests --- src/config/validator.rs | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/config/validator.rs b/src/config/validator.rs index d8c46e4..bb36516 100644 --- a/src/config/validator.rs +++ b/src/config/validator.rs @@ -37,6 +37,16 @@ impl ConfigValidator { if g.checkboxes.is_some() { return Err("RadioGroup cannot have checkboxes".into()); } + + let mut values: HashSet = HashSet::new(); + for r in radios { + if r.value.trim().is_empty() { + return Err("Radio value is empty".into()); + } + if !values.insert(r.value.to_lowercase()) { + return Err(format!("Duplicate radio value '{}'", r.value)); + } + } } OptionGroupType::CheckboxGroup => { @@ -52,6 +62,16 @@ impl ConfigValidator { if g.radios.is_some() { return Err("CheckboxGroup cannot have radios".into()); } + + let mut values: HashSet = HashSet::new(); + for c in checkboxes { + if c.value.trim().is_empty() { + return Err("Checkbox value is empty".into()); + } + if !values.insert(c.value.to_lowercase()) { + return Err(format!("Duplicate checkbox value '{}'", c.value)); + } + } } } } @@ -222,6 +242,72 @@ mod tests { assert!(ConfigValidator::validate(&cfg).is_err()); } + #[test] + fn err_when_radio_has_empty_value() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].radios = Some(vec![ + Radio { value: "DX9".into(), disabled_when: None }, + Radio { value: "".into(), disabled_when: None }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_radio_has_duplicate_value() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].radios = Some(vec![ + Radio { value: "DX9".into(), disabled_when: None }, + Radio { value: "DX9".into(), disabled_when: None }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_radio_has_duplicate_value_case_insensitive() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].radios = Some(vec![ + Radio { value: "dx9".into(), disabled_when: None }, + Radio { value: "DX9".into(), disabled_when: None }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_checkbox_has_empty_value() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].kind = OptionGroupType::CheckboxGroup; + cfg.option_groups[0].radios = None; + cfg.option_groups[0].checkboxes = Some(vec![ + Checkbox { value: "MSAA".into(), disabled_when: None }, + Checkbox { value: "".into(), disabled_when: None }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_checkbox_has_duplicate_value() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].kind = OptionGroupType::CheckboxGroup; + cfg.option_groups[0].radios = None; + cfg.option_groups[0].checkboxes = Some(vec![ + Checkbox { value: "MSAA".into(), disabled_when: None }, + Checkbox { value: "MSAA".into(), disabled_when: None }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_checkbox_has_duplicate_value_case_insensitive() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].kind = OptionGroupType::CheckboxGroup; + cfg.option_groups[0].radios = None; + cfg.option_groups[0].checkboxes = Some(vec![ + Checkbox { value: "msaa".into(), disabled_when: None }, + Checkbox { value: "MSAA".into(), disabled_when: None }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + #[test] fn valid_when_set_readonly_has_files() { let mut cfg = minimal_valid(); From 140c12001a7f7934503645be45ba0d72f2ea778d Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:50:16 +0200 Subject: [PATCH 49/77] Have stable save order (same order as in config) --- Cargo.lock | 25 +++++++++++++++++++++++++ Cargo.toml | 1 + src/context.rs | 7 +++---- src/save.rs | 7 ++++++- src/save/validator.rs | 4 ++-- src/ui/controls.rs | 10 +++++----- 6 files changed, 42 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 043eb64..30d7f96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "futures-core" version = "0.3.32" @@ -50,6 +56,24 @@ dependencies = [ "slab", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + [[package]] name = "itoa" version = "1.0.18" @@ -90,6 +114,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" name = "mulder-config" version = "2.0.0" dependencies = [ + "indexmap", "native-windows-derive", "native-windows-gui", "regex-lite", diff --git a/Cargo.toml b/Cargo.toml index ca7be18..d020fea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ name = "MulderConfig" path = "src/main.rs" [dependencies] +indexmap = { version = "2", features = ["serde"] } native-windows-derive = "1.0.5" native-windows-gui = "1.0.13" regex-lite = "0.1" diff --git a/src/context.rs b/src/context.rs index 2f7ce4a..4f719da 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,13 +1,12 @@ -use std::collections::HashMap; use crate::config::model::ConfigModel; -use crate::save::{SaveData, SaveValue}; +use crate::save::{GroupSelections, SaveData}; pub struct AppContext { pub config: ConfigModel, pub save_data: SaveData, pub save_path: String, pub active_title: String, - pub selections: HashMap, + pub selections: GroupSelections, } impl AppContext { @@ -23,7 +22,7 @@ impl AppContext { } /// Persist the current UI selections into save_data under active_title. - pub fn save_selections(&mut self, selections: HashMap) { + pub fn save_selections(&mut self, selections: GroupSelections) { self.selections = selections.clone(); self.save_data.insert(self.active_title.clone(), selections); } diff --git a/src/save.rs b/src/save.rs index 6c14737..7d6dd47 100644 --- a/src/save.rs +++ b/src/save.rs @@ -2,10 +2,15 @@ pub mod loader; pub mod saver; pub mod validator; +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -pub type SaveData = HashMap>; +/// Outer map: game title → group selections. +/// Inner map: group name → selected value(s). +/// IndexMap preserves insertion order so the saved JSON mirrors config/UI order. +pub type GroupSelections = IndexMap; +pub type SaveData = HashMap; #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(untagged)] diff --git a/src/save/validator.rs b/src/save/validator.rs index 5a1d576..b968a82 100644 --- a/src/save/validator.rs +++ b/src/save/validator.rs @@ -67,7 +67,7 @@ impl SaveValidator { mod tests { use super::*; use crate::config::model::*; - use crate::save::SaveValue; + use crate::save::{GroupSelections, SaveValue}; use std::collections::HashMap; fn make_config(groups: Vec) -> ConfigModel { @@ -83,7 +83,7 @@ mod tests { } fn make_save(entries: &[(&str, SaveValue)]) -> SaveData { - let inner: HashMap = entries + let inner: GroupSelections = entries .iter() .map(|(k, v)| (k.to_string(), v.clone())) .collect(); diff --git a/src/ui/controls.rs b/src/ui/controls.rs index 56c1eca..59f8a97 100644 --- a/src/ui/controls.rs +++ b/src/ui/controls.rs @@ -1,9 +1,9 @@ use native_windows_gui as nwg; -use std::collections::HashMap; +use indexmap::IndexMap; use crate::{ config::model::WhenGroup, config::when_resolver::{match_when, Selections, SelectionValue}, - save::SaveValue, + save::{GroupSelections, SaveValue}, }; pub struct RadioItem { @@ -23,7 +23,7 @@ pub enum Group { Checks { name: String, items: Vec }, } -pub fn load_saved_state(selections: &HashMap, groups: &[Group]) { +pub fn load_saved_state(selections: &GroupSelections, groups: &[Group]) { for group in groups { match group { Group::Radios { name, items } => { @@ -67,8 +67,8 @@ pub fn is_config_complete(groups: &[Group]) -> bool { }) } -pub fn collect_selections_for_save(groups: &[Group]) -> HashMap { - let mut map = HashMap::new(); +pub fn collect_selections_for_save(groups: &[Group]) -> GroupSelections { + let mut map = IndexMap::new(); for group in groups { match group { Group::Radios { name, items } => { From ebd8fa5d84fdd7c4ea97c1a0ea6b846178ce8fbf Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:16:05 +0200 Subject: [PATCH 50/77] ExeReplacer - delete before rename --- src/apply/exe_replacer.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/apply/exe_replacer.rs b/src/apply/exe_replacer.rs index 6412428..a01933e 100644 --- a/src/apply/exe_replacer.rs +++ b/src/apply/exe_replacer.rs @@ -65,6 +65,12 @@ pub fn replace(config: &ConfigModel) -> Result<(), String> { return Err(format!("Launcher not found: {}", paths.launcher.display())); } // Step 1: rename original → backup + // Remove stale backup first — fs::rename on Windows fails if destination exists. + // This handles the case where Steam updated Game.exe after a previous apply. + if paths.backup.exists() { + std::fs::remove_file(&paths.backup) + .map_err(|e| format!("Failed to remove stale backup '{}': {e}", paths.backup.display()))?; + } std::fs::rename(&paths.original, &paths.backup) .map_err(|e| format!("Failed to rename '{}' to '{}': {e}", paths.original.display(), paths.backup.display()))?; From f70972cb9c25c3dd8d1d5acc81511e3936ab00a3 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:02:30 +0200 Subject: [PATCH 51/77] ExeReplacer - add tests --- src/apply/exe_replacer.rs | 146 +++++++++++++++++++++++++++----------- src/save/validator.rs | 1 - 2 files changed, 105 insertions(+), 42 deletions(-) diff --git a/src/apply/exe_replacer.rs b/src/apply/exe_replacer.rs index a01933e..86e72e9 100644 --- a/src/apply/exe_replacer.rs +++ b/src/apply/exe_replacer.rs @@ -1,40 +1,38 @@ use std::os::windows::io::AsRawHandle; use windows_sys::Win32::Storage::FileSystem::{GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION}; - use std::path::{Path, PathBuf}; - use crate::config::model::ConfigModel; pub struct ExePaths { - pub original: PathBuf, // Game.exe (may be the hard link after replacement) - pub backup: PathBuf, // Game_o.exe - pub launcher: PathBuf, // MulderConfig.exe + pub original: PathBuf, // Game.exe (becomes a hard link after apply) + pub original_backup: PathBuf, // Game_o.exe (original game exe, preserved) + pub mulder_config: PathBuf, // This exe (MulderConfig.exe or renamed) } -pub fn get_paths(config: &ConfigModel) -> ExePaths { - let base = exe_dir(); - let original = base.join(&config.game.original_exe); - let stem = Path::new(&config.game.original_exe) +/// Pure path computation — separated from env access for testability. +fn build_paths(base: &Path, original_exe: &str) -> (PathBuf, PathBuf) { + let stem = Path::new(original_exe) .file_stem() .unwrap_or_default() .to_string_lossy() .into_owned(); - let ext = Path::new(&config.game.original_exe) + let ext = Path::new(original_exe) .extension() .map(|e| format!(".{}", e.to_string_lossy())) .unwrap_or_default(); - let backup = base.join(format!("{stem}_o{ext}")); - let launcher = std::env::current_exe().unwrap(); - ExePaths { original, backup, launcher } + (base.join(original_exe), base.join(format!("{stem}_o{ext}"))) } -/// Returns true when Game.exe and MulderConfig.exe are the same file (hard link). +pub fn get_paths(config: &ConfigModel) -> ExePaths { + let base = exe_dir(); + let (original, backup) = build_paths(&base, &config.game.original_exe); + ExePaths { original, original_backup: backup, mulder_config: std::env::current_exe().unwrap() } +} + +/// Returns true when Game.exe is a hard link to this exe. pub fn is_replaced(config: &ConfigModel) -> bool { let paths = get_paths(config); - if !paths.original.exists() || !paths.launcher.exists() { - return false; - } - same_file(&paths.original, &paths.launcher).unwrap_or(false) + paths.original.exists() && same_file(&paths.original, &paths.mulder_config).unwrap_or(false) } fn same_file(a: &Path, b: &Path) -> std::io::Result { @@ -54,45 +52,34 @@ fn same_file(a: &Path, b: &Path) -> std::io::Result { } } -/// Replaces Game.exe with a hard link to MulderConfig.exe. +/// Replaces Game.exe with a hard link to this exe. /// Original Game.exe is renamed to Game_o.exe. +/// If a stale backup already exists (e.g. after a launcher update), it is removed first. pub fn replace(config: &ConfigModel) -> Result<(), String> { let paths = get_paths(config); if !paths.original.exists() { return Err(format!("Original exe not found: {}", paths.original.display())); } - if !paths.launcher.exists() { - return Err(format!("Launcher not found: {}", paths.launcher.display())); + if paths.original_backup.exists() { + std::fs::remove_file(&paths.original_backup) + .map_err(|e| format!("Failed to remove stale backup '{}': {e}", paths.original_backup.display()))?; } - // Step 1: rename original → backup - // Remove stale backup first — fs::rename on Windows fails if destination exists. - // This handles the case where Steam updated Game.exe after a previous apply. - if paths.backup.exists() { - std::fs::remove_file(&paths.backup) - .map_err(|e| format!("Failed to remove stale backup '{}': {e}", paths.backup.display()))?; - } - std::fs::rename(&paths.original, &paths.backup) + std::fs::rename(&paths.original, &paths.original_backup) .map_err(|e| format!("Failed to rename '{}' to '{}': {e}", - paths.original.display(), paths.backup.display()))?; - // Step 2: hard link MulderConfig.exe → original slot - std::fs::hard_link(&paths.launcher, &paths.original) + paths.original.display(), paths.original_backup.display()))?; + std::fs::hard_link(&paths.mulder_config, &paths.original) .map_err(|e| { - // Try to restore original on failure - let _ = std::fs::rename(&paths.backup, &paths.original); + let _ = std::fs::rename(&paths.original_backup, &paths.original); format!("Failed to create hard link: {e}") })?; Ok(()) } -/// Returns the path of the actual game executable to launch. -/// After replacement: Game_o.exe; otherwise: Game.exe. +/// Returns the actual game exe path to launch. +/// After replacement: Game_o.exe (original game); otherwise: Game.exe. pub fn get_default_launch_exe(config: &ConfigModel) -> PathBuf { let paths = get_paths(config); - if is_replaced(config) && paths.backup.exists() { - paths.backup - } else { - paths.original - } + if paths.original_backup.exists() { paths.original_backup } else { paths.original } } fn exe_dir() -> PathBuf { @@ -101,3 +88,80 @@ fn exe_dir() -> PathBuf { .and_then(|p| p.parent().map(|d| d.to_path_buf())) .unwrap_or_else(|| PathBuf::from(".")) } + +#[cfg(test)] +mod tests { + use super::*; + + // --- Pure path tests --- + + #[test] + fn build_paths_simple() { + let (original, backup) = build_paths(Path::new("C:/game"), "Game.exe"); + assert_eq!(original, Path::new("C:/game/Game.exe")); + assert_eq!(backup, Path::new("C:/game/Game_o.exe")); + } + + #[test] + fn build_paths_preserves_extension() { + let (_, backup) = build_paths(Path::new("."), "Launcher.exe"); + assert_eq!(backup.file_name().unwrap(), "Launcher_o.exe"); + } + + #[test] + fn build_paths_no_extension() { + let (_, backup) = build_paths(Path::new("."), "game"); + assert_eq!(backup.file_name().unwrap(), "game_o"); + } + + // --- Filesystem tests --- + + fn setup_dir(name: &str) -> PathBuf { + // Must be on the same drive as current_exe for rename + hard_link to work. + let dir = exe_dir().join(name); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn replace_renames_original_and_creates_hard_link() { + let dir = setup_dir("mulder_replace_basic"); + let original = dir.join("Game.exe"); + let backup = dir.join("Game_o.exe"); + let mulder_config = std::env::current_exe().unwrap(); + + std::fs::write(&original, b"fake game").unwrap(); + let _ = std::fs::remove_file(&backup); + + std::fs::rename(&original, &backup).unwrap(); + std::fs::hard_link(&mulder_config, &original).unwrap(); + + assert!(backup.exists(), "backup should exist"); + assert!(original.exists(), "hard link should exist"); + + let _ = std::fs::remove_file(&original); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn replace_overwrites_stale_backup() { + let dir = setup_dir("mulder_replace_stale"); + let original = dir.join("Game.exe"); + let backup = dir.join("Game_o.exe"); + let mulder_config = std::env::current_exe().unwrap(); + + std::fs::write(&original, b"new game v2").unwrap(); + std::fs::write(&backup, b"old game v1").unwrap(); + + // Simulate the stale-backup removal + rename sequence from replace() + std::fs::remove_file(&backup).unwrap(); + std::fs::rename(&original, &backup).unwrap(); + std::fs::hard_link(&mulder_config, &original).unwrap(); + + let content = std::fs::read(&backup).unwrap(); + assert_eq!(content, b"new game v2", "backup must be the new version"); + + let _ = std::fs::remove_file(&original); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/save/validator.rs b/src/save/validator.rs index b968a82..65bb8d9 100644 --- a/src/save/validator.rs +++ b/src/save/validator.rs @@ -68,7 +68,6 @@ mod tests { use super::*; use crate::config::model::*; use crate::save::{GroupSelections, SaveValue}; - use std::collections::HashMap; fn make_config(groups: Vec) -> ConfigModel { ConfigModel { From 2303a1aabcadf984e3490c70cfa5637a2c1f034b Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:06:12 +0200 Subject: [PATCH 52/77] FileOps - add tests + fix empty line case --- src/apply/file_ops.rs | 103 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index a84ec11..072219e 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -224,10 +224,15 @@ fn filter_lines_in_file(path: &Path, re: &Regex, replacement_lines: &[&str]) -> if i > 0 { out.push_str(eol); } out.push_str(rep); } + // Only write EOL if we actually output something (replace case). + // For the remove case (empty replacement), skip the EOL entirely. + if !replacement_lines.is_empty() { + out.push_str(eol); + } } else { out.push_str(line); + out.push_str(eol); } - out.push_str(eol); } // split('\n') always produces a trailing empty element for files that end @@ -304,4 +309,100 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + + // ── unescape ────────────────────────────────────────────────────────────── + + #[test] + fn unescape_newline() { + assert_eq!(unescape("a\\nb"), "a\nb"); + } + + #[test] + fn unescape_tab() { + assert_eq!(unescape("a\\tb"), "a\tb"); + } + + #[test] + fn unescape_unknown_keeps_backslash() { + assert_eq!(unescape("a\\xb"), "a\\xb"); + } + + #[test] + fn unescape_trailing_backslash() { + assert_eq!(unescape("a\\"), "a\\"); + } + + // ── expand_env_vars ─────────────────────────────────────────────────────── + + #[test] + fn expand_env_vars_known() { + // PATH is always set, so we use it to test expansion without set_var. + let path_val = std::env::var("PATH").unwrap(); + assert_eq!(expand_env_vars("%PATH%"), path_val); + } + + #[test] + fn expand_env_vars_unknown_keeps_literal() { + assert_eq!(expand_env_vars("%NO_SUCH_VAR_XYZ%"), "%NO_SUCH_VAR_XYZ%"); + } + + #[test] + fn expand_env_vars_no_percent() { + assert_eq!(expand_env_vars("just a string"), "just a string"); + } + + // ── filter_lines_in_file ────────────────────────────────────────────────── + + fn write_temp(name: &str, content: &[u8]) -> std::path::PathBuf { + let path = std::env::temp_dir().join(name); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn filter_removes_matching_line() { + let path = write_temp("mulder_remove.txt", b"keep\nremove me\nalso keep\n"); + let re = Regex::new("(?i)remove").unwrap(); + filter_lines_in_file(&path, &re, &[]).unwrap(); + let result = std::fs::read_to_string(&path).unwrap(); + assert_eq!(result, "keep\nalso keep\n"); + } + + #[test] + fn filter_replaces_matching_line() { + let path = write_temp("mulder_replace.txt", b"before\nold line\nafter\n"); + let re = Regex::new("(?i)old").unwrap(); + filter_lines_in_file(&path, &re, &["new line"]).unwrap(); + let result = std::fs::read_to_string(&path).unwrap(); + assert_eq!(result, "before\nnew line\nafter\n"); + } + + #[test] + fn filter_is_case_insensitive() { + let path = write_temp("mulder_case.txt", b"KEEP\nOLD LINE\nKEEP\n"); + let re = Regex::new("(?i)old line").unwrap(); + filter_lines_in_file(&path, &re, &[]).unwrap(); + let result = std::fs::read_to_string(&path).unwrap(); + assert_eq!(result, "KEEP\nKEEP\n"); + } + + #[test] + fn filter_preserves_crlf() { + let path = write_temp("mulder_crlf.txt", b"keep\r\nremove me\r\nalso keep\r\n"); + let re = Regex::new("(?i)remove").unwrap(); + filter_lines_in_file(&path, &re, &[]).unwrap(); + let result = std::fs::read(&path).unwrap(); + assert_eq!(result, b"keep\r\nalso keep\r\n"); + } + + #[test] + fn filter_no_match_does_not_write() { + let content = b"line one\nline two\n"; + let path = write_temp("mulder_nomatch.txt", content); + let modified_before = std::fs::metadata(&path).unwrap().modified().unwrap(); + let re = Regex::new("(?i)no match here").unwrap(); + filter_lines_in_file(&path, &re, &[]).unwrap(); + let modified_after = std::fs::metadata(&path).unwrap().modified().unwrap(); + assert_eq!(modified_before, modified_after, "file should not be written when no match"); + } } From d8d19a01f3412ebd0a91bb19ba7f2699e99fa84b Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:08:03 +0200 Subject: [PATCH 53/77] Launcher - add tests --- src/apply/launcher.rs | 73 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/apply/launcher.rs b/src/apply/launcher.rs index 74b94ec..dc51302 100644 --- a/src/apply/launcher.rs +++ b/src/apply/launcher.rs @@ -164,4 +164,77 @@ mod tests { assert_eq!(resolved.work_dir, PathBuf::from("C:\\tmp")); assert_eq!(resolved.args, vec!["-nosetup", "-novsync", "-borderless"]); } + + #[test] + fn rule_without_when_always_applies() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "exec": { "name": "always.exe", "workDir": ".\\" }, + "args": ["-always"] + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let resolved = resolve_launch(&config, &sel(&[])); + + assert_eq!(resolved.exe.file_name().unwrap(), "always.exe"); + assert_eq!(resolved.args, vec!["-always"]); + } + + #[test] + fn wait_flag_propagated() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "exec": { "name": "game.exe", "workDir": ".\\", "wait": true } + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let resolved = resolve_launch(&config, &sel(&[])); + + assert!(resolved.wait); + } + + #[test] + fn args_only_rule_does_not_change_exe() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "exec": { "name": "game.exe", "workDir": ".\\" } + }, + { + "when": [ { "Renderer": "DX9" } ], + "args": ["-dx9"] + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let resolved = resolve_launch(&config, &sel(&[("Renderer", "DX9")])); + + assert_eq!(resolved.exe.file_name().unwrap(), "game.exe"); + assert_eq!(resolved.args, vec!["-dx9"]); + } } From e6cb991d7888c8b5a07cf7cbe7de36345b0c3812 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Mon, 4 May 2026 18:28:17 +0200 Subject: [PATCH 54/77] file_ops - check same drive before move --- src/apply/file_ops.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index 072219e..90b57ff 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -119,6 +119,12 @@ fn exec_move(action: &OperationAction) -> Result<(), String> { let src = resolve_path(src); let dst = resolve_path(dst); if !src.exists() { return Ok(()); } // idempotent + if !same_drive(&src, &dst) { + return Err(format!( + "Cannot move '{}' to '{}': source and destination are on different drives.", + src.display(), dst.display() + )); + } if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?; } @@ -128,6 +134,10 @@ fn exec_move(action: &OperationAction) -> Result<(), String> { std::fs::rename(&src, &dst).map_err(|e| format!("{e}")) } +fn same_drive(a: &Path, b: &Path) -> bool { + a.components().next() == b.components().next() +} + fn exec_copy(action: &OperationAction) -> Result<(), String> { let src = action.source.as_deref() .filter(|s| !s.is_empty()) @@ -310,6 +320,26 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + // ── same_drive ──────────────────────────────────────────────────────────── + + #[test] + fn same_drive_identical_root() { + assert!(same_drive(Path::new("C:\\foo\\a.txt"), Path::new("C:\\bar\\b.txt"))); + } + + #[test] + fn same_drive_different_root() { + assert!(!same_drive(Path::new("C:\\foo"), Path::new("D:\\bar"))); + } + + #[test] + fn same_drive_case_insensitive_roots() { + // On Windows path components are case-insensitive by convention; + // this test documents the current behaviour (Rust compares raw bytes). + // In practice config paths always use consistent casing. + assert!(same_drive(Path::new("C:\\foo"), Path::new("C:\\bar"))); + } + // ── unescape ────────────────────────────────────────────────────────────── #[test] From 8230077fdb8ee1aa3def7809a7f05dd94febc792 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Mon, 4 May 2026 18:34:07 +0200 Subject: [PATCH 55/77] fix(file_ops): preserve trailing-newline state in filter_lines_in_file --- src/apply/file_ops.rs | 50 ++++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index 90b57ff..da2c4d9 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -222,36 +222,35 @@ fn filter_lines_in_file(path: &Path, re: &Regex, replacement_lines: &[&str]) -> // Detect the dominant line ending so we can preserve it. let eol = if content.contains("\r\n") { "\r\n" } else { "\n" }; + // split('\n') on a file ending with '\n' always produces a trailing "". + // We peel that off, process the real lines, then re-add the trailing EOL + // at the very end based on whether the original file had one. + let had_trailing_newline = content.ends_with('\n'); + let all_lines: Vec<&str> = content.split('\n').collect(); + let raw_lines = if had_trailing_newline && !all_lines.is_empty() { + &all_lines[..all_lines.len() - 1] + } else { + &all_lines[..] + }; + let mut modified = false; - let mut out = String::with_capacity(content.len()); + let mut out_lines: Vec<&str> = Vec::with_capacity(raw_lines.len()); - for raw_line in content.split('\n') { - // Strip the \r so the regex pattern doesn't have to account for it. + for raw_line in raw_lines { let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); if re.is_match(line) { modified = true; - for (i, rep) in replacement_lines.iter().enumerate() { - if i > 0 { out.push_str(eol); } - out.push_str(rep); - } - // Only write EOL if we actually output something (replace case). - // For the remove case (empty replacement), skip the EOL entirely. - if !replacement_lines.is_empty() { - out.push_str(eol); - } + out_lines.extend_from_slice(replacement_lines); } else { - out.push_str(line); - out.push_str(eol); + out_lines.push(line); } } - // split('\n') always produces a trailing empty element for files that end - // with a newline — remove its trailing eol to avoid adding a blank line. - if content.ends_with('\n') { - out.truncate(out.len() - eol.len()); - } - if modified { + let mut out = out_lines.join(eol); + if had_trailing_newline { + out.push_str(eol); + } std::fs::write(path, out.as_bytes()).map_err(|e| e.to_string())?; } Ok(()) @@ -435,4 +434,15 @@ mod tests { let modified_after = std::fs::metadata(&path).unwrap().modified().unwrap(); assert_eq!(modified_before, modified_after, "file should not be written when no match"); } + + #[test] + fn filter_removes_line_from_file_without_trailing_newline() { + // Bug: removing the last line from a file without trailing \n + // used to produce a spurious trailing \n in the output. + let path = write_temp("mulder_no_trailing_nl.txt", b"keep\nremove me"); + let re = Regex::new("(?i)remove").unwrap(); + filter_lines_in_file(&path, &re, &[]).unwrap(); + let result = std::fs::read(&path).unwrap(); + assert_eq!(result, b"keep"); + } } From 8533bb8821e30bf096b17cd24f27b85b58023069 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Mon, 4 May 2026 18:36:59 +0200 Subject: [PATCH 56/77] fix(file_ops): create dest directory if not exists --- src/apply/file_ops.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index da2c4d9..f46e34c 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -148,6 +148,9 @@ fn exec_copy(action: &OperationAction) -> Result<(), String> { let src = resolve_path(src); let dst = resolve_path(dst); if !src.exists() { return Ok(()); } // idempotent + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?; + } std::fs::copy(&src, &dst) .map(|_| ()) .map_err(|e| format!("{e}")) @@ -281,6 +284,19 @@ mod tests { use crate::config::model::OperationAction; use crate::config::when_resolver::Selections; + fn op_copy(source: &str, target: &str) -> OperationAction { + OperationAction { + when: None, + operation: "copy".into(), + source: Some(source.into()), + target: Some(target.into()), + files: None, + pattern: None, + search: None, + replacement: None, + } + } + fn op_move(source: &str, target: &str) -> OperationAction { OperationAction { when: None, @@ -319,6 +335,32 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + // ── exec_copy ───────────────────────────────────────────────────────────── + + #[test] + fn copy_creates_missing_parent_dir() { + let root = std::env::temp_dir() + .join(format!("mulderconfig_copy_test_{}", std::process::id())); + let src_file = root.join("src.txt"); + let dst_file = root.join("nested").join("subdir").join("dst.txt"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&src_file, b"hello").unwrap(); + + let errors = execute_operations( + &[op_copy( + src_file.to_str().unwrap(), + dst_file.to_str().unwrap(), + )], + &Selections::new(), + ); + + assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); + assert!(dst_file.exists(), "Copied file should exist at destination"); + assert_eq!(std::fs::read(&dst_file).unwrap(), b"hello"); + + std::fs::remove_dir_all(&root).ok(); + } + // ── same_drive ──────────────────────────────────────────────────────────── #[test] From f4f0738ad9ec0ced0ce0f87e388403f071e6d5f9 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Mon, 4 May 2026 18:45:22 +0200 Subject: [PATCH 57/77] test(file_ops): document replace_line multi-line and CRLF behaviour --- src/apply/file_ops.rs | 75 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index f46e34c..a387fe9 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -284,6 +284,19 @@ mod tests { use crate::config::model::OperationAction; use crate::config::when_resolver::Selections; + fn op_replace_line(files: &[&str], pattern: &str, replacement: &str) -> OperationAction { + OperationAction { + when: None, + operation: "replaceLine".into(), + source: None, + target: None, + files: Some(files.iter().map(|s| (*s).into()).collect()), + pattern: Some(pattern.into()), + search: None, + replacement: Some(replacement.into()), + } + } + fn op_copy(source: &str, target: &str) -> OperationAction { OperationAction { when: None, @@ -487,4 +500,66 @@ mod tests { let result = std::fs::read(&path).unwrap(); assert_eq!(result, b"keep"); } + + // ── replace_line with escaped \n in replacement ─────────────────────────── + + #[test] + fn replace_line_escaped_newline_expands_to_multiple_lines() { + // A replacement value like "a\nb" in JSON expands to two lines. + let path = write_temp("mulder_multiline_rep.txt", b"before\nold\nafter\n"); + let errors = execute_operations( + &[op_replace_line( + &[path.to_str().unwrap()], + "old", + "new1\\nnew2", + )], + &Selections::new(), + ); + assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); + let result = std::fs::read_to_string(&path).unwrap(); + assert_eq!(result, "before\nnew1\nnew2\nafter\n"); + } + + #[test] + fn replace_line_multiline_replacement_uses_crlf_in_crlf_file() { + // When the file uses CRLF and the replacement contains \n, + // the separator between the inserted lines must be \r\n, not \n. + let path = write_temp( + "mulder_crlf_multiline_rep.txt", + b"before\r\nAntiAliasing = 8x\r\nafter\r\n", + ); + let errors = execute_operations( + &[op_replace_line( + &[path.to_str().unwrap()], + "AntiAliasing = 8x", + "AntiAliasing = 8x\\nAAMethod = FXAA", + )], + &Selections::new(), + ); + assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); + let result = std::fs::read(&path).unwrap(); + assert_eq!( + result, + b"before\r\nAntiAliasing = 8x\r\nAAMethod = FXAA\r\nafter\r\n", + "Replacement lines must be separated by \\r\\n in a CRLF file" + ); + } + + #[test] + fn replace_line_empty_segments_in_replacement_are_ignored() { + // Double \n in replacement ("a\n\nb") produces two lines, not three — + // the empty segment between the two \n is filtered out. + let path = write_temp("mulder_empty_seg.txt", b"before\nold\nafter\n"); + let errors = execute_operations( + &[op_replace_line( + &[path.to_str().unwrap()], + "old", + "new1\\n\\nnew2", + )], + &Selections::new(), + ); + assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); + let result = std::fs::read_to_string(&path).unwrap(); + assert_eq!(result, "before\nnew1\nnew2\nafter\n"); + } } From ec44bf45f3ac430f51204d227798786faf7e7b14 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Mon, 4 May 2026 18:47:49 +0200 Subject: [PATCH 58/77] Keep WhenGroup order as defined in Json --- src/config/model.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/model.rs b/src/config/model.rs index 14963d4..69ba25f 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1,5 +1,5 @@ +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -57,7 +57,7 @@ pub struct Checkbox { pub disabled_when: Option>, } -pub type WhenGroup = HashMap; +pub type WhenGroup = IndexMap; #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] From 123dda22740b281686ac210ad09493d75f8441fa Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 5 May 2026 09:41:07 +0200 Subject: [PATCH 59/77] Make save works with unmatching case --- src/save/validator.rs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/save/validator.rs b/src/save/validator.rs index 65bb8d9..1abb160 100644 --- a/src/save/validator.rs +++ b/src/save/validator.rs @@ -19,7 +19,7 @@ impl SaveValidator { let radios = group.radios.as_ref().unwrap(); match saved { SaveValue::Single(val) => { - if !radios.iter().any(|r| &r.value == val) { + if !radios.iter().any(|r| r.value.eq_ignore_ascii_case(val)) { return Err(format!( "Invalid radio value '{}' for group '{}'", val, group.name @@ -40,7 +40,7 @@ impl SaveValidator { match saved { SaveValue::Multiple(vals) => { for val in vals { - if !checkboxes.iter().any(|c| &c.value == val) { + if !checkboxes.iter().any(|c| c.value.eq_ignore_ascii_case(val)) { return Err(format!( "Invalid checkbox value '{}' for group '{}'", val, group.name @@ -161,6 +161,36 @@ mod tests { assert!(SaveValidator::validate(&save, &config).is_err()); } + #[test] + fn valid_when_radio_value_differs_only_in_case() { + let config = make_config(vec![OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![ + Radio { value: "DX9".into(), disabled_when: None }, + Radio { value: "DX11".into(), disabled_when: None }, + ]), + checkboxes: None, + }]); + let save = make_save(&[("Renderer", SaveValue::Single("dx9".into()))]); + assert!(SaveValidator::validate(&save, &config).is_ok()); + } + + #[test] + fn valid_when_checkbox_value_differs_only_in_case() { + let config = make_config(vec![OptionGroup { + name: "Mods".into(), + kind: OptionGroupType::CheckboxGroup, + radios: None, + checkboxes: Some(vec![ + Checkbox { value: "ModA".into(), disabled_when: None }, + Checkbox { value: "ModB".into(), disabled_when: None }, + ]), + }]); + let save = make_save(&[("Mods", SaveValue::Multiple(vec!["moda".into(), "MODB".into()]))]); + assert!(SaveValidator::validate(&save, &config).is_ok()); + } + #[test] fn err_for_wrong_types() { let config = make_config(vec![ From d7e25e3cb7260575775291e84a6a1d7d39dc9d15 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 5 May 2026 09:47:56 +0200 Subject: [PATCH 60/77] resolve_path in exec too --- src/apply/file_ops.rs | 2 +- src/apply/launcher.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index a387fe9..b289d77 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -51,7 +51,7 @@ fn resolve_path(path: &str) -> PathBuf { } } -fn expand_env_vars(s: &str) -> String { +pub fn expand_env_vars(s: &str) -> String { let mut result = String::new(); let mut rest = s; while let Some(start) = rest.find('%') { diff --git a/src/apply/launcher.rs b/src/apply/launcher.rs index dc51302..0835157 100644 --- a/src/apply/launcher.rs +++ b/src/apply/launcher.rs @@ -64,7 +64,8 @@ pub fn launch(config: &ConfigModel, selected: &Selections) -> Result<(), String> } fn resolve_path(path: &str) -> PathBuf { - let p = Path::new(path); + let expanded = super::file_ops::expand_env_vars(path); + let p = Path::new(&expanded); if p.is_absolute() { p.to_path_buf() } else { @@ -237,4 +238,30 @@ mod tests { assert_eq!(resolved.exe.file_name().unwrap(), "game.exe"); assert_eq!(resolved.args, vec!["-dx9"]); } + + #[test] + fn resolve_path_expands_env_vars_in_exec_name() { + // %SystemRoot% is always set on Windows (e.g. C:\Windows). + let sys = std::env::var("SystemRoot").unwrap(); + let json = format!(r#" + {{ + "game": {{ "title": "Test", "originalExe": "Game.exe" }}, + "optionGroups": [], + "actions": {{ + "launch": [ + {{ + "exec": {{ "name": "%SystemRoot%\\System32\\notepad.exe", "workDir": ".\\" }} + }} + ], + "operations": [] + }} + }} + "#); + + let config = parse_config(&json); + let resolved = resolve_launch(&config, &sel(&[])); + + let expected = PathBuf::from(format!("{}\\System32\\notepad.exe", sys)); + assert_eq!(resolved.exe, expected, "%%SystemRoot%% in exec.name should be expanded"); + } } From 5a267ba7a4cc87ed2e6ea754c596f5180b3946b4 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 5 May 2026 10:54:51 +0200 Subject: [PATCH 61/77] validator - allow emply replacement in replaceText --- src/config/validator.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/config/validator.rs b/src/config/validator.rs index bb36516..6129ead 100644 --- a/src/config/validator.rs +++ b/src/config/validator.rs @@ -133,7 +133,7 @@ impl ConfigValidator { if op.search.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) { return Err("Operation 'replaceText' requires search".into()); } - if op.replacement.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) { + if op.replacement.is_none() { return Err("Operation 'replaceText' requires replacement".into()); } } @@ -323,4 +323,21 @@ mod tests { }]; assert!(ConfigValidator::validate(&cfg).is_ok()); } + + #[test] + fn valid_when_replace_text_has_empty_replacement() { + // empty replacement is a valid "delete all occurrences of search text" use case + let mut cfg = minimal_valid(); + cfg.actions.operations = vec![OperationAction { + when: None, + operation: "replaceText".into(), + source: None, + target: None, + files: Some(vec!["Fallout.ini".into()]), + pattern: None, + search: Some("OldText".into()), + replacement: Some("".into()), + }]; + assert!(ConfigValidator::validate(&cfg).is_ok()); + } } From c1f945ec637b8cf59e48e7fea953459feaf7ebf0 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 5 May 2026 11:21:26 +0200 Subject: [PATCH 62/77] Keep game/addons order in save Json --- src/save.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/save.rs b/src/save.rs index 7d6dd47..bbbb5e3 100644 --- a/src/save.rs +++ b/src/save.rs @@ -4,13 +4,12 @@ pub mod validator; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; /// Outer map: game title → group selections. /// Inner map: group name → selected value(s). /// IndexMap preserves insertion order so the saved JSON mirrors config/UI order. pub type GroupSelections = IndexMap; -pub type SaveData = HashMap; +pub type SaveData = IndexMap; #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(untagged)] From 8e623bacc9ec6606dc909e84acb361fcac9341eb Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 5 May 2026 11:29:57 +0200 Subject: [PATCH 63/77] ui - release GDI font handles on drop --- src/ui/chrome.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index 4698f04..28f4519 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -7,7 +7,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ }; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; use windows_sys::Win32::UI::Input::KeyboardAndMouse::IsWindowEnabled; -use windows_sys::Win32::Graphics::Gdi::{CreateSolidBrush, CreateFontIndirectW, SetTextColor, SetBkMode, FillRect}; +use windows_sys::Win32::Graphics::Gdi::{CreateSolidBrush, CreateFontIndirectW, DeleteObject, SetTextColor, SetBkMode, FillRect}; use windows_sys::Win32::Foundation::RECT; use windows_sys::Win32::UI::Shell::ShellExecuteW; @@ -39,6 +39,17 @@ pub struct AppFonts { pub link: isize, } +impl Drop for AppFonts { + fn drop(&mut self) { + unsafe { + DeleteObject(self.normal as *mut _); + DeleteObject(self.bold as *mut _); + DeleteObject(self.small as *mut _); + DeleteObject(self.link as *mut _); + } + } +} + pub fn create_fonts() -> AppFonts { unsafe { let mut ncm: NONCLIENTMETRICSW = std::mem::zeroed(); From 04675a50876c78edcfa84f7e29b8587bc9ee47a2 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 5 May 2026 12:17:37 +0200 Subject: [PATCH 64/77] cargo clippy (fix warning) --- src/apply.rs | 5 ++--- src/apply/file_ops.rs | 5 ++--- src/apply/launcher.rs | 5 ++--- src/config/when_resolver.rs | 12 ++++++------ src/main.rs | 2 +- src/ui.rs | 10 +++++----- src/ui/chrome.rs | 3 ++- src/ui/error.rs | 2 +- 8 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/apply.rs b/src/apply.rs index fb76393..15c36bd 100644 --- a/src/apply.rs +++ b/src/apply.rs @@ -26,11 +26,10 @@ fn to_selections(ctx: &AppContext) -> Selections { pub fn apply(ctx: &AppContext) -> Vec { let selected = to_selections(ctx); let mut errors = file_ops::execute_operations(&ctx.config.actions.operations, &selected); - if !ctx.config.actions.launch.is_empty() && !exe_replacer::is_replaced(&ctx.config) { - if let Err(e) = exe_replacer::replace(&ctx.config) { + if !ctx.config.actions.launch.is_empty() && !exe_replacer::is_replaced(&ctx.config) + && let Err(e) = exe_replacer::replace(&ctx.config) { errors.push(e); } - } errors } diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index b289d77..7e4cd54 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -7,11 +7,10 @@ use crate::config::when_resolver::{match_when, Selections}; pub fn execute_operations(operations: &[OperationAction], selected: &Selections) -> Vec { let mut errors = Vec::new(); for action in operations { - if let Some(when) = &action.when { - if !match_when(when, selected) { + if let Some(when) = &action.when + && !match_when(when, selected) { continue; } - } let result = match action.operation.to_lowercase().as_str() { "setreadonly" => exec_set_readonly(action, true), "removereadonly" => exec_set_readonly(action, false), diff --git a/src/apply/launcher.rs b/src/apply/launcher.rs index 0835157..85874d1 100644 --- a/src/apply/launcher.rs +++ b/src/apply/launcher.rs @@ -19,11 +19,10 @@ fn resolve_launch(config: &ConfigModel, selected: &Selections) -> ResolvedLaunch let mut args: Vec = Vec::new(); for rule in &config.actions.launch { - if let Some(when) = &rule.when { - if !match_when(when, selected) { + if let Some(when) = &rule.when + && !match_when(when, selected) { continue; } - } // Last match wins for exec if let Some(exec) = &rule.exec { exe = resolve_path(&exec.name); diff --git a/src/config/when_resolver.rs b/src/config/when_resolver.rs index 1176080..61fac7a 100644 --- a/src/config/when_resolver.rs +++ b/src/config/when_resolver.rs @@ -32,12 +32,12 @@ enum ConditionOperator { } fn parse_key(raw_key: &str) -> (ConditionOperator, &str) { - if raw_key.starts_with("!*") { - (ConditionOperator::NotContains, &raw_key[2..]) - } else if raw_key.starts_with('*') { - (ConditionOperator::Contains, &raw_key[1..]) - } else if raw_key.starts_with('!') { - (ConditionOperator::NotEquals, &raw_key[1..]) + if let Some(s) = raw_key.strip_prefix("!*") { + (ConditionOperator::NotContains, s) + } else if let Some(s) = raw_key.strip_prefix('*') { + (ConditionOperator::Contains, s) + } else if let Some(s) = raw_key.strip_prefix('!') { + (ConditionOperator::NotEquals, s) } else { (ConditionOperator::Equals, raw_key) } diff --git a/src/main.rs b/src/main.rs index 86112f1..f8a67f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,7 +46,7 @@ fn main() { }; let active_title = config.addons.as_deref() - .and_then(|addons| detect_addon(addons)) + .and_then(detect_addon) .unwrap_or_else(|| config.game.title.clone()); let ctx = AppContext::new(config, save_data, SAVE_PATH.to_string(), active_title); diff --git a/src/ui.rs b/src/ui.rs index 8b7c627..b420ead 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -64,7 +64,7 @@ pub fn run(ctx: AppContext) { chrome::apply_icon(&app.window); // --- Addon combo --- - let has_addons = ctx.config.addons.as_ref().map_or(false, |a| !a.is_empty()); + let has_addons = ctx.config.addons.as_ref().is_some_and(|a| !a.is_empty()); let mut y: i32 = MARGIN; let mut addon_titles: Vec = vec![ctx.config.game.title.clone()]; @@ -95,8 +95,8 @@ pub fn run(ctx: AppContext) { // --- Bind event handlers --- // WM_COMMAND (BN_CLICKED) goes from each radio/checkbox to its direct parent (the Frame). // So we subclass each Frame, not the individual controls. - let frame_handles: Vec = frames.iter().map(|f| f.handle.clone()).collect(); - let window_handle = app.window.handle.clone(); + let frame_handles: Vec = frames.iter().map(|f| f.handle).collect(); + let window_handle = app.window.handle; // Wrap AppContext in Rc for shared mutation across closures let ctx_rc: Rc> = Rc::new(RefCell::new(ctx)); @@ -122,8 +122,8 @@ pub fn run(ctx: AppContext) { }) .collect(); - let save_handle = app.save_button.handle.clone(); - let apply_handle = app.apply_button.handle.clone(); + let save_handle = app.save_button.handle; + let apply_handle = app.apply_button.handle; // Window-level handler: combo selection + save button click const CB_GETCURSEL: u32 = 0x0147; diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index 28f4519..295cae5 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -75,6 +75,7 @@ pub fn create_fonts() -> AppFonts { pub fn apply_icon(window: &nwg::Window) { if let nwg::ControlHandle::Hwnd(hwnd) = window.handle { + #[allow(clippy::manual_dangling_ptr)] // 1 is a resource ID (MAKEINTRESOURCE), not a real pointer let hicon = unsafe { LoadIconW(GetModuleHandleW(std::ptr::null()), 1 as *const u16) }; if !hicon.is_null() { unsafe { @@ -108,7 +109,7 @@ pub fn apply_theme(window: &nwg::Window, frames: &[nwg::Frame]) { } None }).ok(); - std::mem::forget(_theme_win); + let _ = _theme_win; // RawEventHandler has no Drop — handler stays registered at OS level // Frame backgrounds + radio/checkbox colors let _theme_frames: Vec<_> = frames.iter().enumerate().map(|(i, frame)| { diff --git a/src/ui/error.rs b/src/ui/error.rs index 2bd3643..3024cd1 100644 --- a/src/ui/error.rs +++ b/src/ui/error.rs @@ -23,5 +23,5 @@ pub fn ask_delete_save(msg: &str) -> bool { let result = unsafe { MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), MB_YESNO | MB_ICONWARNING) }; - result == IDYES as i32 + result == IDYES } From f4a489fd8af52d36f889f48eb4d22afdde9d5605 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 5 May 2026 12:37:52 +0200 Subject: [PATCH 65/77] Show warning when unknown Steam Addon ID --- src/addon_detector.rs | 60 +++++++++++++++++++++++++++++++++++-------- src/main.rs | 18 ++++++++++--- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/addon_detector.rs b/src/addon_detector.rs index 1ffa20d..90a45b0 100644 --- a/src/addon_detector.rs +++ b/src/addon_detector.rs @@ -1,19 +1,59 @@ -use std::env; use crate::config::model::Addon; -/// Parses `-addon ` from the command-line arguments. -/// Returns the title of the first addon in `addons` whose `steam_id` matches, -/// or `None` if the flag is absent or no addon matches. -pub fn detect_addon(addons: &[Addon]) -> Option { - let args: Vec = env::args().collect(); - - let steam_id = args - .windows(2) +/// Extracts the numeric Steam ID from a `-addon ` flag in the given args. +/// Returns `None` if the flag is absent or the value is not a valid u32. +pub fn parse_addon_arg(args: &[String]) -> Option { + args.windows(2) .find(|w| w[0] == "-addon") - .and_then(|w| w[1].parse::().ok())?; + .and_then(|w| w[1].parse::().ok()) +} +/// Looks up the addon title for the given Steam ID. +/// Returns `Ok(title)` if found, `Err(message)` if the ID is unknown. +pub fn resolve_addon(addons: &[Addon], steam_id: u32) -> Result { addons .iter() .find(|a| a.steam_id == steam_id) .map(|a| a.title.clone()) + .ok_or_else(|| format!("Unknown addon Steam ID {steam_id}, launching with base game configuration.")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::model::Addon; + + fn addon(steam_id: u32, title: &str) -> Addon { + Addon { steam_id, title: title.into() } + } + + #[test] + fn resolve_addon_returns_title_when_found() { + let addons = vec![addon(272270, "Duke Caribbean")]; + assert_eq!(resolve_addon(&addons, 272270), Ok("Duke Caribbean".into())); + } + + #[test] + fn resolve_addon_returns_err_when_unknown() { + let addons = vec![addon(272270, "Duke Caribbean")]; + assert!(resolve_addon(&addons, 99999).is_err()); + } + + #[test] + fn parse_addon_arg_returns_none_when_absent() { + let args = vec!["MulderConfig.exe".into()]; + assert_eq!(parse_addon_arg(&args), None); + } + + #[test] + fn parse_addon_arg_returns_steam_id() { + let args = vec!["MulderConfig.exe".into(), "-addon".into(), "272270".into()]; + assert_eq!(parse_addon_arg(&args), Some(272270)); + } + + #[test] + fn parse_addon_arg_returns_none_for_non_numeric_value() { + let args = vec!["MulderConfig.exe".into(), "-addon".into(), "abc".into()]; + assert_eq!(parse_addon_arg(&args), None); + } } diff --git a/src/main.rs b/src/main.rs index f8a67f2..86e3464 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ mod mode_detector; mod save; mod ui; -use addon_detector::detect_addon; +use addon_detector::{parse_addon_arg, resolve_addon}; use config::loader::ConfigLoader; use context::AppContext; use ui::error::{ask_delete_save, fatal}; @@ -45,9 +45,19 @@ fn main() { Default::default() }; - let active_title = config.addons.as_deref() - .and_then(detect_addon) - .unwrap_or_else(|| config.game.title.clone()); + let args: Vec = std::env::args().collect(); + let active_title = match parse_addon_arg(&args) { + None => config.game.title.clone(), + Some(steam_id) => { + match resolve_addon(config.addons.as_deref().unwrap_or(&[]), steam_id) { + Ok(title) => title, + Err(msg) => { + ui::error::warn(&msg); + config.game.title.clone() + } + } + } + }; let ctx = AppContext::new(config, save_data, SAVE_PATH.to_string(), active_title); From ca858be3cff5abd465980711add56840ad2e193f Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 5 May 2026 15:39:28 +0200 Subject: [PATCH 66/77] Make version number settable with env var --- Cargo.lock | 2 +- Cargo.toml | 2 +- build.rs | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30d7f96..2293330 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mulder-config" -version = "2.0.0" +version = "26.0.0-dev" dependencies = [ "indexmap", "native-windows-derive", diff --git a/Cargo.toml b/Cargo.toml index d020fea..a78ba89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mulder-config" -version = "2.0.0" +version = "26.0.0-dev" edition = "2024" [[bin]] diff --git a/build.rs b/build.rs index 4bfa982..67a92c5 100644 --- a/build.rs +++ b/build.rs @@ -1,7 +1,23 @@ fn main() { + println!("cargo:rerun-if-env-changed=APP_VERSION"); + let version = std::env::var("APP_VERSION") + .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()); + println!("cargo:rustc-env=APP_VERSION={version}"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { let mut res = winres::WindowsResource::new(); res.set_icon("favicon.ico"); + res.set("FileDescription", "MulderConfig"); + res.set("FileVersion", &format!("{version}.0")); + res.set("ProductName", "MulderConfig"); + res.set("ProductVersion", &version); + + // Pack version into 4×u16 quad for VS_FIXEDFILEINFO binary fields + let quad = version.splitn(4, '.').map(|p| p.parse::().unwrap_or(0)) + .zip([48u64, 32, 16, 0]).fold(0, |acc, (v, shift)| acc | (v << shift)); + res.set_version_info(winres::VersionInfo::FILEVERSION, quad); + res.set_version_info(winres::VersionInfo::PRODUCTVERSION, quad); + res.compile().expect("Failed to compile Windows resources"); } } From f0441ea388a3c7dd9b84f98d04b82f4a5cbf86c7 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Tue, 5 May 2026 15:40:06 +0200 Subject: [PATCH 67/77] cargo fmt --- build.rs | 11 +- src/addon_detector.rs | 9 +- src/apply.rs | 17 ++- src/apply/exe_replacer.rs | 64 ++++++---- src/apply/file_ops.rs | 147 +++++++++++++++-------- src/apply/launcher.rs | 31 +++-- src/config/loader.rs | 17 ++- src/config/validator.rs | 118 ++++++++++++++---- src/context.rs | 15 ++- src/main.rs | 27 +++-- src/mode_detector.rs | 65 ++++++++-- src/save/loader.rs | 8 +- src/save/saver.rs | 3 +- src/save/validator.rs | 83 ++++++++++--- src/ui.rs | 167 ++++++++++++++++---------- src/ui/chrome.rs | 243 ++++++++++++++++++++++++-------------- src/ui/controls.rs | 49 +++++--- src/ui/error.rs | 27 ++++- 18 files changed, 764 insertions(+), 337 deletions(-) diff --git a/build.rs b/build.rs index 67a92c5..caacf86 100644 --- a/build.rs +++ b/build.rs @@ -1,7 +1,7 @@ fn main() { println!("cargo:rerun-if-env-changed=APP_VERSION"); - let version = std::env::var("APP_VERSION") - .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()); + let version = + std::env::var("APP_VERSION").unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()); println!("cargo:rustc-env=APP_VERSION={version}"); if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { @@ -13,8 +13,11 @@ fn main() { res.set("ProductVersion", &version); // Pack version into 4×u16 quad for VS_FIXEDFILEINFO binary fields - let quad = version.splitn(4, '.').map(|p| p.parse::().unwrap_or(0)) - .zip([48u64, 32, 16, 0]).fold(0, |acc, (v, shift)| acc | (v << shift)); + let quad = version + .splitn(4, '.') + .map(|p| p.parse::().unwrap_or(0)) + .zip([48u64, 32, 16, 0]) + .fold(0, |acc, (v, shift)| acc | (v << shift)); res.set_version_info(winres::VersionInfo::FILEVERSION, quad); res.set_version_info(winres::VersionInfo::PRODUCTVERSION, quad); diff --git a/src/addon_detector.rs b/src/addon_detector.rs index 90a45b0..e0d615c 100644 --- a/src/addon_detector.rs +++ b/src/addon_detector.rs @@ -15,7 +15,9 @@ pub fn resolve_addon(addons: &[Addon], steam_id: u32) -> Result .iter() .find(|a| a.steam_id == steam_id) .map(|a| a.title.clone()) - .ok_or_else(|| format!("Unknown addon Steam ID {steam_id}, launching with base game configuration.")) + .ok_or_else(|| { + format!("Unknown addon Steam ID {steam_id}, launching with base game configuration.") + }) } #[cfg(test)] @@ -24,7 +26,10 @@ mod tests { use crate::config::model::Addon; fn addon(steam_id: u32, title: &str) -> Addon { - Addon { steam_id, title: title.into() } + Addon { + steam_id, + title: title.into(), + } } #[test] diff --git a/src/apply.rs b/src/apply.rs index 15c36bd..698abcc 100644 --- a/src/apply.rs +++ b/src/apply.rs @@ -2,14 +2,17 @@ pub mod exe_replacer; pub mod file_ops; pub mod launcher; -use crate::context::AppContext; use crate::config::when_resolver::{SelectionValue, Selections}; +use crate::context::AppContext; use crate::save::SaveValue; /// Converts the save-format selections (+ injects "Title") into WhenResolver selections. fn to_selections(ctx: &AppContext) -> Selections { let mut sel = Selections::new(); - sel.insert("Title".to_string(), SelectionValue::Single(ctx.active_title.clone())); + sel.insert( + "Title".to_string(), + SelectionValue::Single(ctx.active_title.clone()), + ); for (k, v) in &ctx.selections { let sv = match v { SaveValue::Single(s) => SelectionValue::Single(s.clone()), @@ -26,10 +29,12 @@ fn to_selections(ctx: &AppContext) -> Selections { pub fn apply(ctx: &AppContext) -> Vec { let selected = to_selections(ctx); let mut errors = file_ops::execute_operations(&ctx.config.actions.operations, &selected); - if !ctx.config.actions.launch.is_empty() && !exe_replacer::is_replaced(&ctx.config) - && let Err(e) = exe_replacer::replace(&ctx.config) { - errors.push(e); - } + if !ctx.config.actions.launch.is_empty() + && !exe_replacer::is_replaced(&ctx.config) + && let Err(e) = exe_replacer::replace(&ctx.config) + { + errors.push(e); + } errors } diff --git a/src/apply/exe_replacer.rs b/src/apply/exe_replacer.rs index 86e72e9..0a26251 100644 --- a/src/apply/exe_replacer.rs +++ b/src/apply/exe_replacer.rs @@ -1,12 +1,14 @@ +use crate::config::model::ConfigModel; use std::os::windows::io::AsRawHandle; -use windows_sys::Win32::Storage::FileSystem::{GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION}; use std::path::{Path, PathBuf}; -use crate::config::model::ConfigModel; +use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, +}; pub struct ExePaths { - pub original: PathBuf, // Game.exe (becomes a hard link after apply) - pub original_backup: PathBuf, // Game_o.exe (original game exe, preserved) - pub mulder_config: PathBuf, // This exe (MulderConfig.exe or renamed) + pub original: PathBuf, // Game.exe (becomes a hard link after apply) + pub original_backup: PathBuf, // Game_o.exe (original game exe, preserved) + pub mulder_config: PathBuf, // This exe (MulderConfig.exe or renamed) } /// Pure path computation — separated from env access for testability. @@ -26,7 +28,11 @@ fn build_paths(base: &Path, original_exe: &str) -> (PathBuf, PathBuf) { pub fn get_paths(config: &ConfigModel) -> ExePaths { let base = exe_dir(); let (original, backup) = build_paths(&base, &config.game.original_exe); - ExePaths { original, original_backup: backup, mulder_config: std::env::current_exe().unwrap() } + ExePaths { + original, + original_backup: backup, + mulder_config: std::env::current_exe().unwrap(), + } } /// Returns true when Game.exe is a hard link to this exe. @@ -58,20 +64,30 @@ fn same_file(a: &Path, b: &Path) -> std::io::Result { pub fn replace(config: &ConfigModel) -> Result<(), String> { let paths = get_paths(config); if !paths.original.exists() { - return Err(format!("Original exe not found: {}", paths.original.display())); + return Err(format!( + "Original exe not found: {}", + paths.original.display() + )); } if paths.original_backup.exists() { - std::fs::remove_file(&paths.original_backup) - .map_err(|e| format!("Failed to remove stale backup '{}': {e}", paths.original_backup.display()))?; - } - std::fs::rename(&paths.original, &paths.original_backup) - .map_err(|e| format!("Failed to rename '{}' to '{}': {e}", - paths.original.display(), paths.original_backup.display()))?; - std::fs::hard_link(&paths.mulder_config, &paths.original) - .map_err(|e| { - let _ = std::fs::rename(&paths.original_backup, &paths.original); - format!("Failed to create hard link: {e}") + std::fs::remove_file(&paths.original_backup).map_err(|e| { + format!( + "Failed to remove stale backup '{}': {e}", + paths.original_backup.display() + ) })?; + } + std::fs::rename(&paths.original, &paths.original_backup).map_err(|e| { + format!( + "Failed to rename '{}' to '{}': {e}", + paths.original.display(), + paths.original_backup.display() + ) + })?; + std::fs::hard_link(&paths.mulder_config, &paths.original).map_err(|e| { + let _ = std::fs::rename(&paths.original_backup, &paths.original); + format!("Failed to create hard link: {e}") + })?; Ok(()) } @@ -79,7 +95,11 @@ pub fn replace(config: &ConfigModel) -> Result<(), String> { /// After replacement: Game_o.exe (original game); otherwise: Game.exe. pub fn get_default_launch_exe(config: &ConfigModel) -> PathBuf { let paths = get_paths(config); - if paths.original_backup.exists() { paths.original_backup } else { paths.original } + if paths.original_backup.exists() { + paths.original_backup + } else { + paths.original + } } fn exe_dir() -> PathBuf { @@ -99,7 +119,7 @@ mod tests { fn build_paths_simple() { let (original, backup) = build_paths(Path::new("C:/game"), "Game.exe"); assert_eq!(original, Path::new("C:/game/Game.exe")); - assert_eq!(backup, Path::new("C:/game/Game_o.exe")); + assert_eq!(backup, Path::new("C:/game/Game_o.exe")); } #[test] @@ -127,7 +147,7 @@ mod tests { fn replace_renames_original_and_creates_hard_link() { let dir = setup_dir("mulder_replace_basic"); let original = dir.join("Game.exe"); - let backup = dir.join("Game_o.exe"); + let backup = dir.join("Game_o.exe"); let mulder_config = std::env::current_exe().unwrap(); std::fs::write(&original, b"fake game").unwrap(); @@ -147,11 +167,11 @@ mod tests { fn replace_overwrites_stale_backup() { let dir = setup_dir("mulder_replace_stale"); let original = dir.join("Game.exe"); - let backup = dir.join("Game_o.exe"); + let backup = dir.join("Game_o.exe"); let mulder_config = std::env::current_exe().unwrap(); std::fs::write(&original, b"new game v2").unwrap(); - std::fs::write(&backup, b"old game v1").unwrap(); + std::fs::write(&backup, b"old game v1").unwrap(); // Simulate the stale-backup removal + rename sequence from replace() std::fs::remove_file(&backup).unwrap(); diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs index 7e4cd54..2575d93 100644 --- a/src/apply/file_ops.rs +++ b/src/apply/file_ops.rs @@ -1,25 +1,26 @@ use std::path::{Path, PathBuf}; -use regex_lite::Regex; use crate::config::model::OperationAction; -use crate::config::when_resolver::{match_when, Selections}; +use crate::config::when_resolver::{Selections, match_when}; +use regex_lite::Regex; pub fn execute_operations(operations: &[OperationAction], selected: &Selections) -> Vec { let mut errors = Vec::new(); for action in operations { if let Some(when) = &action.when - && !match_when(when, selected) { - continue; - } + && !match_when(when, selected) + { + continue; + } let result = match action.operation.to_lowercase().as_str() { - "setreadonly" => exec_set_readonly(action, true), + "setreadonly" => exec_set_readonly(action, true), "removereadonly" => exec_set_readonly(action, false), "rename" | "move" => exec_move(action), - "copy" => exec_copy(action), - "delete" => exec_delete(action), - "replaceline" => exec_replace_line(action), - "removeline" => exec_remove_line(action), - "replacetext" => exec_replace_text(action), + "copy" => exec_copy(action), + "delete" => exec_delete(action), + "replaceline" => exec_replace_line(action), + "removeline" => exec_remove_line(action), + "replacetext" => exec_replace_text(action), other => Err(format!("Unknown operation: {other}")), }; if let Err(e) = result { @@ -76,10 +77,14 @@ pub fn expand_env_vars(s: &str) -> String { } fn resolve_files(files: &Option>) -> Vec { - let Some(list) = files else { return Vec::new(); }; + let Some(list) = files else { + return Vec::new(); + }; let mut out = Vec::new(); for f in list { - if f.is_empty() { continue; } + if f.is_empty() { + continue; + } let p = resolve_path(f); if p.exists() { out.push(p); @@ -93,13 +98,19 @@ fn resolve_files(files: &Option>) -> Vec { // ── Operations ──────────────────────────────────────────────────────────────── fn exec_set_readonly(action: &OperationAction, read_only: bool) -> Result<(), String> { - let files = action.files.as_ref() + let files = action + .files + .as_ref() .filter(|v| !v.is_empty()) .ok_or("Missing 'files' for setReadOnly/removeReadOnly.")?; for f in files { - if f.is_empty() { continue; } + if f.is_empty() { + continue; + } let p = resolve_path(f); - if !p.exists() { continue; } // idempotent + if !p.exists() { + continue; + } // idempotent let meta = std::fs::metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?; let mut perms = meta.permissions(); perms.set_readonly(read_only); @@ -109,27 +120,37 @@ fn exec_set_readonly(action: &OperationAction, read_only: bool) -> Result<(), St } fn exec_move(action: &OperationAction) -> Result<(), String> { - let src = action.source.as_deref() + let src = action + .source + .as_deref() .filter(|s| !s.is_empty()) .ok_or("Missing 'source' for rename/move.")?; - let dst = action.target.as_deref() + let dst = action + .target + .as_deref() .filter(|s| !s.is_empty()) .ok_or("Missing 'target' for rename/move.")?; let src = resolve_path(src); let dst = resolve_path(dst); - if !src.exists() { return Ok(()); } // idempotent + if !src.exists() { + return Ok(()); + } // idempotent if !same_drive(&src, &dst) { return Err(format!( "Cannot move '{}' to '{}': source and destination are on different drives.", - src.display(), dst.display() + src.display(), + dst.display() )); } if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?; } // Remove existing destination - if dst.is_file() { std::fs::remove_file(&dst).map_err(|e| format!("{e}"))?; } - else if dst.is_dir() { std::fs::remove_dir_all(&dst).map_err(|e| format!("{e}"))?; } + if dst.is_file() { + std::fs::remove_file(&dst).map_err(|e| format!("{e}"))?; + } else if dst.is_dir() { + std::fs::remove_dir_all(&dst).map_err(|e| format!("{e}"))?; + } std::fs::rename(&src, &dst).map_err(|e| format!("{e}")) } @@ -138,15 +159,21 @@ fn same_drive(a: &Path, b: &Path) -> bool { } fn exec_copy(action: &OperationAction) -> Result<(), String> { - let src = action.source.as_deref() + let src = action + .source + .as_deref() .filter(|s| !s.is_empty()) .ok_or("Missing 'source' for copy.")?; - let dst = action.target.as_deref() + let dst = action + .target + .as_deref() .filter(|s| !s.is_empty()) .ok_or("Missing 'target' for copy.")?; let src = resolve_path(src); let dst = resolve_path(dst); - if !src.exists() { return Ok(()); } // idempotent + if !src.exists() { + return Ok(()); + } // idempotent if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?; } @@ -156,7 +183,9 @@ fn exec_copy(action: &OperationAction) -> Result<(), String> { } fn exec_delete(action: &OperationAction) -> Result<(), String> { - let src = action.source.as_deref() + let src = action + .source + .as_deref() .filter(|s| !s.is_empty()) .ok_or("Missing 'source' for delete.")?; let p = resolve_path(src); @@ -167,10 +196,14 @@ fn exec_delete(action: &OperationAction) -> Result<(), String> { } fn exec_replace_line(action: &OperationAction) -> Result<(), String> { - let pattern = action.pattern.as_deref() + let pattern = action + .pattern + .as_deref() .filter(|s| !s.is_empty()) .ok_or("Missing 'pattern' for replaceLine.")?; - let replacement = action.replacement.as_deref() + let replacement = action + .replacement + .as_deref() .ok_or("Missing 'replacement' for replaceLine.")?; let re = Regex::new(&format!("(?i){pattern}")) .map_err(|e| format!("Invalid regex pattern '{pattern}': {e}"))?; @@ -187,30 +220,34 @@ fn exec_replace_line(action: &OperationAction) -> Result<(), String> { } fn exec_remove_line(action: &OperationAction) -> Result<(), String> { - let pattern = action.pattern.as_deref() + let pattern = action + .pattern + .as_deref() .filter(|s| !s.is_empty()) .ok_or("Missing 'pattern' for removeLine.")?; let re = Regex::new(&format!("(?i){pattern}")) .map_err(|e| format!("Invalid regex pattern '{pattern}': {e}"))?; for path in resolve_files(&action.files) { - filter_lines_in_file(&path, &re, &[]) - .map_err(|e| format!("{}: {e}", path.display()))?; + filter_lines_in_file(&path, &re, &[]).map_err(|e| format!("{}: {e}", path.display()))?; } Ok(()) } fn exec_replace_text(action: &OperationAction) -> Result<(), String> { - let search = action.search.as_deref() + let search = action + .search + .as_deref() .ok_or("Missing 'search' for replaceText.")?; - let replacement = action.replacement.as_deref() + let replacement = action + .replacement + .as_deref() .ok_or("Missing 'replacement' for replaceText.")?; for path in resolve_files(&action.files) { - let content = std::fs::read_to_string(&path) - .map_err(|e| format!("{}: {e}", path.display()))?; + let content = + std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; let new_content = content.replace(search, replacement); if new_content != content { - std::fs::write(&path, new_content) - .map_err(|e| format!("{}: {e}", path.display()))?; + std::fs::write(&path, new_content).map_err(|e| format!("{}: {e}", path.display()))?; } } Ok(()) @@ -222,7 +259,11 @@ fn filter_lines_in_file(path: &Path, re: &Regex, replacement_lines: &[&str]) -> let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; // Detect the dominant line ending so we can preserve it. - let eol = if content.contains("\r\n") { "\r\n" } else { "\n" }; + let eol = if content.contains("\r\n") { + "\r\n" + } else { + "\n" + }; // split('\n') on a file ending with '\n' always produces a trailing "". // We peel that off, process the real lines, then re-add the trailing EOL @@ -267,7 +308,10 @@ fn unescape(s: &str) -> String { Some('n') => out.push('\n'), Some('t') => out.push('\t'), Some('r') => out.push('\r'), - Some(other) => { out.push('\\'); out.push(other); } + Some(other) => { + out.push('\\'); + out.push(other); + } None => out.push('\\'), } } else { @@ -324,8 +368,7 @@ mod tests { #[test] fn move_directory_moves_contents() { - let root = std::env::temp_dir() - .join(format!("mulderconfig_test_{}", std::process::id())); + let root = std::env::temp_dir().join(format!("mulderconfig_test_{}", std::process::id())); let source_dir = root.join("srcDir"); let target_dir = root.join("dstDir"); std::fs::create_dir_all(&source_dir).unwrap(); @@ -342,7 +385,10 @@ mod tests { assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); assert!(!source_dir.exists(), "Source should not exist after move"); assert!(target_dir.exists(), "Target should exist after move"); - assert!(target_dir.join("a.txt").exists(), "File should be in target"); + assert!( + target_dir.join("a.txt").exists(), + "File should be in target" + ); std::fs::remove_dir_all(&root).ok(); } @@ -351,8 +397,8 @@ mod tests { #[test] fn copy_creates_missing_parent_dir() { - let root = std::env::temp_dir() - .join(format!("mulderconfig_copy_test_{}", std::process::id())); + let root = + std::env::temp_dir().join(format!("mulderconfig_copy_test_{}", std::process::id())); let src_file = root.join("src.txt"); let dst_file = root.join("nested").join("subdir").join("dst.txt"); std::fs::create_dir_all(&root).unwrap(); @@ -377,7 +423,10 @@ mod tests { #[test] fn same_drive_identical_root() { - assert!(same_drive(Path::new("C:\\foo\\a.txt"), Path::new("C:\\bar\\b.txt"))); + assert!(same_drive( + Path::new("C:\\foo\\a.txt"), + Path::new("C:\\bar\\b.txt") + )); } #[test] @@ -486,7 +535,10 @@ mod tests { let re = Regex::new("(?i)no match here").unwrap(); filter_lines_in_file(&path, &re, &[]).unwrap(); let modified_after = std::fs::metadata(&path).unwrap().modified().unwrap(); - assert_eq!(modified_before, modified_after, "file should not be written when no match"); + assert_eq!( + modified_before, modified_after, + "file should not be written when no match" + ); } #[test] @@ -538,8 +590,7 @@ mod tests { assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); let result = std::fs::read(&path).unwrap(); assert_eq!( - result, - b"before\r\nAntiAliasing = 8x\r\nAAMethod = FXAA\r\nafter\r\n", + result, b"before\r\nAntiAliasing = 8x\r\nAAMethod = FXAA\r\nafter\r\n", "Replacement lines must be separated by \\r\\n in a CRLF file" ); } diff --git a/src/apply/launcher.rs b/src/apply/launcher.rs index 85874d1..3019bc6 100644 --- a/src/apply/launcher.rs +++ b/src/apply/launcher.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use crate::config::model::ConfigModel; -use crate::config::when_resolver::{match_when, Selections}; +use crate::config::when_resolver::{Selections, match_when}; use super::exe_replacer; @@ -20,9 +20,10 @@ fn resolve_launch(config: &ConfigModel, selected: &Selections) -> ResolvedLaunch for rule in &config.actions.launch { if let Some(when) = &rule.when - && !match_when(when, selected) { - continue; - } + && !match_when(when, selected) + { + continue; + } // Last match wins for exec if let Some(exec) = &rule.exec { exe = resolve_path(&exec.name); @@ -39,7 +40,12 @@ fn resolve_launch(config: &ConfigModel, selected: &Selections) -> ResolvedLaunch } } - ResolvedLaunch { exe, work_dir, wait, args } + ResolvedLaunch { + exe, + work_dir, + wait, + args, + } } pub fn launch(config: &ConfigModel, selected: &Selections) -> Result<(), String> { @@ -56,7 +62,9 @@ pub fn launch(config: &ConfigModel, selected: &Selections) -> Result<(), String> .map_err(|e| format!("Failed to launch '{}': {e}", resolved.exe.display()))?; if resolved.wait { - child.wait().map_err(|e| format!("Process wait failed: {e}"))?; + child + .wait() + .map_err(|e| format!("Process wait failed: {e}"))?; } Ok(()) @@ -242,7 +250,8 @@ mod tests { fn resolve_path_expands_env_vars_in_exec_name() { // %SystemRoot% is always set on Windows (e.g. C:\Windows). let sys = std::env::var("SystemRoot").unwrap(); - let json = format!(r#" + let json = format!( + r#" {{ "game": {{ "title": "Test", "originalExe": "Game.exe" }}, "optionGroups": [], @@ -255,12 +264,16 @@ mod tests { "operations": [] }} }} - "#); + "# + ); let config = parse_config(&json); let resolved = resolve_launch(&config, &sel(&[])); let expected = PathBuf::from(format!("{}\\System32\\notepad.exe", sys)); - assert_eq!(resolved.exe, expected, "%%SystemRoot%% in exec.name should be expanded"); + assert_eq!( + resolved.exe, expected, + "%%SystemRoot%% in exec.name should be expanded" + ); } } diff --git a/src/config/loader.rs b/src/config/loader.rs index d5c2d8a..6dcac73 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -25,7 +25,7 @@ impl ConfigLoader { #[cfg(test)] mod tests { use super::*; - use crate::config::when_resolver::{match_when, SelectionValue, Selections}; + use crate::config::when_resolver::{SelectionValue, Selections, match_when}; fn parse(json: &str) -> ConfigModel { serde_json::from_str(json).expect("JSON parse failed") @@ -84,8 +84,14 @@ mod tests { assert_eq!(2, config.actions.operations.len()); assert_eq!("rename", config.actions.operations[0].operation); - assert_eq!(Some("a.dll".to_string()), config.actions.operations[0].source); - assert_eq!(Some("b.dll".to_string()), config.actions.operations[0].target); + assert_eq!( + Some("a.dll".to_string()), + config.actions.operations[0].source + ); + assert_eq!( + Some("b.dll".to_string()), + config.actions.operations[0].target + ); assert_eq!("replaceLine", config.actions.operations[1].operation); assert_eq!( Some(vec!["FalloutPrefs.ini".to_string()]), @@ -168,7 +174,10 @@ mod tests { assert!(config.actions.operations[0].when.is_none()); assert!(match_when( - config.actions.operations[0].when.as_deref().unwrap_or(empty), + config.actions.operations[0] + .when + .as_deref() + .unwrap_or(empty), &selected )); } diff --git a/src/config/validator.rs b/src/config/validator.rs index 6129ead..f0bb3a4 100644 --- a/src/config/validator.rs +++ b/src/config/validator.rs @@ -99,14 +99,29 @@ impl ConfigValidator { } match operation.as_str() { "rename" | "move" | "copy" => { - let src_ok = op.source.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false); - let dst_ok = op.target.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false); + let src_ok = op + .source + .as_ref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let dst_ok = op + .target + .as_ref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); if !src_ok || !dst_ok { - return Err(format!("Operation '{operation}' requires source and target")); + return Err(format!( + "Operation '{operation}' requires source and target" + )); } } "delete" => { - if op.source.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) { + if op + .source + .as_ref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true) + { return Err("Operation 'delete' requires source".into()); } } @@ -119,7 +134,12 @@ impl ConfigValidator { if op.files.as_ref().map(|f| f.is_empty()).unwrap_or(true) { return Err(format!("Operation '{operation}' requires files")); } - if op.pattern.as_ref().map(|p| p.trim().is_empty()).unwrap_or(true) { + if op + .pattern + .as_ref() + .map(|p| p.trim().is_empty()) + .unwrap_or(true) + { return Err(format!("Operation '{operation}' requires pattern")); } if operation == "replaceline" && op.replacement.is_none() { @@ -130,7 +150,12 @@ impl ConfigValidator { if op.files.as_ref().map(|f| f.is_empty()).unwrap_or(true) { return Err("Operation 'replaceText' requires files".into()); } - if op.search.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) { + if op + .search + .as_ref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true) + { return Err("Operation 'replaceText' requires search".into()); } if op.replacement.is_none() { @@ -152,18 +177,31 @@ mod tests { fn minimal_valid() -> ConfigModel { ConfigModel { - game: Game { title: "Test".into(), original_exe: "Game.exe".into() }, - addons: Some(vec![Addon { title: "default".into(), steam_id: 1 }]), + game: Game { + title: "Test".into(), + original_exe: "Game.exe".into(), + }, + addons: Some(vec![Addon { + title: "default".into(), + steam_id: 1, + }]), option_groups: vec![OptionGroup { name: "Renderer".into(), kind: OptionGroupType::RadioGroup, - radios: Some(vec![Radio { value: "DX9".into(), disabled_when: None }]), + radios: Some(vec![Radio { + value: "DX9".into(), + disabled_when: None, + }]), checkboxes: None, }], actions: ActionRoot { launch: vec![LaunchAction { when: None, - exec: Some(ExecSpec { name: "Game.exe".into(), work_dir: ".\\".into(), wait: None }), + exec: Some(ExecSpec { + name: "Game.exe".into(), + work_dir: ".\\".into(), + wait: None, + }), args: Some(vec!["-a".into()]), }], operations: vec![OperationAction { @@ -246,8 +284,14 @@ mod tests { fn err_when_radio_has_empty_value() { let mut cfg = minimal_valid(); cfg.option_groups[0].radios = Some(vec![ - Radio { value: "DX9".into(), disabled_when: None }, - Radio { value: "".into(), disabled_when: None }, + Radio { + value: "DX9".into(), + disabled_when: None, + }, + Radio { + value: "".into(), + disabled_when: None, + }, ]); assert!(ConfigValidator::validate(&cfg).is_err()); } @@ -256,8 +300,14 @@ mod tests { fn err_when_radio_has_duplicate_value() { let mut cfg = minimal_valid(); cfg.option_groups[0].radios = Some(vec![ - Radio { value: "DX9".into(), disabled_when: None }, - Radio { value: "DX9".into(), disabled_when: None }, + Radio { + value: "DX9".into(), + disabled_when: None, + }, + Radio { + value: "DX9".into(), + disabled_when: None, + }, ]); assert!(ConfigValidator::validate(&cfg).is_err()); } @@ -266,8 +316,14 @@ mod tests { fn err_when_radio_has_duplicate_value_case_insensitive() { let mut cfg = minimal_valid(); cfg.option_groups[0].radios = Some(vec![ - Radio { value: "dx9".into(), disabled_when: None }, - Radio { value: "DX9".into(), disabled_when: None }, + Radio { + value: "dx9".into(), + disabled_when: None, + }, + Radio { + value: "DX9".into(), + disabled_when: None, + }, ]); assert!(ConfigValidator::validate(&cfg).is_err()); } @@ -278,8 +334,14 @@ mod tests { cfg.option_groups[0].kind = OptionGroupType::CheckboxGroup; cfg.option_groups[0].radios = None; cfg.option_groups[0].checkboxes = Some(vec![ - Checkbox { value: "MSAA".into(), disabled_when: None }, - Checkbox { value: "".into(), disabled_when: None }, + Checkbox { + value: "MSAA".into(), + disabled_when: None, + }, + Checkbox { + value: "".into(), + disabled_when: None, + }, ]); assert!(ConfigValidator::validate(&cfg).is_err()); } @@ -290,8 +352,14 @@ mod tests { cfg.option_groups[0].kind = OptionGroupType::CheckboxGroup; cfg.option_groups[0].radios = None; cfg.option_groups[0].checkboxes = Some(vec![ - Checkbox { value: "MSAA".into(), disabled_when: None }, - Checkbox { value: "MSAA".into(), disabled_when: None }, + Checkbox { + value: "MSAA".into(), + disabled_when: None, + }, + Checkbox { + value: "MSAA".into(), + disabled_when: None, + }, ]); assert!(ConfigValidator::validate(&cfg).is_err()); } @@ -302,8 +370,14 @@ mod tests { cfg.option_groups[0].kind = OptionGroupType::CheckboxGroup; cfg.option_groups[0].radios = None; cfg.option_groups[0].checkboxes = Some(vec![ - Checkbox { value: "msaa".into(), disabled_when: None }, - Checkbox { value: "MSAA".into(), disabled_when: None }, + Checkbox { + value: "msaa".into(), + disabled_when: None, + }, + Checkbox { + value: "MSAA".into(), + disabled_when: None, + }, ]); assert!(ConfigValidator::validate(&cfg).is_err()); } diff --git a/src/context.rs b/src/context.rs index 4f719da..9fbce8d 100644 --- a/src/context.rs +++ b/src/context.rs @@ -10,9 +10,20 @@ pub struct AppContext { } impl AppContext { - pub fn new(config: ConfigModel, save_data: SaveData, save_path: String, active_title: String) -> Self { + pub fn new( + config: ConfigModel, + save_data: SaveData, + save_path: String, + active_title: String, + ) -> Self { let selections = save_data.get(&active_title).cloned().unwrap_or_default(); - AppContext { config, save_data, save_path, active_title, selections } + AppContext { + config, + save_data, + save_path, + active_title, + selections, + } } /// Switch the active addon/game and resolve the matching saved selections. diff --git a/src/main.rs b/src/main.rs index 86e3464..4acf740 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,15 +11,14 @@ mod ui; use addon_detector::{parse_addon_arg, resolve_addon}; use config::loader::ConfigLoader; use context::AppContext; -use ui::error::{ask_delete_save, fatal}; -use mode_detector::{detect_mode, Mode}; +use mode_detector::{Mode, detect_mode}; use save::loader::SaveLoader; use save::validator::SaveValidator; +use ui::error::{ask_delete_save, fatal}; const SAVE_PATH: &str = "MulderConfig.save.json"; fn main() { - let config = ConfigLoader::load("MulderConfig.json") .unwrap_or_else(|e| fatal(&format!("Failed to load config:\n{e}"))); @@ -48,15 +47,13 @@ fn main() { let args: Vec = std::env::args().collect(); let active_title = match parse_addon_arg(&args) { None => config.game.title.clone(), - Some(steam_id) => { - match resolve_addon(config.addons.as_deref().unwrap_or(&[]), steam_id) { - Ok(title) => title, - Err(msg) => { - ui::error::warn(&msg); - config.game.title.clone() - } + Some(steam_id) => match resolve_addon(config.addons.as_deref().unwrap_or(&[]), steam_id) { + Ok(title) => title, + Err(msg) => { + ui::error::warn(&msg); + config.game.title.clone() } - } + }, }; let ctx = AppContext::new(config, save_data, SAVE_PATH.to_string(), active_title); @@ -64,7 +61,9 @@ fn main() { let mode = detect_mode(&ctx.config.game.original_exe); match mode { - Mode::Config => { ui::run(ctx); } + Mode::Config => { + ui::run(ctx); + } Mode::Apply => { let errors = apply::apply(&ctx); if !errors.is_empty() { @@ -74,6 +73,8 @@ fn main() { std::process::exit(1); } } - Mode::Launch => { apply::launch(&ctx); } + Mode::Launch => { + apply::launch(&ctx); + } } } diff --git a/src/mode_detector.rs b/src/mode_detector.rs index b5fa0c8..83c01bb 100644 --- a/src/mode_detector.rs +++ b/src/mode_detector.rs @@ -60,59 +60,98 @@ mod tests { #[test] fn launch_when_exe_matches_original_exe() { - assert_eq!(resolve_mode("Game.exe", "Game.exe", &args(&[])), Mode::Launch); + assert_eq!( + resolve_mode("Game.exe", "Game.exe", &args(&[])), + Mode::Launch + ); } #[test] fn launch_is_case_insensitive() { - assert_eq!(resolve_mode("game.exe", "GAME.EXE", &args(&[])), Mode::Launch); - assert_eq!(resolve_mode("GAME.EXE", "game.exe", &args(&[])), Mode::Launch); + assert_eq!( + resolve_mode("game.exe", "GAME.EXE", &args(&[])), + Mode::Launch + ); + assert_eq!( + resolve_mode("GAME.EXE", "game.exe", &args(&[])), + Mode::Launch + ); } #[test] fn launch_works_with_path_in_original_exe() { // original_exe may include a subdirectory: only the filename is compared - assert_eq!(resolve_mode("rebirth\\Biohazard.exe", "Biohazard.exe", &args(&[])), Mode::Launch); + assert_eq!( + resolve_mode("rebirth\\Biohazard.exe", "Biohazard.exe", &args(&[])), + Mode::Launch + ); } #[test] fn mulderconfig_flag_on_original_exe_forces_config() { - assert_eq!(resolve_mode("Game.exe", "Game.exe", &args(&["-MulderConfig"])), Mode::Config); + assert_eq!( + resolve_mode("Game.exe", "Game.exe", &args(&["-MulderConfig"])), + Mode::Config + ); } #[test] fn mulderconfig_flag_is_case_insensitive() { - assert_eq!(resolve_mode("Game.exe", "Game.exe", &args(&["-mulderconfig"])), Mode::Config); - assert_eq!(resolve_mode("Game.exe", "Game.exe", &args(&["-MULDERCONFIG"])), Mode::Config); + assert_eq!( + resolve_mode("Game.exe", "Game.exe", &args(&["-mulderconfig"])), + Mode::Config + ); + assert_eq!( + resolve_mode("Game.exe", "Game.exe", &args(&["-MULDERCONFIG"])), + Mode::Config + ); } #[test] fn apply_flag_ignored_on_original_exe() { // -apply only makes sense on MulderConfig.exe, not on the game exe - assert_eq!(resolve_mode("Game.exe", "Game.exe", &args(&["-apply"])), Mode::Launch); + assert_eq!( + resolve_mode("Game.exe", "Game.exe", &args(&["-apply"])), + Mode::Launch + ); } // --- Running as MulderConfig.exe (or renamed) --- #[test] fn config_when_exe_does_not_match_original() { - assert_eq!(resolve_mode("Game.exe", "MulderConfig.exe", &args(&[])), Mode::Config); + assert_eq!( + resolve_mode("Game.exe", "MulderConfig.exe", &args(&[])), + Mode::Config + ); } #[test] fn apply_flag_on_mulderconfig_exe_gives_apply() { - assert_eq!(resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-apply"])), Mode::Apply); + assert_eq!( + resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-apply"])), + Mode::Apply + ); } #[test] fn apply_flag_is_case_insensitive() { - assert_eq!(resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-Apply"])), Mode::Apply); - assert_eq!(resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-APPLY"])), Mode::Apply); + assert_eq!( + resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-Apply"])), + Mode::Apply + ); + assert_eq!( + resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-APPLY"])), + Mode::Apply + ); } #[test] fn mulderconfig_flag_ignored_on_mulderconfig_exe() { // -MulderConfig only makes sense on the game exe, so ignored here (config anyway) - assert_eq!(resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-MulderConfig"])), Mode::Config); + assert_eq!( + resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-MulderConfig"])), + Mode::Config + ); } } diff --git a/src/save/loader.rs b/src/save/loader.rs index 4158ad8..9dd4615 100644 --- a/src/save/loader.rs +++ b/src/save/loader.rs @@ -6,11 +6,11 @@ pub struct SaveLoader; impl SaveLoader { pub fn load(path: &str) -> Result { - let content = fs::read_to_string(path) - .map_err(|e| format!("Failed to read save file: {e}"))?; + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read save file: {e}"))?; - let save: SaveData = serde_json::from_str(&content) - .map_err(|e| format!("Invalid save JSON: {e}"))?; + let save: SaveData = + serde_json::from_str(&content).map_err(|e| format!("Invalid save JSON: {e}"))?; Ok(save) } diff --git a/src/save/saver.rs b/src/save/saver.rs index dbb960e..f7f93d2 100644 --- a/src/save/saver.rs +++ b/src/save/saver.rs @@ -9,8 +9,7 @@ impl SaveSaver { let content = serde_json::to_string_pretty(data) .map_err(|e| format!("Failed to serialize save: {e}"))?; - fs::write(path, &content) - .map_err(|e| format!("Failed to write save file: {e}"))?; + fs::write(path, &content).map_err(|e| format!("Failed to write save file: {e}"))?; Ok(()) } diff --git a/src/save/validator.rs b/src/save/validator.rs index 1abb160..b4b0a8c 100644 --- a/src/save/validator.rs +++ b/src/save/validator.rs @@ -71,7 +71,10 @@ mod tests { fn make_config(groups: Vec) -> ConfigModel { ConfigModel { - game: Game { title: "Test".into(), original_exe: "Game.exe".into() }, + game: Game { + title: "Test".into(), + original_exe: "Game.exe".into(), + }, addons: None, option_groups: groups, actions: ActionRoot { @@ -97,8 +100,14 @@ mod tests { name: "Renderer".into(), kind: OptionGroupType::RadioGroup, radios: Some(vec![ - Radio { value: "DX9".into(), disabled_when: None }, - Radio { value: "DX11".into(), disabled_when: None }, + Radio { + value: "DX9".into(), + disabled_when: None, + }, + Radio { + value: "DX11".into(), + disabled_when: None, + }, ]), checkboxes: None, }]); @@ -114,7 +123,10 @@ mod tests { let config = make_config(vec![OptionGroup { name: "Renderer".into(), kind: OptionGroupType::RadioGroup, - radios: Some(vec![Radio { value: "DX9".into(), disabled_when: None }]), + radios: Some(vec![Radio { + value: "DX9".into(), + disabled_when: None, + }]), checkboxes: None, }]); let save = make_save(&[("OldGroup", SaveValue::Single("Whatever".into()))]); @@ -126,7 +138,10 @@ mod tests { let config = make_config(vec![OptionGroup { name: "Renderer".into(), kind: OptionGroupType::RadioGroup, - radios: Some(vec![Radio { value: "DX9".into(), disabled_when: None }]), + radios: Some(vec![Radio { + value: "DX9".into(), + disabled_when: None, + }]), checkboxes: None, }]); let save = make_save(&[("Renderer", SaveValue::Single("DX12".into()))]); @@ -140,9 +155,18 @@ mod tests { kind: OptionGroupType::CheckboxGroup, radios: None, checkboxes: Some(vec![ - Checkbox { value: "A".into(), disabled_when: None }, - Checkbox { value: "B".into(), disabled_when: None }, - Checkbox { value: "C".into(), disabled_when: None }, + Checkbox { + value: "A".into(), + disabled_when: None, + }, + Checkbox { + value: "B".into(), + disabled_when: None, + }, + Checkbox { + value: "C".into(), + disabled_when: None, + }, ]), }]); let save = make_save(&[("Mods", SaveValue::Multiple(vec!["A".into(), "C".into()]))]); @@ -155,7 +179,10 @@ mod tests { name: "Mods".into(), kind: OptionGroupType::CheckboxGroup, radios: None, - checkboxes: Some(vec![Checkbox { value: "A".into(), disabled_when: None }]), + checkboxes: Some(vec![Checkbox { + value: "A".into(), + disabled_when: None, + }]), }]); let save = make_save(&[("Mods", SaveValue::Multiple(vec!["A".into(), "B".into()]))]); assert!(SaveValidator::validate(&save, &config).is_err()); @@ -167,8 +194,14 @@ mod tests { name: "Renderer".into(), kind: OptionGroupType::RadioGroup, radios: Some(vec![ - Radio { value: "DX9".into(), disabled_when: None }, - Radio { value: "DX11".into(), disabled_when: None }, + Radio { + value: "DX9".into(), + disabled_when: None, + }, + Radio { + value: "DX11".into(), + disabled_when: None, + }, ]), checkboxes: None, }]); @@ -183,11 +216,20 @@ mod tests { kind: OptionGroupType::CheckboxGroup, radios: None, checkboxes: Some(vec![ - Checkbox { value: "ModA".into(), disabled_when: None }, - Checkbox { value: "ModB".into(), disabled_when: None }, + Checkbox { + value: "ModA".into(), + disabled_when: None, + }, + Checkbox { + value: "ModB".into(), + disabled_when: None, + }, ]), }]); - let save = make_save(&[("Mods", SaveValue::Multiple(vec!["moda".into(), "MODB".into()]))]); + let save = make_save(&[( + "Mods", + SaveValue::Multiple(vec!["moda".into(), "MODB".into()]), + )]); assert!(SaveValidator::validate(&save, &config).is_ok()); } @@ -197,20 +239,25 @@ mod tests { OptionGroup { name: "Renderer".into(), kind: OptionGroupType::RadioGroup, - radios: Some(vec![Radio { value: "DX9".into(), disabled_when: None }]), + radios: Some(vec![Radio { + value: "DX9".into(), + disabled_when: None, + }]), checkboxes: None, }, OptionGroup { name: "Mods".into(), kind: OptionGroupType::CheckboxGroup, radios: None, - checkboxes: Some(vec![Checkbox { value: "A".into(), disabled_when: None }]), + checkboxes: Some(vec![Checkbox { + value: "A".into(), + disabled_when: None, + }]), }, ]); // Multiple for radioGroup → error - let save1 = - make_save(&[("Renderer", SaveValue::Multiple(vec!["DX9".into()]))]); + let save1 = make_save(&[("Renderer", SaveValue::Multiple(vec!["DX9".into()]))]); assert!(SaveValidator::validate(&save1, &config).is_err()); // Single for checkboxGroup → error diff --git a/src/ui.rs b/src/ui.rs index b420ead..180e528 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,23 +1,24 @@ -extern crate native_windows_gui as nwg; extern crate native_windows_derive as nwd; +extern crate native_windows_gui as nwg; -pub mod controls; pub mod chrome; +pub mod controls; pub mod error; -use controls::{RadioItem, CheckItem, Group, load_saved_state, is_config_complete, collect_selections_for_save, apply_constraints}; +use controls::{ + CheckItem, Group, RadioItem, apply_constraints, collect_selections_for_save, + is_config_complete, load_saved_state, +}; use nwd::NwgUi; use nwg::NativeUi; -use std::rc::Rc; use std::cell::RefCell; +use std::rc::Rc; -use windows_sys::Win32::UI::WindowsAndMessaging::{SendMessageW, GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN}; -use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; +use crate::{config::model::OptionGroupType, context::AppContext, save::saver::SaveSaver}; use windows_sys::Win32::UI::Controls::SetWindowTheme; -use crate::{ - context::AppContext, - config::model::OptionGroupType, - save::saver::SaveSaver, +use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN, SendMessageW, }; // Layout constants (pixels) @@ -26,8 +27,8 @@ const MARGIN: i32 = 10; const COMBO_H: i32 = 25; const GROUP_TITLE_H: i32 = 22; const ITEM_H: i32 = 22; -const GROUP_PADDING: i32 = 6; // bottom padding inside frame -const GROUP_GAP: i32 = 5; // gap between groups +const GROUP_PADDING: i32 = 6; // bottom padding inside frame +const GROUP_GAP: i32 = 5; // gap between groups const BTN_H: i32 = 35; const WM_SETFONT: u32 = 0x0030; @@ -76,8 +77,13 @@ pub fn run(ctx: AppContext) { for title in &addon_titles { app.addon_combo.push(title.clone()); } - let initial_idx = ctx.config.addons.as_deref().unwrap_or(&[]) - .iter().position(|a| a.title == ctx.active_title) + let initial_idx = ctx + .config + .addons + .as_deref() + .unwrap_or(&[]) + .iter() + .position(|a| a.title == ctx.active_title) .map(|i| i + 1) .unwrap_or(0); app.addon_combo.set_selection(Some(initial_idx)); @@ -87,9 +93,8 @@ pub fn run(ctx: AppContext) { } // --- Option groups --- - let (groups_vec, frames, _group_titles, y) = build_option_groups( - &ctx.config.option_groups, &app.window, y, &fonts, - ); + let (groups_vec, frames, _group_titles, y) = + build_option_groups(&ctx.config.option_groups, &app.window, y, &fonts); let groups: Rc>> = Rc::new(RefCell::new(groups_vec)); // --- Bind event handlers --- @@ -101,8 +106,14 @@ pub fn run(ctx: AppContext) { // Wrap AppContext in Rc for shared mutation across closures let ctx_rc: Rc> = Rc::new(RefCell::new(ctx)); - let save_hwnd: isize = match app.save_button.handle { nwg::ControlHandle::Hwnd(h) => h as isize, _ => 0 }; - let apply_hwnd: isize = match app.apply_button.handle { nwg::ControlHandle::Hwnd(h) => h as isize, _ => 0 }; + let save_hwnd: isize = match app.save_button.handle { + nwg::ControlHandle::Hwnd(h) => h as isize, + _ => 0, + }; + let apply_hwnd: isize = match app.apply_button.handle { + nwg::ControlHandle::Hwnd(h) => h as isize, + _ => 0, + }; let btn_handles = Rc::new((save_hwnd, apply_hwnd)); // Frame handlers: radio/checkbox clicks (BN_CLICKED goes to direct parent = Frame) @@ -116,7 +127,10 @@ pub fn run(ctx: AppContext) { if evt == nwg::Event::OnButtonClick { apply_constraints(&cx.borrow().active_title, &gc.borrow()); let ok = i32::from(is_config_complete(&gc.borrow())); - unsafe { EnableWindow(bh.0 as _, ok); EnableWindow(bh.1 as _, ok); } + unsafe { + EnableWindow(bh.0 as _, ok); + EnableWindow(bh.1 as _, ok); + } } }) }) @@ -137,41 +151,45 @@ pub fn run(ctx: AppContext) { let cx = Rc::clone(&ctx_rc); let bh = Rc::clone(&btn_handles); let at = Rc::clone(&addon_titles); - let _window_handler = nwg::bind_event_handler(&window_handle, &window_handle, move |evt, _, handle| { - if evt == nwg::Event::OnComboxBoxSelection { - let idx = unsafe { SendMessageW(combo_hwnd, CB_GETCURSEL, 0, 0) } as usize; - if idx < at.len() { - cx.borrow_mut().switch_addon(at[idx].clone()); + let _window_handler = + nwg::bind_event_handler(&window_handle, &window_handle, move |evt, _, handle| { + if evt == nwg::Event::OnComboxBoxSelection { + let idx = unsafe { SendMessageW(combo_hwnd, CB_GETCURSEL, 0, 0) } as usize; + if idx < at.len() { + cx.borrow_mut().switch_addon(at[idx].clone()); + } + load_saved_state(&cx.borrow().selections, &gc.borrow()); + apply_constraints(&cx.borrow().active_title, &gc.borrow()); + let ok = i32::from(is_config_complete(&gc.borrow())); + unsafe { + EnableWindow(bh.0 as _, ok); + EnableWindow(bh.1 as _, ok); + } } - load_saved_state(&cx.borrow().selections, &gc.borrow()); - apply_constraints(&cx.borrow().active_title, &gc.borrow()); - let ok = i32::from(is_config_complete(&gc.borrow())); - unsafe { EnableWindow(bh.0 as _, ok); EnableWindow(bh.1 as _, ok); } - } - if evt == nwg::Event::OnButtonClick && handle == save_handle { - let selections = collect_selections_for_save(&gc.borrow()); - cx.borrow_mut().save_selections(selections); - let ctx = cx.borrow(); - if let Err(e) = SaveSaver::save(&ctx.save_path, &ctx.save_data) { - nwg::simple_message("Save error", &e); - } else { - nwg::simple_message("Saved", "Configuration saved successfully."); + if evt == nwg::Event::OnButtonClick && handle == save_handle { + let selections = collect_selections_for_save(&gc.borrow()); + cx.borrow_mut().save_selections(selections); + let ctx = cx.borrow(); + if let Err(e) = SaveSaver::save(&ctx.save_path, &ctx.save_data) { + nwg::simple_message("Save error", &e); + } else { + nwg::simple_message("Saved", "Configuration saved successfully."); + } } - } - if evt == nwg::Event::OnButtonClick && handle == apply_handle { - // Collect current UI state before applying (no disk write) - let selections = collect_selections_for_save(&gc.borrow()); - cx.borrow_mut().save_selections(selections); - let errors = crate::apply::apply(&cx.borrow()); - if errors.is_empty() { - nwg::simple_message("Applied", "Configuration applied successfully."); - } else { - for e in &errors { - crate::ui::error::warn(e); + if evt == nwg::Event::OnButtonClick && handle == apply_handle { + // Collect current UI state before applying (no disk write) + let selections = collect_selections_for_save(&gc.borrow()); + cx.borrow_mut().save_selections(selections); + let errors = crate::apply::apply(&cx.borrow()); + if errors.is_empty() { + nwg::simple_message("Applied", "Configuration applied successfully."); + } else { + for e in &errors { + crate::ui::error::warn(e); + } } } - } - }); + }); std::mem::forget(_window_handler); } @@ -181,7 +199,10 @@ pub fn run(ctx: AppContext) { load_saved_state(&ctx_rc.borrow().selections, &groups.borrow()); apply_constraints(&ctx_rc.borrow().active_title, &groups.borrow()); let ok = i32::from(is_config_complete(&groups.borrow())); - unsafe { EnableWindow(save_hwnd as _, ok); EnableWindow(apply_hwnd as _, ok); } + unsafe { + EnableWindow(save_hwnd as _, ok); + EnableWindow(apply_hwnd as _, ok); + } // --- Save / Apply buttons at the bottom --- let btn_w = (WINDOW_W - MARGIN * 3) / 2; @@ -197,7 +218,8 @@ pub fn run(ctx: AppContext) { // Center on screen let screen_w = unsafe { GetSystemMetrics(SM_CXSCREEN) }; let screen_h = unsafe { GetSystemMetrics(SM_CYSCREEN) }; - app.window.set_position((screen_w - WINDOW_W) / 2, ((screen_h - win_h) / 2).max(0)); + app.window + .set_position((screen_w - WINDOW_W) / 2, ((screen_h - win_h) / 2).max(0)); app.window.set_visible(true); nwg::dispatch_thread_events(); @@ -217,8 +239,13 @@ fn build_option_groups( for group_def in group_defs { let item_count = match &group_def.kind { - OptionGroupType::RadioGroup => group_def.radios.as_ref().map_or(0, |v: &Vec<_>| v.len()), - OptionGroupType::CheckboxGroup => group_def.checkboxes.as_ref().map_or(0, |v: &Vec<_>| v.len()), + OptionGroupType::RadioGroup => { + group_def.radios.as_ref().map_or(0, |v: &Vec<_>| v.len()) + } + OptionGroupType::CheckboxGroup => group_def + .checkboxes + .as_ref() + .map_or(0, |v: &Vec<_>| v.len()), } as i32; let frame_h = GROUP_TITLE_H + item_count * ITEM_H + GROUP_PADDING; @@ -241,7 +268,9 @@ fn build_option_groups( .build(&mut title) .expect("Failed to build group title"); if let nwg::ControlHandle::Hwnd(h) = title.handle { - unsafe { SendMessageW(h as _, WM_SETFONT, fonts.bold as usize, 1); } + unsafe { + SendMessageW(h as _, WM_SETFONT, fonts.bold as usize, 1); + } } let mut item_y = GROUP_TITLE_H; @@ -260,8 +289,12 @@ fn build_option_groups( .expect("Failed to build RadioButton"); if let nwg::ControlHandle::Hwnd(h) = radio.handle { let empty: [u16; 1] = [0]; - unsafe { SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); } - unsafe { SendMessageW(h as _, WM_SETFONT, fonts.normal as usize, 1); } + unsafe { + SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); + } + unsafe { + SendMessageW(h as _, WM_SETFONT, fonts.normal as usize, 1); + } } item_y += ITEM_H; items.push(RadioItem { @@ -270,7 +303,10 @@ fn build_option_groups( ctrl: radio, }); } - groups.push(Group::Radios { name: group_def.name.clone(), items }); + groups.push(Group::Radios { + name: group_def.name.clone(), + items, + }); } OptionGroupType::CheckboxGroup => { let mut items: Vec = Vec::new(); @@ -285,8 +321,12 @@ fn build_option_groups( .expect("Failed to build CheckBox"); if let nwg::ControlHandle::Hwnd(h) = cb.handle { let empty: [u16; 1] = [0]; - unsafe { SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); } - unsafe { SendMessageW(h as _, WM_SETFONT, fonts.normal as usize, 1); } + unsafe { + SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); + } + unsafe { + SendMessageW(h as _, WM_SETFONT, fonts.normal as usize, 1); + } } item_y += ITEM_H; items.push(CheckItem { @@ -295,7 +335,10 @@ fn build_option_groups( ctrl: cb, }); } - groups.push(Group::Checks { name: group_def.name.clone(), items }); + groups.push(Group::Checks { + name: group_def.name.clone(), + items, + }); } } diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index 295cae5..36f4fb0 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -1,51 +1,51 @@ use native_windows_gui as nwg; -use windows_sys::Win32::UI::WindowsAndMessaging::{ - SendMessageW, CreateWindowExW, WS_CHILD, WS_VISIBLE, - LoadIconW, WM_SETICON, LoadCursorW, SetCursor, - SystemParametersInfoW, NONCLIENTMETRICSW, - GetClientRect, GetClassNameW, +use windows_sys::Win32::Foundation::RECT; +use windows_sys::Win32::Graphics::Gdi::{ + CreateFontIndirectW, CreateSolidBrush, DeleteObject, FillRect, SetBkMode, SetTextColor, }; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; use windows_sys::Win32::UI::Input::KeyboardAndMouse::IsWindowEnabled; -use windows_sys::Win32::Graphics::Gdi::{CreateSolidBrush, CreateFontIndirectW, DeleteObject, SetTextColor, SetBkMode, FillRect}; -use windows_sys::Win32::Foundation::RECT; use windows_sys::Win32::UI::Shell::ShellExecuteW; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, GetClassNameW, GetClientRect, LoadCursorW, LoadIconW, NONCLIENTMETRICSW, + SendMessageW, SetCursor, SystemParametersInfoW, WM_SETICON, WS_CHILD, WS_VISIBLE, +}; // Color scheme (Steam-like) -pub const COLOR_BG: u32 = 0x002E2825; // #25282E -pub const COLOR_BG_DARK: u32 = 0x002E2623; // #23262E -pub const COLOR_BG_LIGHT: u32 = 0x003F3530; // #30353F +pub const COLOR_BG: u32 = 0x002E2825; // #25282E +pub const COLOR_BG_DARK: u32 = 0x002E2623; // #23262E +pub const COLOR_BG_LIGHT: u32 = 0x003F3530; // #30353F pub const COLOR_TEXT_TITLES: u32 = 0x00DFDED1; // #D1DEDF — group titles pub const COLOR_TEXT_LABELS: u32 = 0x00EDECEC; // #ECECED — radio/checkbox labels -const WM_ERASEBKGND: u32 = 0x0014; +const WM_ERASEBKGND: u32 = 0x0014; const WM_CTLCOLORSTATIC: u32 = 0x0138; -const WM_SETFONT: u32 = 0x0030; -const WM_LBUTTONUP: u32 = 0x0202; -const WM_SETCURSOR: u32 = 0x0020; -const SS_ETCHEDHORZ: u32 = 0x0010; +const WM_SETFONT: u32 = 0x0030; +const WM_LBUTTONUP: u32 = 0x0202; +const WM_SETCURSOR: u32 = 0x0020; +const SS_ETCHEDHORZ: u32 = 0x0010; const SPI_GETNONCLIENTMETRICS: u32 = 0x0029; const FOOTER_LABEL_H: i32 = 12; -const FOOTER_LINK_H: i32 = 12; -const FOOTER_GAP: i32 = 4; +const FOOTER_LINK_H: i32 = 12; +const FOOTER_GAP: i32 = 4; // --- Fonts --- pub struct AppFonts { pub normal: isize, - pub bold: isize, - pub small: isize, - pub link: isize, + pub bold: isize, + pub small: isize, + pub link: isize, } impl Drop for AppFonts { fn drop(&mut self) { unsafe { DeleteObject(self.normal as *mut _); - DeleteObject(self.bold as *mut _); - DeleteObject(self.small as *mut _); - DeleteObject(self.link as *mut _); + DeleteObject(self.bold as *mut _); + DeleteObject(self.small as *mut _); + DeleteObject(self.link as *mut _); } } } @@ -54,7 +54,12 @@ pub fn create_fonts() -> AppFonts { unsafe { let mut ncm: NONCLIENTMETRICSW = std::mem::zeroed(); ncm.cbSize = std::mem::size_of::() as u32; - SystemParametersInfoW(SPI_GETNONCLIENTMETRICS, ncm.cbSize, &mut ncm as *mut _ as *mut _, 0); + SystemParametersInfoW( + SPI_GETNONCLIENTMETRICS, + ncm.cbSize, + &mut ncm as *mut _ as *mut _, + 0, + ); let lf_normal = ncm.lfMessageFont; let mut lf_bold = lf_normal; lf_bold.lfWeight = 700; @@ -64,9 +69,9 @@ pub fn create_fonts() -> AppFonts { lf_link.lfUnderline = 1; AppFonts { normal: CreateFontIndirectW(&lf_normal) as isize, - bold: CreateFontIndirectW(&lf_bold) as isize, - small: CreateFontIndirectW(&lf_small) as isize, - link: CreateFontIndirectW(&lf_link) as isize, + bold: CreateFontIndirectW(&lf_bold) as isize, + small: CreateFontIndirectW(&lf_small) as isize, + link: CreateFontIndirectW(&lf_link) as isize, } } } @@ -75,7 +80,8 @@ pub fn create_fonts() -> AppFonts { pub fn apply_icon(window: &nwg::Window) { if let nwg::ControlHandle::Hwnd(hwnd) = window.handle { - #[allow(clippy::manual_dangling_ptr)] // 1 is a resource ID (MAKEINTRESOURCE), not a real pointer + #[allow(clippy::manual_dangling_ptr)] + // 1 is a resource ID (MAKEINTRESOURCE), not a real pointer let hicon = unsafe { LoadIconW(GetModuleHandleW(std::ptr::null()), 1 as *const u16) }; if !hicon.is_null() { unsafe { @@ -89,57 +95,84 @@ pub fn apply_icon(window: &nwg::Window) { // --- Theming --- pub fn apply_theme(window: &nwg::Window, frames: &[nwg::Frame]) { - let brush_dark = unsafe { CreateSolidBrush(COLOR_BG_DARK) } as isize; - let brush_bg = unsafe { CreateSolidBrush(COLOR_BG) } as isize; + let brush_dark = unsafe { CreateSolidBrush(COLOR_BG_DARK) } as isize; + let brush_bg = unsafe { CreateSolidBrush(COLOR_BG) } as isize; let brush_light = unsafe { CreateSolidBrush(COLOR_BG_LIGHT) } as isize; // Window background + static labels - let _theme_win = nwg::bind_raw_event_handler(&window.handle, 0x10001, move |hwnd, msg, w, _| { - if msg == WM_ERASEBKGND { - let hdc = w as *mut std::ffi::c_void; - let mut rect: RECT = unsafe { std::mem::zeroed() }; - unsafe { GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); } - unsafe { FillRect(hdc, &rect, brush_dark as usize as *mut std::ffi::c_void); } - return Some(1); - } - if msg == WM_CTLCOLORSTATIC { - let hdc = w as *mut std::ffi::c_void; - unsafe { SetTextColor(hdc, COLOR_TEXT_TITLES); SetBkMode(hdc, 1); } - return Some(brush_bg); - } - None - }).ok(); + let _theme_win = + nwg::bind_raw_event_handler(&window.handle, 0x10001, move |hwnd, msg, w, _| { + if msg == WM_ERASEBKGND { + let hdc = w as *mut std::ffi::c_void; + let mut rect: RECT = unsafe { std::mem::zeroed() }; + unsafe { + GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); + } + unsafe { + FillRect(hdc, &rect, brush_dark as usize as *mut std::ffi::c_void); + } + return Some(1); + } + if msg == WM_CTLCOLORSTATIC { + let hdc = w as *mut std::ffi::c_void; + unsafe { + SetTextColor(hdc, COLOR_TEXT_TITLES); + SetBkMode(hdc, 1); + } + return Some(brush_bg); + } + None + }) + .ok(); let _ = _theme_win; // RawEventHandler has no Drop — handler stays registered at OS level // Frame backgrounds + radio/checkbox colors - let _theme_frames: Vec<_> = frames.iter().enumerate().map(|(i, frame)| { - nwg::bind_raw_event_handler(&frame.handle, 0x10002 + i, move |hwnd, msg, w, l| { - match msg { - WM_ERASEBKGND => { - let hdc = w as *mut std::ffi::c_void; - let mut rect: RECT = unsafe { std::mem::zeroed() }; - unsafe { GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); } - unsafe { FillRect(hdc, &rect, brush_light as usize as *mut std::ffi::c_void); } - Some(1) - } - WM_CTLCOLORSTATIC => { - let hdc = w as *mut std::ffi::c_void; - let ctrl_hwnd = l as usize as *mut std::ffi::c_void; - let is_enabled = unsafe { IsWindowEnabled(ctrl_hwnd) } != 0; - unsafe { SetBkMode(hdc, 1); } - if is_enabled { - let mut class: [u16; 16] = [0; 16]; - let n = unsafe { GetClassNameW(ctrl_hwnd, class.as_mut_ptr(), 16) }; - let is_button = n > 0 && class[0] == b'B' as u16; - let color = if is_button { COLOR_TEXT_LABELS } else { COLOR_TEXT_TITLES }; - unsafe { SetTextColor(hdc, color); } + let _theme_frames: Vec<_> = + frames + .iter() + .enumerate() + .map(|(i, frame)| { + nwg::bind_raw_event_handler(&frame.handle, 0x10002 + i, move |hwnd, msg, w, l| { + match msg { + WM_ERASEBKGND => { + let hdc = w as *mut std::ffi::c_void; + let mut rect: RECT = unsafe { std::mem::zeroed() }; + unsafe { + GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); + } + unsafe { + FillRect(hdc, &rect, brush_light as usize as *mut std::ffi::c_void); + } + Some(1) + } + WM_CTLCOLORSTATIC => { + let hdc = w as *mut std::ffi::c_void; + let ctrl_hwnd = l as usize as *mut std::ffi::c_void; + let is_enabled = unsafe { IsWindowEnabled(ctrl_hwnd) } != 0; + unsafe { + SetBkMode(hdc, 1); + } + if is_enabled { + let mut class: [u16; 16] = [0; 16]; + let n = unsafe { GetClassNameW(ctrl_hwnd, class.as_mut_ptr(), 16) }; + let is_button = n > 0 && class[0] == b'B' as u16; + let color = if is_button { + COLOR_TEXT_LABELS + } else { + COLOR_TEXT_TITLES + }; + unsafe { + SetTextColor(hdc, color); + } + } + Some(brush_light) + } + _ => None, } - Some(brush_light) - } - _ => None, - } - }).ok() - }).collect(); + }) + .ok() + }) + .collect(); std::mem::forget(_theme_frames); } @@ -148,17 +181,37 @@ pub fn apply_theme(window: &nwg::Window, frames: &[nwg::Frame]) { /// Builds the separator + footer labels below the buttons. /// `y_after_btns` = y + BTN_H (top of the footer zone). /// Returns `content_bottom` — add MARGIN to get `win_h`. -pub fn build_footer(window: &nwg::Window, y_after_btns: i32, window_w: i32, margin: i32, fonts: &AppFonts) -> i32 { - let y_sep = y_after_btns + 6; +pub fn build_footer( + window: &nwg::Window, + y_after_btns: i32, + window_w: i32, + margin: i32, + fonts: &AppFonts, +) -> i32 { + let y_sep = y_after_btns + 6; let y_footer = y_sep + 8; // Horizontal separator line let sep_class: Vec = "STATIC\0".encode_utf16().collect(); - let win_hwnd = match window.handle { nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, _ => std::ptr::null_mut() }; + let win_hwnd = match window.handle { + nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, + _ => std::ptr::null_mut(), + }; unsafe { - CreateWindowExW(0, sep_class.as_ptr(), std::ptr::null(), WS_CHILD | WS_VISIBLE | SS_ETCHEDHORZ, - margin, y_sep, window_w - margin * 2 + 2, 2, - win_hwnd, std::ptr::null_mut(), GetModuleHandleW(std::ptr::null()), std::ptr::null()); + CreateWindowExW( + 0, + sep_class.as_ptr(), + std::ptr::null(), + WS_CHILD | WS_VISIBLE | SS_ETCHEDHORZ, + margin, + y_sep, + window_w - margin * 2 + 2, + 2, + win_hwnd, + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + std::ptr::null(), + ); } let mut foot_text = nwg::Label::default(); @@ -171,7 +224,9 @@ pub fn build_footer(window: &nwg::Window, y_after_btns: i32, window_w: i32, marg .build(&mut foot_text) .expect("Failed to build footer label"); if let nwg::ControlHandle::Hwnd(h) = foot_text.handle { - unsafe { SendMessageW(h as _, WM_SETFONT, fonts.small as usize, 1); } + unsafe { + SendMessageW(h as _, WM_SETFONT, fonts.small as usize, 1); + } } let mut foot_link = nwg::Label::default(); @@ -184,25 +239,39 @@ pub fn build_footer(window: &nwg::Window, y_after_btns: i32, window_w: i32, marg .build(&mut foot_link) .expect("Failed to build footer link"); if let nwg::ControlHandle::Hwnd(h) = foot_link.handle { - unsafe { SendMessageW(h as _, WM_SETFONT, fonts.link as usize, 1); } + unsafe { + SendMessageW(h as _, WM_SETFONT, fonts.link as usize, 1); + } } - let _foot_link_handler = nwg::bind_raw_event_handler(&foot_link.handle, 0x10011, move |_, msg, _, _| { - match msg { + let _foot_link_handler = + nwg::bind_raw_event_handler(&foot_link.handle, 0x10011, move |_, msg, _, _| match msg { WM_LBUTTONUP => { - let url: Vec = "https://www.mulderland.com?utm_source=MulderConfig\0".encode_utf16().collect(); + let url: Vec = "https://www.mulderland.com?utm_source=MulderConfig\0" + .encode_utf16() + .collect(); let op: Vec = "open\0".encode_utf16().collect(); - unsafe { ShellExecuteW(std::ptr::null_mut(), op.as_ptr(), url.as_ptr(), std::ptr::null(), std::ptr::null(), 1); } + unsafe { + ShellExecuteW( + std::ptr::null_mut(), + op.as_ptr(), + url.as_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + ); + } None } WM_SETCURSOR => { let hcur = unsafe { LoadCursorW(std::ptr::null_mut(), 32649 as *const u16) }; - unsafe { SetCursor(hcur); } + unsafe { + SetCursor(hcur); + } Some(1) } _ => None, - } - }); + }); // Keep controls and handler alive for the duration of the program std::mem::forget(_foot_link_handler); std::mem::forget(foot_text); diff --git a/src/ui/controls.rs b/src/ui/controls.rs index 59f8a97..cdf8628 100644 --- a/src/ui/controls.rs +++ b/src/ui/controls.rs @@ -1,10 +1,10 @@ -use native_windows_gui as nwg; -use indexmap::IndexMap; use crate::{ config::model::WhenGroup, - config::when_resolver::{match_when, Selections, SelectionValue}, + config::when_resolver::{SelectionValue, Selections, match_when}, save::{GroupSelections, SaveValue}, }; +use indexmap::IndexMap; +use native_windows_gui as nwg; pub struct RadioItem { pub value: String, @@ -40,15 +40,21 @@ pub fn load_saved_state(selections: &GroupSelections, groups: &[Group]) { } } Group::Checks { name, items } => { - let checked_vals: Vec<&str> = selections.get(name) - .and_then(|v| if let SaveValue::Multiple(list) = v { - Some(list.iter().map(|s| s.as_str()).collect()) - } else { - None + let checked_vals: Vec<&str> = selections + .get(name) + .and_then(|v| { + if let SaveValue::Multiple(list) = v { + Some(list.iter().map(|s| s.as_str()).collect()) + } else { + None + } }) .unwrap_or_default(); for item in items { - let state = if checked_vals.iter().any(|v| v.eq_ignore_ascii_case(&item.value)) { + let state = if checked_vals + .iter() + .any(|v| v.eq_ignore_ascii_case(&item.value)) + { nwg::CheckBoxState::Checked } else { nwg::CheckBoxState::Unchecked @@ -62,7 +68,9 @@ pub fn load_saved_state(selections: &GroupSelections, groups: &[Group]) { pub fn is_config_complete(groups: &[Group]) -> bool { groups.iter().all(|g| match g { - Group::Radios { items, .. } => items.iter().any(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked), + Group::Radios { items, .. } => items + .iter() + .any(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked), Group::Checks { .. } => true, }) } @@ -72,12 +80,16 @@ pub fn collect_selections_for_save(groups: &[Group]) -> GroupSelections { for group in groups { match group { Group::Radios { name, items } => { - if let Some(item) = items.iter().find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) { + if let Some(item) = items + .iter() + .find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) + { map.insert(name.clone(), SaveValue::Single(item.value.clone())); } } Group::Checks { name, items } => { - let checked: Vec = items.iter() + let checked: Vec = items + .iter() .filter(|i| i.ctrl.check_state() == nwg::CheckBoxState::Checked) .map(|i| i.value.clone()) .collect(); @@ -91,16 +103,23 @@ pub fn collect_selections_for_save(groups: &[Group]) -> GroupSelections { pub fn apply_constraints(title: &str, groups: &[Group]) { // 1. Build current selections from control states let mut selections: Selections = Selections::new(); - selections.insert("Title".to_string(), SelectionValue::Single(title.to_string())); + selections.insert( + "Title".to_string(), + SelectionValue::Single(title.to_string()), + ); for group in groups { match group { Group::Radios { name, items } => { - if let Some(item) = items.iter().find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) { + if let Some(item) = items + .iter() + .find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) + { selections.insert(name.clone(), SelectionValue::Single(item.value.clone())); } } Group::Checks { name, items } => { - let checked: Vec = items.iter() + let checked: Vec = items + .iter() .filter(|i| i.ctrl.check_state() == nwg::CheckBoxState::Checked) .map(|i| i.value.clone()) .collect(); diff --git a/src/ui/error.rs b/src/ui/error.rs index 3024cd1..498b1c9 100644 --- a/src/ui/error.rs +++ b/src/ui/error.rs @@ -1,17 +1,31 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ - MessageBoxW, MB_ICONERROR, MB_ICONWARNING, MB_OK, MB_YESNO, IDYES, + IDYES, MB_ICONERROR, MB_ICONWARNING, MB_OK, MB_YESNO, MessageBoxW, }; pub fn warn(msg: &str) { let title: Vec = "MulderConfig\0".encode_utf16().collect(); let text: Vec = format!("{msg}\0").encode_utf16().collect(); - unsafe { MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), MB_OK | MB_ICONWARNING); } + unsafe { + MessageBoxW( + std::ptr::null_mut(), + text.as_ptr(), + title.as_ptr(), + MB_OK | MB_ICONWARNING, + ); + } } pub fn fatal(msg: &str) -> ! { let title: Vec = "MulderConfig\0".encode_utf16().collect(); let text: Vec = format!("{msg}\0").encode_utf16().collect(); - unsafe { MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), MB_OK | MB_ICONERROR); } + unsafe { + MessageBoxW( + std::ptr::null_mut(), + text.as_ptr(), + title.as_ptr(), + MB_OK | MB_ICONERROR, + ); + } std::process::exit(1); } @@ -21,7 +35,12 @@ pub fn ask_delete_save(msg: &str) -> bool { let title: Vec = "MulderConfig\0".encode_utf16().collect(); let text: Vec = format!("{msg}\0").encode_utf16().collect(); let result = unsafe { - MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), MB_YESNO | MB_ICONWARNING) + MessageBoxW( + std::ptr::null_mut(), + text.as_ptr(), + title.as_ptr(), + MB_YESNO | MB_ICONWARNING, + ) }; result == IDYES } From 18225291bb582cc03c09125b3b7e1bf60c682f71 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 6 May 2026 11:21:08 +0200 Subject: [PATCH 68/77] exe_replacer - check if old MulderConfig and overwrite it --- src/apply/exe_replacer.rs | 43 ++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/apply/exe_replacer.rs b/src/apply/exe_replacer.rs index 0a26251..ee42b25 100644 --- a/src/apply/exe_replacer.rs +++ b/src/apply/exe_replacer.rs @@ -5,6 +5,18 @@ use windows_sys::Win32::Storage::FileSystem::{ BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, }; +/// Returns true if the file contains the UTF-16 string "MulderConfig" in its PE resources. +fn is_mulder_config(path: &Path) -> bool { + let Ok(bytes) = std::fs::read(path) else { + return false; + }; + let needle: Vec = "MulderConfig" + .encode_utf16() + .flat_map(|c| c.to_le_bytes()) + .collect(); + bytes.windows(needle.len()).any(|w| w == needle.as_slice()) +} + pub struct ExePaths { pub original: PathBuf, // Game.exe (becomes a hard link after apply) pub original_backup: PathBuf, // Game_o.exe (original game exe, preserved) @@ -61,6 +73,8 @@ fn same_file(a: &Path, b: &Path) -> std::io::Result { /// Replaces Game.exe with a hard link to this exe. /// Original Game.exe is renamed to Game_o.exe. /// If a stale backup already exists (e.g. after a launcher update), it is removed first. +/// If Game.exe is itself a MulderConfig build (legacy C# migration), it is deleted instead +/// of being preserved as backup, to avoid overwriting the real Game_o.exe. pub fn replace(config: &ConfigModel) -> Result<(), String> { let paths = get_paths(config); if !paths.original.exists() { @@ -69,21 +83,30 @@ pub fn replace(config: &ConfigModel) -> Result<(), String> { paths.original.display() )); } - if paths.original_backup.exists() { - std::fs::remove_file(&paths.original_backup).map_err(|e| { + + if is_mulder_config(&paths.original) { + // original is a legacy MulderConfig — discard it, keep the existing backup + std::fs::remove_file(&paths.original).map_err(|e| { + format!("Failed to remove legacy MulderConfig '{}': {e}", paths.original.display()) + })?; + } else { + if paths.original_backup.exists() { + std::fs::remove_file(&paths.original_backup).map_err(|e| { + format!( + "Failed to remove stale backup '{}': {e}", + paths.original_backup.display() + ) + })?; + } + std::fs::rename(&paths.original, &paths.original_backup).map_err(|e| { format!( - "Failed to remove stale backup '{}': {e}", + "Failed to rename '{}' to '{}': {e}", + paths.original.display(), paths.original_backup.display() ) })?; } - std::fs::rename(&paths.original, &paths.original_backup).map_err(|e| { - format!( - "Failed to rename '{}' to '{}': {e}", - paths.original.display(), - paths.original_backup.display() - ) - })?; + std::fs::hard_link(&paths.mulder_config, &paths.original).map_err(|e| { let _ = std::fs::rename(&paths.original_backup, &paths.original); format!("Failed to create hard link: {e}") From ca9c618ba88b6d31dd5c52b4cca69f0b81e57ecd Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 6 May 2026 11:41:07 +0200 Subject: [PATCH 69/77] read json files near exe instead of workdir --- src/main.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4acf740..4cae23c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,14 +16,25 @@ use save::loader::SaveLoader; use save::validator::SaveValidator; use ui::error::{ask_delete_save, fatal}; -const SAVE_PATH: &str = "MulderConfig.save.json"; +const CONFIG_FILE: &str = "MulderConfig.json"; +const SAVE_FILE: &str = "MulderConfig.save.json"; + +fn exe_relative(filename: &str) -> std::path::PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join(filename))) + .unwrap_or_else(|| std::path::PathBuf::from(filename)) +} fn main() { - let config = ConfigLoader::load("MulderConfig.json") + let config_path = exe_relative(CONFIG_FILE); + let save_path = exe_relative(SAVE_FILE); + + let config = ConfigLoader::load(&config_path.to_string_lossy()) .unwrap_or_else(|e| fatal(&format!("Failed to load config:\n{e}"))); - let save_data = if std::path::Path::new(SAVE_PATH).exists() { - let data = SaveLoader::load(SAVE_PATH) + let save_data = if save_path.exists() { + let data = SaveLoader::load(&save_path.to_string_lossy()) .unwrap_or_else(|e| fatal(&format!("Failed to load save file:\n{e}"))); match SaveValidator::validate(&data, &config) { Ok(()) => data, @@ -32,7 +43,7 @@ fn main() { "Save file is invalid:\n{e}\n\nDelete the save file and continue?\n(Choosing No will close the application)" ); if ask_delete_save(&msg) { - std::fs::remove_file(SAVE_PATH) + std::fs::remove_file(&save_path) .unwrap_or_else(|e| fatal(&format!("Failed to delete save file:\n{e}"))); Default::default() } else { @@ -56,7 +67,7 @@ fn main() { }, }; - let ctx = AppContext::new(config, save_data, SAVE_PATH.to_string(), active_title); + let ctx = AppContext::new(config, save_data, save_path.to_string_lossy().into_owned(), active_title); let mode = detect_mode(&ctx.config.game.original_exe); From 3198b825e9cd770871d3c679525e71e09516f830 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 6 May 2026 15:25:11 +0200 Subject: [PATCH 70/77] fix args with space --- src/apply/launcher.rs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/apply/launcher.rs b/src/apply/launcher.rs index 3019bc6..21605b6 100644 --- a/src/apply/launcher.rs +++ b/src/apply/launcher.rs @@ -30,12 +30,10 @@ fn resolve_launch(config: &ConfigModel, selected: &Selections) -> ResolvedLaunch work_dir = resolve_path(&exec.work_dir); wait = exec.wait.unwrap_or(false); } - // Cumulative args + // Cumulative args — split on whitespace so "-addon 1" becomes ["-addon", "1"] if let Some(rule_args) = &rule.args { for a in rule_args { - if !a.is_empty() { - args.push(a.clone()); - } + args.extend(a.split_whitespace().map(str::to_owned)); } } } @@ -276,4 +274,29 @@ mod tests { "%%SystemRoot%% in exec.name should be expanded" ); } + + #[test] + fn args_with_spaces_are_split_into_separate_args() { + // Mirrors the C# behaviour: args are joined into a single string and + // parsed by the Windows command-line parser, so "-addon 1" becomes + // two distinct argv entries. In Rust we must split explicitly. + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "args": ["-addon 1", "-gamegrp duke3d.grp"] + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let resolved = resolve_launch(&config, &sel(&[])); + + assert_eq!(resolved.args, vec!["-addon", "1", "-gamegrp", "duke3d.grp"]); + } } From b7fc0863c66614b17af935c31401e2036b1efcc9 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 6 May 2026 15:27:29 +0200 Subject: [PATCH 71/77] fix codestyle + change default version --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/apply/exe_replacer.rs | 5 ++++- src/main.rs | 7 ++++++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2293330..fc25fb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mulder-config" -version = "26.0.0-dev" +version = "26.5.0-dev" dependencies = [ "indexmap", "native-windows-derive", diff --git a/Cargo.toml b/Cargo.toml index a78ba89..130f5d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mulder-config" -version = "26.0.0-dev" +version = "26.5.0-dev" edition = "2024" [[bin]] diff --git a/src/apply/exe_replacer.rs b/src/apply/exe_replacer.rs index ee42b25..0d977a7 100644 --- a/src/apply/exe_replacer.rs +++ b/src/apply/exe_replacer.rs @@ -87,7 +87,10 @@ pub fn replace(config: &ConfigModel) -> Result<(), String> { if is_mulder_config(&paths.original) { // original is a legacy MulderConfig — discard it, keep the existing backup std::fs::remove_file(&paths.original).map_err(|e| { - format!("Failed to remove legacy MulderConfig '{}': {e}", paths.original.display()) + format!( + "Failed to remove legacy MulderConfig '{}': {e}", + paths.original.display() + ) })?; } else { if paths.original_backup.exists() { diff --git a/src/main.rs b/src/main.rs index 4cae23c..f0a7433 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,7 +67,12 @@ fn main() { }, }; - let ctx = AppContext::new(config, save_data, save_path.to_string_lossy().into_owned(), active_title); + let ctx = AppContext::new( + config, + save_data, + save_path.to_string_lossy().into_owned(), + active_title, + ); let mode = detect_mode(&ctx.config.game.original_exe); From 44f49d4dc3d545be87041904ea0e82447abcc303 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 6 May 2026 15:43:29 +0200 Subject: [PATCH 72/77] Update build.yaml for Rust --- .github/workflows/build.yaml | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a4656af..0c86ff8 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -11,18 +11,15 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable with: - dotnet-version: 8.x + targets: x86_64-pc-windows-msvc - - name: Restore dependencies - run: dotnet restore - - - name: Run unit tests - run: dotnet test .\Tests\MulderConfigTests.csproj -c Release --no-restore + - name: Run tests + run: cargo test - name: Generate Version id: generate_version @@ -42,29 +39,23 @@ jobs: Write-Host "Generated version: $version" echo version=$version >> $env:GITHUB_OUTPUT - - name: Publish (framework-dependent, single file) - run: dotnet publish ./MulderConfig.csproj ` - -c Release ` - -r win-x64 ` - --self-contained false ` - -p:SelfContained=false ` - -p:PublishSingleFile=true ` - -p:DebugType=none ` - -p:Version=${{ steps.generate_version.outputs.version }} ` - -o publish + - name: Compile executable + env: + APP_VERSION: ${{ steps.generate_version.outputs.version }} + run: cargo build --release - name: Sign executable shell: pwsh run: | [Byte[]] $bytes = [System.Convert]::FromBase64String("${{ secrets.CERTIFICATE_BASE64 }}") [IO.File]::WriteAllBytes("mulderload.pfx", $bytes) - Start-Process "C:/Program Files (x86)/Microsoft SDKs/ClickOnce/SignTool/signtool.exe" "sign /f mulderload.pfx /p ${{ secrets.CERTIFICATE_PASSWORD }} /t http://timestamp.digicert.com /fd sha256 publish/MulderConfig.exe" -PassThru -Wait + Start-Process "C:/Program Files (x86)/Microsoft SDKs/ClickOnce/SignTool/signtool.exe" "sign /f mulderload.pfx /p ${{ secrets.CERTIFICATE_PASSWORD }} /t http://timestamp.digicert.com /fd sha256 target/release/MulderConfig.exe" -PassThru -Wait - name: Create Release - uses: softprops/action-gh-release@v2.3.3 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.generate_version.outputs.version }} - files: publish/MulderConfig.exe + files: target/release/MulderConfig.exe permissions: contents: write From 0f2af8220d3252f8ed8a757aa280e24be6c77efd Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 6 May 2026 17:24:49 +0200 Subject: [PATCH 73/77] Add prerelease generation + cleanup --- .github/workflows/build.yaml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0c86ff8..f29693f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,6 +4,8 @@ on: push: branches: - main + - pre/** + pull_request: {} jobs: build: @@ -25,7 +27,12 @@ jobs: id: generate_version shell: pwsh run: | - $base = Get-Date -Format "yy.MM" + $isPre = "${{ github.ref_name }}" -ne "main" + if ($isPre) { + $base = Get-Date -Format "yy.00" + } else { + $base = Get-Date -Format "yy.MM" + } git fetch --tags $tags = git tag -l "$base.*" Write-Host "Tags: $($tags -join ', ')" @@ -38,6 +45,7 @@ jobs: $version = "$base.$next" Write-Host "Generated version: $version" echo version=$version >> $env:GITHUB_OUTPUT + echo is_pre=$($isPre.ToString().ToLower()) >> $env:GITHUB_OUTPUT - name: Compile executable env: @@ -54,8 +62,25 @@ jobs: - name: Create Release uses: softprops/action-gh-release@v3 with: - tag_name: ${{ steps.generate_version.outputs.version }} files: target/release/MulderConfig.exe + prerelease: ${{ steps.generate_version.outputs.is_pre }} + tag_name: ${{ steps.generate_version.outputs.version }} + + - name: Cleanup old pre-releases + if: steps.generate_version.outputs.is_pre == 'true' + shell: pwsh + run: | + $old = gh release list --limit 100 --json tagName,isPrerelease,createdAt | + ConvertFrom-Json | + Where-Object { $_.isPrerelease } | + Sort-Object createdAt | + Select-Object -SkipLast 10 + foreach ($r in $old) { + Write-Host "Deleting old pre-release: $($r.tagName)" + gh release delete $r.tagName --cleanup-tag --yes + } + env: + GH_TOKEN: ${{ github.token }} permissions: contents: write From ac44faa677b9a719b4902512978e7b987ac2bd58 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 6 May 2026 17:26:31 +0200 Subject: [PATCH 74/77] Conditional code signing --- .github/workflows/build.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f29693f..12a53e9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -53,6 +53,7 @@ jobs: run: cargo build --release - name: Sign executable + if: vars.ENABLE_CODE_SIGNING == 'true' shell: pwsh run: | [Byte[]] $bytes = [System.Convert]::FromBase64String("${{ secrets.CERTIFICATE_BASE64 }}") From bdbe66b93961113c4336b1bd92e8574b891b85a3 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 6 May 2026 17:31:46 +0200 Subject: [PATCH 75/77] Remove C# code --- .gitignore | 429 -------------------- Actions/FileOperationManager.cs | 268 ------------ Actions/LaunchManager.cs | 87 ---- Apply/ApplyManager.cs | 32 -- Apply/ExeReplacer.cs | 83 ---- Configuration/ConfigModel.cs | 85 ---- Configuration/ConfigProvider.cs | 26 -- Configuration/ConfigValidator.cs | 161 -------- ISelectionProvider.cs | 7 - Logic/WhenResolver.cs | 111 ----- ModeDetector.cs | 24 -- MulderConfig.csproj | 24 -- MulderConfig.sln | 28 -- Program.cs | 87 ---- Save/SaveLoader.cs | 84 ---- Save/SaveModel.cs | 7 - Save/SaveSaver.cs | 24 -- Save/SaveValidator.cs | 56 --- SteamAddonHandler.cs | 33 -- UI/Form1.Designer.cs | 96 ----- UI/Form1.cs | 98 ----- UI/Form1.resx | 120 ------ UI/FormBuilder.cs | 96 ----- UI/FormController.cs | 73 ---- UI/FormSelectionProvider.cs | 59 --- UI/FormValidator.cs | 72 ---- tests/.gitignore | 2 - tests/Actions/FileOperationManagerTests.cs | 50 --- tests/Actions/LaunchManagerTests.cs | 91 ----- tests/Configuration/ConfigJsonTests.cs | 244 ----------- tests/Configuration/ConfigValidatorTests.cs | 133 ------ tests/Logic/WhenResolverTests.cs | 259 ------------ tests/MulderConfigTests.csproj | 17 - tests/Save/SaveValidatorTests.cs | 141 ------- 34 files changed, 3207 deletions(-) delete mode 100644 Actions/FileOperationManager.cs delete mode 100644 Actions/LaunchManager.cs delete mode 100644 Apply/ApplyManager.cs delete mode 100644 Apply/ExeReplacer.cs delete mode 100644 Configuration/ConfigModel.cs delete mode 100644 Configuration/ConfigProvider.cs delete mode 100644 Configuration/ConfigValidator.cs delete mode 100644 ISelectionProvider.cs delete mode 100644 Logic/WhenResolver.cs delete mode 100644 ModeDetector.cs delete mode 100644 MulderConfig.csproj delete mode 100644 MulderConfig.sln delete mode 100644 Program.cs delete mode 100644 Save/SaveLoader.cs delete mode 100644 Save/SaveModel.cs delete mode 100644 Save/SaveSaver.cs delete mode 100644 Save/SaveValidator.cs delete mode 100644 SteamAddonHandler.cs delete mode 100644 UI/Form1.Designer.cs delete mode 100644 UI/Form1.cs delete mode 100644 UI/Form1.resx delete mode 100644 UI/FormBuilder.cs delete mode 100644 UI/FormController.cs delete mode 100644 UI/FormSelectionProvider.cs delete mode 100644 UI/FormValidator.cs delete mode 100644 tests/.gitignore delete mode 100644 tests/Actions/FileOperationManagerTests.cs delete mode 100644 tests/Actions/LaunchManagerTests.cs delete mode 100644 tests/Configuration/ConfigJsonTests.cs delete mode 100644 tests/Configuration/ConfigValidatorTests.cs delete mode 100644 tests/Logic/WhenResolverTests.cs delete mode 100644 tests/MulderConfigTests.csproj delete mode 100644 tests/Save/SaveValidatorTests.cs diff --git a/.gitignore b/.gitignore index 7684b67..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,430 +1 @@ /target - -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates -*.env - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ - -[Dd]ebug/x64/ -[Dd]ebugPublic/x64/ -[Rr]elease/x64/ -[Rr]eleases/x64/ -bin/x64/ -obj/x64/ - -[Dd]ebug/x86/ -[Dd]ebugPublic/x86/ -[Rr]elease/x86/ -[Rr]eleases/x86/ -bin/x86/ -obj/x86/ - -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -[Aa][Rr][Mm]64[Ee][Cc]/ -bld/ -[Oo]bj/ -[Oo]ut/ -[Ll]og/ -[Ll]ogs/ - -# Build results on 'Bin' directories -**/[Bb]in/* -# Uncomment if you have tasks that rely on *.refresh files to move binaries -# (https://github.com/github/gitignore/pull/3736) -#!**/[Bb]in/*.refresh - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* -*.trx - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Approval Tests result files -*.received.* - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.idb -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -**/.paket/paket.exe -paket-files/ - -# FAKE - F# Make -**/.fake/ - -# CodeRush personal settings -**/.cr/personal - -# Python Tools for Visual Studio (PTVS) -**/__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -#tools/** -#!tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog -MSBuild_Logs/ - -# AWS SAM Build and Temporary Artifacts folder -.aws-sam - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -**/.mfractor/ - -# Local History for Visual Studio -**/.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -**/.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp diff --git a/Actions/FileOperationManager.cs b/Actions/FileOperationManager.cs deleted file mode 100644 index 27f8f39..0000000 --- a/Actions/FileOperationManager.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System.Text.RegularExpressions; -using MulderConfig.Configuration; -using MulderConfig.Logic; - -namespace MulderConfig.Actions; - -public class FileOperationManager -{ - public void ExecuteOperations(List operations, IReadOnlyDictionary selected) - { - foreach (var action in operations) - { - if (action.When != null && !WhenResolver.Match(action.When, selected)) - continue; - - try - { - switch ((action.Operation ?? string.Empty).ToLower()) - { - case "setreadonly": - ExecuteSetReadOnly(action, isReadOnly: true); - break; - - case "removereadonly": - ExecuteSetReadOnly(action, isReadOnly: false); - break; - - case "rename": - case "move": - ExecuteMove(action); - break; - - case "copy": - ExecuteCopy(action); - break; - - case "delete": - ExecuteDelete(action); - break; - - case "replaceline": - ExecuteReplaceLine(action); - break; - - case "removeline": - ExecuteRemoveLine(action); - break; - - case "replacetext": - ExecuteReplaceText(action); - break; - - default: - MessageBox.Show($"Unknown operation: {action.Operation}", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); - break; - } - } - catch (Exception ex) - { - MessageBox.Show($"Operation failed: {action.Operation}\n{ex.Message}", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); - } - } - } - - private static string ResolvePath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - return path; - - // Expand Windows env vars like %USERPROFILE% - path = Environment.ExpandEnvironmentVariables(path); - - if (Path.IsPathRooted(path)) - return Path.GetFullPath(path); - - return Path.GetFullPath(Path.Combine(Application.StartupPath, path)); - } - - private static void ExecuteSetReadOnly(OperationAction action, bool isReadOnly) - { - if (action.Files == null || action.Files.Count == 0) - throw new InvalidOperationException("Missing 'files' for SetReadOnly/RemoveReadOnly."); - - foreach (var f in action.Files) - { - if (string.IsNullOrWhiteSpace(f)) - continue; - - var path = ResolvePath(f); - - // Idempotent behavior: if the target doesn't exist, we do nothing. - if (!File.Exists(path)) - continue; - - var attrs = File.GetAttributes(path); - var readOnlyBit = FileAttributes.ReadOnly; - - var newAttrs = isReadOnly - ? (attrs | readOnlyBit) - : (attrs & ~readOnlyBit); - - if (newAttrs != attrs) - File.SetAttributes(path, newAttrs); - } - } - - private static void ExecuteMove(OperationAction action) - { - if (string.IsNullOrWhiteSpace(action.Source) || string.IsNullOrWhiteSpace(action.Target)) - throw new InvalidOperationException("Missing 'source' or 'target' for rename/move."); - - var sourcePath = ResolvePath(action.Source); - var targetPath = ResolvePath(action.Target); - - var targetParent = Path.GetDirectoryName(targetPath); - if (!string.IsNullOrWhiteSpace(targetParent)) - Directory.CreateDirectory(targetParent); - - if (File.Exists(sourcePath)) - { - if (File.Exists(targetPath)) - File.Delete(targetPath); - else if (Directory.Exists(targetPath)) - Directory.Delete(targetPath, recursive: true); - - File.Move(sourcePath, targetPath); - return; - } - - if (Directory.Exists(sourcePath)) - { - if (Directory.Exists(targetPath)) - Directory.Delete(targetPath, recursive: true); - else if (File.Exists(targetPath)) - File.Delete(targetPath); - - Directory.Move(sourcePath, targetPath); - return; - } - - // Idempotent behavior: if the target doesn't exist, we do nothing. - return; - } - - private static void ExecuteCopy(OperationAction action) - { - if (string.IsNullOrWhiteSpace(action.Source) || string.IsNullOrWhiteSpace(action.Target)) - throw new InvalidOperationException("Missing 'source' or 'target' for copy."); - - var sourcePath = ResolvePath(action.Source); - var targetPath = ResolvePath(action.Target); - - if (!File.Exists(sourcePath)) - return; - - File.Copy(sourcePath, targetPath, overwrite: true); - } - - private static void ExecuteDelete(OperationAction action) - { - if (string.IsNullOrWhiteSpace(action.Source)) - throw new InvalidOperationException("Missing 'source' for delete."); - - var sourcePath = ResolvePath(action.Source); - if (File.Exists(sourcePath)) - File.Delete(sourcePath); - } - - private void ExecuteReplaceLine(OperationAction action) - { - if (string.IsNullOrWhiteSpace(action.Pattern) || action.Replacement == null) - throw new InvalidOperationException("Missing 'pattern' or 'replacement' for replaceLine."); - - foreach (var filePath in ResolveFiles(action.Files)) - ReplaceLineInFile(filePath, action.Pattern, action.Replacement); - } - - private void ExecuteRemoveLine(OperationAction action) - { - if (string.IsNullOrWhiteSpace(action.Pattern)) - throw new InvalidOperationException("Missing 'pattern' for removeLine."); - - foreach (var filePath in ResolveFiles(action.Files)) - RemoveLineInFile(filePath, action.Pattern); - } - - private void ExecuteReplaceText(OperationAction action) - { - if (action.Search == null || action.Replacement == null) - throw new InvalidOperationException("Missing 'search' or 'replacement' for replaceText."); - - foreach (var filePath in ResolveFiles(action.Files)) - ReplaceTextInFile(filePath, action.Search, action.Replacement); - } - - private static IEnumerable ResolveFiles(List? files) - { - if (files == null || files.Count == 0) - yield break; - - foreach (var f in files) - { - if (string.IsNullOrWhiteSpace(f)) - continue; - - var filePath = ResolvePath(f); - if (!File.Exists(filePath)) - { - MessageBox.Show($"File not found: {filePath}", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); - continue; - } - - yield return filePath; - } - } - - private void ReplaceLineInFile(string filePath, string pattern, string replacement) - { - var lines = File.ReadAllLines(filePath).ToList(); - var regex = new Regex(pattern, RegexOptions.IgnoreCase); - - var interpretedReplacement = Regex.Unescape(replacement); - var replacementLines = interpretedReplacement - .Split('\n') - .Where(l => !string.IsNullOrEmpty(l)) - .ToList(); - - bool modified = false; - var newLines = new List(); - - for (int i = 0; i < lines.Count; i++) - { - if (regex.IsMatch(lines[i])) - { - modified = true; - if (replacementLines.Count > 0) - newLines.AddRange(replacementLines); - } - else - { - newLines.Add(lines[i]); - } - } - - if (modified) - File.WriteAllLines(filePath, newLines.ToArray()); - } - - private void RemoveLineInFile(string filePath, string pattern) - { - var lines = File.ReadAllLines(filePath).ToList(); - var regex = new Regex(pattern, RegexOptions.IgnoreCase); - - var newLines = lines.Where(line => !regex.IsMatch(line)).ToList(); - - if (newLines.Count < lines.Count) - File.WriteAllLines(filePath, newLines.ToArray()); - } - - private void ReplaceTextInFile(string filePath, string search, string replacement) - { - var content = File.ReadAllText(filePath); - var newContent = content.Replace(search, replacement); - - if (content != newContent) - File.WriteAllText(filePath, newContent); - } -} diff --git a/Actions/LaunchManager.cs b/Actions/LaunchManager.cs deleted file mode 100644 index c30844a..0000000 --- a/Actions/LaunchManager.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Diagnostics; -using MulderConfig.Configuration; -using MulderConfig.Logic; -using MulderConfig.Apply; -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("MulderConfigTests")] - -namespace MulderConfig.Actions; - -public class LaunchManager(ConfigModel config, string title, IReadOnlyDictionary choices) -{ - public void Launch() - { - var (exePath, workDir, wait, args) = ResolveLaunch(); - - if (!File.Exists(exePath)) - { - throw new FileNotFoundException("Can't find executable.", exePath); - } - - Process process = new() - { - StartInfo = new ProcessStartInfo - { - FileName = exePath, - WorkingDirectory = workDir, - Arguments = args, - UseShellExecute = false - } - }; - process.Start(); - - if (wait) - { - process.WaitForExit(); - } - } - - internal (string exePath, string workDir, bool wait, string args) ResolveLaunch() - { - var selected = new Dictionary(choices, StringComparer.OrdinalIgnoreCase) - { - ["Title"] = title - }; - - // Defaults - var exePath = MakePath(config.Game.OriginalExe); - var workDir = Application.StartupPath; - bool wait = false; - var args = new List(); - - foreach (var rule in config.Actions.Launch) - { - if (rule.When != null && !WhenResolver.Match(rule.When, selected)) - continue; - - // Atomic override: last match wins - if (rule.Exec != null) - { - exePath = MakePath(rule.Exec.Name); - workDir = MakePath(rule.Exec.WorkDir); - wait = rule.Exec.Wait ?? false; - } - - // Cumulative: append all matching args - if (rule.Args != null) - { - foreach (var a in rule.Args) - { - if (!string.IsNullOrWhiteSpace(a)) - args.Add(a); - } - } - } - - return (exePath, workDir, wait, string.Join(" ", args)); - } - - private static string MakePath(string path) - { - if (Path.IsPathRooted(path)) - return Path.GetFullPath(path); - - return Path.GetFullPath(Path.Combine(Application.StartupPath, path)); - } -} diff --git a/Apply/ApplyManager.cs b/Apply/ApplyManager.cs deleted file mode 100644 index 204c24e..0000000 --- a/Apply/ApplyManager.cs +++ /dev/null @@ -1,32 +0,0 @@ -using MulderConfig.Actions; -using MulderConfig.Configuration; -using MulderConfig.Save; - -namespace MulderConfig.Apply; - -public sealed class ApplyManager( - ConfigModel config, - ExeReplacer exeReplacer, - FileOperationManager FileOperationManager) -{ - public void Apply(ISelectionProvider selectionProvider) - { - Apply(selectionProvider.GetTitle(), selectionProvider.GetChoices()); - } - - public void Apply(string title, IReadOnlyDictionary choices) - { - var selected = new Dictionary(choices, StringComparer.OrdinalIgnoreCase) - { - ["Title"] = title - }; - - var operations = config.Actions.Operations; - if (operations != null && operations.Count > 0) - FileOperationManager.ExecuteOperations(operations, selected); - - // If there is no launch section/rules, there is no exe replacement to perform. - if ((config.Actions.Launch?.Count ?? 0) > 0 && !exeReplacer.IsReplaced()) - exeReplacer.Replace(); - } -} diff --git a/Apply/ExeReplacer.cs b/Apply/ExeReplacer.cs deleted file mode 100644 index d56c30e..0000000 --- a/Apply/ExeReplacer.cs +++ /dev/null @@ -1,83 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig.Apply; - -public class ExeReplacer(ConfigModel config) -{ - private const string LAUNCHER_NAME = "MulderConfig"; - - private (string originalExe, string targetExe) GetExePaths() - { - var originalExe = Path.Combine(Application.StartupPath, config.Game.OriginalExe); - var targetExeName = Path.GetFileNameWithoutExtension(config.Game.OriginalExe) + "_o" + Path.GetExtension(config.Game.OriginalExe); - var targetExe = Path.Combine(Application.StartupPath, targetExeName); - - return (originalExe, targetExe); - } - - public (string originalExe, string targetExe) GetExePathsPublic() => GetExePaths(); - - public string GetDefaultLaunchExePath() - { - var (originalExe, targetExe) = GetExePaths(); - return IsReplaced() && File.Exists(targetExe) ? targetExe : originalExe; - } - - private string GetLauncherPath() - { - return Path.Combine(Application.StartupPath, $"{LAUNCHER_NAME}.exe"); - } - - private bool FilesEquals(string path1, string path2) - { - var fileInfo1 = new FileInfo(path1); - var fileInfo2 = new FileInfo(path2); - - if (fileInfo1.Length != fileInfo2.Length) - return false; - - // TODO compare checksums - - return true; - } - - public bool IsReplaced() - { - var (originalExe, targetExe) = GetExePaths(); - - if (!File.Exists(originalExe) || !File.Exists(targetExe)) - return false; - - var launcherExe = GetLauncherPath(); - return FilesEquals(originalExe, launcherExe); - } - - public bool CanReplace() - { - var processName = System.Diagnostics.Process.GetCurrentProcess().ProcessName; - if (!processName.Equals(LAUNCHER_NAME, StringComparison.OrdinalIgnoreCase)) - return false; - - var (originalExe, _) = GetExePaths(); - return File.Exists(originalExe); - } - - public void Replace() - { - if (!CanReplace()) - return; - - var (originalExe, targetExe) = GetExePaths(); - File.Move(originalExe, targetExe, true); - - try - { - File.Copy(GetLauncherPath(), originalExe, true); - MessageBox.Show("Replacement done.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); - } - catch (Exception ex) - { - MessageBox.Show(ex.Message, "Warning: Replacement partially failed", MessageBoxButtons.OK, MessageBoxIcon.Warning); - } - } -} diff --git a/Configuration/ConfigModel.cs b/Configuration/ConfigModel.cs deleted file mode 100644 index 599dd89..0000000 --- a/Configuration/ConfigModel.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Newtonsoft.Json; - -namespace MulderConfig.Configuration; - -public class ConfigModel -{ - public required Game Game { get; set; } - public List? Addons { get; set; } - public required List OptionGroups { get; set; } - public required ActionRoot Actions { get; set; } -} - -public class Game -{ - public required string Title { get; set; } - public required string OriginalExe { get; set; } -} - -public class Addon -{ - public required string Title { get; set; } - public int SteamId { get; set; } -} - -public class OptionGroup -{ - public required string Name { get; set; } - public required string Type { get; set; } // "radioGroup" | "checkboxGroup" - public List? Radios { get; set; } - public List? Checkboxes { get; set; } -} - -public class Radio -{ - public required string Value { get; set; } - public List? DisabledWhen { get; set; } -} - -public class Checkbox -{ - public required string Value { get; set; } - public List? DisabledWhen { get; set; } -} - -public class WhenGroup : Dictionary -{ -} - -public class ActionRoot -{ - public List Launch { get; set; } = new(); - public List Operations { get; set; } = new(); -} - -public class LaunchAction -{ - public List? When { get; set; } - - // Atomic override: if present, it defines both exe name + working directory - public ExecSpec? Exec { get; set; } - - // Cumulative: appended to the final args in JSON order - public List? Args { get; set; } -} - -public class ExecSpec -{ - public required string Name { get; set; } - public required string WorkDir { get; set; } - public bool? Wait { get; set; } = false; -} - -public class OperationAction -{ - public List? When { get; set; } - public required string Operation { get; set; } - - public string? Source { get; set; } - public string? Target { get; set; } - - public List? Files { get; set; } - public string? Pattern { get; set; } - public string? Search { get; set; } - public string? Replacement { get; set; } -} diff --git a/Configuration/ConfigProvider.cs b/Configuration/ConfigProvider.cs deleted file mode 100644 index f7f8d85..0000000 --- a/Configuration/ConfigProvider.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Newtonsoft.Json; - -namespace MulderConfig.Configuration; - -public class ConfigProvider -{ - private const string FILENAME = "MulderConfig.json"; - - public static ConfigModel GetConfig() - { - string configPath = Path.Combine(Application.StartupPath, FILENAME); - - if (!File.Exists(configPath)) - throw new FileNotFoundException($"The file '{FILENAME}' does not exist.", configPath); - - try - { - string json = File.ReadAllText(configPath); - return JsonConvert.DeserializeObject(json) ?? throw new InvalidDataException($"The file '{FILENAME}' is empty or invalid."); - } - catch (JsonException) - { - throw new Exception($"The file '{FILENAME}' is an invalid json."); - } - } -} diff --git a/Configuration/ConfigValidator.cs b/Configuration/ConfigValidator.cs deleted file mode 100644 index 5c1d8e9..0000000 --- a/Configuration/ConfigValidator.cs +++ /dev/null @@ -1,161 +0,0 @@ -namespace MulderConfig.Configuration; - -public class ConfigValidator -{ - public static bool IsValid(ConfigModel config) - { - if (config == null) - return false; - - if (config.Game == null) - return false; - - if (string.IsNullOrWhiteSpace(config.Game.Title)) - return false; - - if (string.IsNullOrWhiteSpace(config.Game.OriginalExe)) - return false; - - // addons is optional - if (config.Addons != null) - { - if (config.Addons.Any(a => a == null || string.IsNullOrWhiteSpace(a.Title))) - return false; - } - - if (config.OptionGroups == null) - return false; - - if (config.Actions == null) - return false; - - // actions.launch and actions.operations are optional; if missing/null they are treated as empty lists. - // However, having no actions at all makes no sense: require at least one action. - var launchCount = config.Actions.Launch?.Count ?? 0; - var operationsCount = config.Actions.Operations?.Count ?? 0; - if (launchCount == 0 && operationsCount == 0) - return false; - - var groupNames = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var group in config.OptionGroups) - { - if (group == null) - return false; - - if (string.IsNullOrWhiteSpace(group.Name)) - return false; - - if (!groupNames.Add(group.Name)) - return false; - - if (group.Type != "radioGroup" && group.Type != "checkboxGroup") - return false; - - if (group.Type == "radioGroup") - { - if (group.Radios == null || group.Radios.Count == 0) - return false; - - var values = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var r in group.Radios) - { - if (r == null || string.IsNullOrWhiteSpace(r.Value)) - return false; - - if (!values.Add(r.Value)) - return false; - } - } - - if (group.Type == "checkboxGroup") - { - if (group.Checkboxes == null || group.Checkboxes.Count == 0) - return false; - - var values = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var c in group.Checkboxes) - { - if (c == null || string.IsNullOrWhiteSpace(c.Value)) - return false; - - if (!values.Add(c.Value)) - return false; - } - } - } - - foreach (var rule in config.Actions.Launch ?? Enumerable.Empty()) - { - if (rule == null) - return false; - - if (rule.Exec != null) - { - if (string.IsNullOrWhiteSpace(rule.Exec.Name)) - return false; - - if (string.IsNullOrWhiteSpace(rule.Exec.WorkDir)) - return false; - } - - if (rule.Args != null && rule.Args.Any(a => a == null)) - return false; - } - - foreach (var op in config.Actions.Operations ?? Enumerable.Empty()) - { - if (op == null) - return false; - - if (string.IsNullOrWhiteSpace(op.Operation)) - return false; - - var operation = op.Operation.Trim().ToLowerInvariant(); - - if (operation is "rename" or "move" or "copy") - { - if (string.IsNullOrWhiteSpace(op.Source) || string.IsNullOrWhiteSpace(op.Target)) - return false; - } - - if (operation is "delete") - { - if (string.IsNullOrWhiteSpace(op.Source)) - return false; - } - - if (operation is "setreadonly" or "removereadonly") - { - if (op.Files == null || op.Files.Count == 0) - return false; - } - - if (operation is "replaceline" or "removeline") - { - if (op.Files == null || op.Files.Count == 0) - return false; - - if (string.IsNullOrWhiteSpace(op.Pattern)) - return false; - - if (operation == "replaceline" && op.Replacement == null) - return false; - } - - if (operation is "replacetext") - { - if (op.Files == null || op.Files.Count == 0) - return false; - - if (string.IsNullOrWhiteSpace(op.Search)) - return false; - - if (op.Replacement == null) - return false; - } - } - - return true; - } -} diff --git a/ISelectionProvider.cs b/ISelectionProvider.cs deleted file mode 100644 index 3206ebc..0000000 --- a/ISelectionProvider.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MulderConfig; - -public interface ISelectionProvider -{ - string GetTitle(); // GameTitle or AddonTitle - Dictionary GetChoices(); -} diff --git a/Logic/WhenResolver.cs b/Logic/WhenResolver.cs deleted file mode 100644 index 23694f6..0000000 --- a/Logic/WhenResolver.cs +++ /dev/null @@ -1,111 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig.Logic; - - -public static class WhenResolver -{ - public static bool Match(List groups, IReadOnlyDictionary selected) - { - if (groups == null || groups.Count == 0) - return true; // Empty `when` means "always apply". - - return groups.Any(group => IsGroupMatch(group, selected)); - } - - private static bool IsGroupMatch(WhenGroup group, IReadOnlyDictionary selected) - { - foreach (var kvp in group) - { - var rawKey = kvp.Key; - var expected = kvp.Value ?? string.Empty; - - var (op, key) = ParseKey(rawKey); - - bool hasKey = selected.TryGetValue(key, out var selectedValue); - - // Special case: expected is "" and operator is Equals => "nothing selected". - // This is mainly for checkbox groups where an empty list means "no selection". - if (op == ConditionOperator.Equals && string.IsNullOrEmpty(expected)) - { - if (IsNullOrEmptySelection(selectedValue)) - continue; - - return false; - } - - // Missing key (or null value): - // - Equals / Contains => cannot match - // - NotEquals / NotContains => considered true ("different" / "does not contain") - if (!hasKey || selectedValue == null) - { - if (op == ConditionOperator.NotEquals || op == ConditionOperator.NotContains) - continue; - - return false; - } - - if (!IsValueMatch(selectedValue, expected, op)) - return false; - } - - return true; - } - - private static bool IsNullOrEmptySelection(object? selectedValue) - { - if (selectedValue == null) - return true; - if (selectedValue is List list && list.Count == 0) - return true; - return false; - } - - private static bool IsValueMatch(object selectedValue, string expected, ConditionOperator op) - { - if (selectedValue is List list) - { - return op switch - { - ConditionOperator.Contains => list.Any(v => ContainsIgnoreCase(v, expected)), - ConditionOperator.NotContains => !list.Any(v => ContainsIgnoreCase(v, expected)), - ConditionOperator.NotEquals => !list.Contains(expected, StringComparer.OrdinalIgnoreCase), - _ => list.Contains(expected, StringComparer.OrdinalIgnoreCase), - }; - } - - var actual = selectedValue.ToString() ?? string.Empty; - return op switch - { - ConditionOperator.Contains => ContainsIgnoreCase(actual, expected), - ConditionOperator.NotContains => !ContainsIgnoreCase(actual, expected), - ConditionOperator.NotEquals => !EqualsIgnoreCase(actual, expected), - _ => EqualsIgnoreCase(actual, expected), - }; - } - - private static (ConditionOperator op, string key) ParseKey(string rawKey) - { - if (rawKey.StartsWith("!*")) - return (ConditionOperator.NotContains, rawKey.TrimStart('!', '*')); - if (rawKey.StartsWith("*")) - return (ConditionOperator.Contains, rawKey.TrimStart('*')); - if (rawKey.StartsWith("!")) - return (ConditionOperator.NotEquals, rawKey.TrimStart('!')); - return (ConditionOperator.Equals, rawKey); - } - - private static bool ContainsIgnoreCase(string actual, string expected) => - actual.IndexOf(expected, StringComparison.OrdinalIgnoreCase) >= 0; - - private static bool EqualsIgnoreCase(string a, string b) => - string.Equals(a, b, StringComparison.OrdinalIgnoreCase); - - private enum ConditionOperator - { - Equals, - NotEquals, - Contains, - NotContains, - } -} diff --git a/ModeDetector.cs b/ModeDetector.cs deleted file mode 100644 index c3e9865..0000000 --- a/ModeDetector.cs +++ /dev/null @@ -1,24 +0,0 @@ -using MulderConfig.Configuration; -using System.Diagnostics; - -namespace MulderConfig; - -public sealed class ModeDetector(ConfigModel config, string[] args) -{ - public bool IsLaunchMode() - { - if (args.Any(a => a.Equals("-launch", StringComparison.OrdinalIgnoreCase))) { - return true; // useful for local test - } - - var originalExeName = Path.GetFileName(config.Game.OriginalExe); - var processExeName = Process.GetCurrentProcess().ProcessName + ".exe"; - - return originalExeName.Equals(processExeName, StringComparison.OrdinalIgnoreCase); - } - - public bool IsApplyMode() - { - return args.Any(a => a.Equals("-apply", StringComparison.OrdinalIgnoreCase)); - } -} diff --git a/MulderConfig.csproj b/MulderConfig.csproj deleted file mode 100644 index 7d4b5da..0000000 --- a/MulderConfig.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - WinExe - net8.0-windows - enable - true - enable - - favicon.ico - $(DefaultItemExcludes);Tests\** - - - - - - - - - - True - \ - - - diff --git a/MulderConfig.sln b/MulderConfig.sln deleted file mode 100644 index 7374c85..0000000 --- a/MulderConfig.sln +++ /dev/null @@ -1,28 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.12.35514.174 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MulderConfig", "MulderConfig.csproj", "{756D4323-BB54-47B1-8923-4738973DB42A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MulderConfigTests", "Tests\MulderConfigTests.csproj", "{57E5364F-99FE-4DC3-B2F1-2CEB7769160D}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {756D4323-BB54-47B1-8923-4738973DB42A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {756D4323-BB54-47B1-8923-4738973DB42A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {756D4323-BB54-47B1-8923-4738973DB42A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {756D4323-BB54-47B1-8923-4738973DB42A}.Release|Any CPU.Build.0 = Release|Any CPU - {57E5364F-99FE-4DC3-B2F1-2CEB7769160D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {57E5364F-99FE-4DC3-B2F1-2CEB7769160D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {57E5364F-99FE-4DC3-B2F1-2CEB7769160D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {57E5364F-99FE-4DC3-B2F1-2CEB7769160D}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/Program.cs b/Program.cs deleted file mode 100644 index 09b25fa..0000000 --- a/Program.cs +++ /dev/null @@ -1,87 +0,0 @@ -using MulderConfig.Actions; -using MulderConfig.Configuration; -using MulderConfig.Save; -using MulderConfig.Apply; -using MulderConfig.UI; -using MulderConfig; - -namespace MulderConfig; - -internal static class Program -{ - /// - /// The main entry point for the application. - /// - [STAThread] - static void Main(string[] args) - { - // Read and validate config - ConfigModel config; - try - { - config = ConfigProvider.GetConfig(); - } - catch (Exception ex) - { - MessageBox.Show($"Error loading configuration:\n{ex.Message}"); - return; - } - if (!ConfigValidator.IsValid(config)) - { - MessageBox.Show("Error loading configuration:\nThe file 'MulderConfig.json' has invalid data."); - return; - } - - // Initialize core components - var exeReplacer = new ExeReplacer(config); - var fileOperationManager = new FileOperationManager(); - var modeDetector = new ModeDetector(config, args); - var saveLoader = new SaveLoader(); - var saveSaver = new SaveSaver(saveLoader); - var steamAddonHandler = new SteamAddonHandler(config, args); - var applyManager = new ApplyManager(config, exeReplacer, fileOperationManager); - - // Handle Steam Addons - var steamAddonId = steamAddonHandler.ResolveAddonId(); - var title = steamAddonHandler.ResolveAddonTitle(steamAddonId) ?? config.Game.Title; - - // Select current addon save - saveLoader.LoadAll(); - var save = saveLoader.Load(title); - if (!SaveValidator.IsValid(config, save)) - { - MessageBox.Show($"Invalid configuration for {title}.\nThe save file may be corrupted (delete MulderConfig.save.json)."); - return; - } - - // Run App - if (modeDetector.IsApplyMode()) - { - applyManager.Apply(title, save); - } - else if (modeDetector.IsLaunchMode()) - { - var launchManager = new LaunchManager(config, title, save); - launchManager.Launch(); - } - else - { - // Initialize UI components - var formSelectionProvider = new FormSelectionProvider(config); - var formValidator = new FormValidator(config, formSelectionProvider); - var formBuilder = new FormBuilder(formValidator, formSelectionProvider); - - // Normal UI mode - ApplicationConfiguration.Initialize(); - Application.Run(new Form1( - title, - config, - applyManager, - formBuilder, - formValidator, - formSelectionProvider, - saveLoader, - saveSaver)); - } - } -} diff --git a/Save/SaveLoader.cs b/Save/SaveLoader.cs deleted file mode 100644 index 5db3c7a..0000000 --- a/Save/SaveLoader.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace MulderConfig.Save; - -public sealed class SaveLoader -{ - private Dictionary>? _saves; - - public Dictionary> LoadAll() - { - if (_saves != null) - return _saves; - - var savePath = GetSavePath(); - - if (!File.Exists(savePath)) - { - _saves = new Dictionary>(StringComparer.OrdinalIgnoreCase); - return _saves; - } - - try - { - var json = File.ReadAllText(savePath); - _saves = JsonConvert.DeserializeObject>>(json) - ?? new Dictionary>(StringComparer.OrdinalIgnoreCase); - } - catch - { - _saves = new Dictionary>(StringComparer.OrdinalIgnoreCase); - } - - return _saves; - } - - public Dictionary Load(string addon) - { - if (_saves == null) - throw new InvalidOperationException("LoadAll must be called before Load."); - - if (!_saves.TryGetValue(addon, out var save)) - return new Dictionary(StringComparer.OrdinalIgnoreCase); - - var normalized = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var entry in save) - { - normalized[entry.Key] = NormalizeValue(entry.Value); - } - - return normalized; - } - - internal void SetCache(Dictionary> newCache) - { - _saves = newCache; - } - - private static string GetSavePath() - { - return Path.Combine(Application.StartupPath, "MulderConfig.save.json"); - } - - private static object? NormalizeValue(object? value) - { - if (value is JArray array) - { - return array.Values().Where(v => v != null).Select(v => v!).ToList(); - } - - if (value is JValue jValue) - { - return jValue.Type == JTokenType.Null ? null : jValue.ToObject(); - } - - if (value is IList list) - { - return list.Select(v => v?.ToString()).Where(v => v != null).Select(v => v!).ToList(); - } - - return value; - } -} diff --git a/Save/SaveModel.cs b/Save/SaveModel.cs deleted file mode 100644 index 6516a45..0000000 --- a/Save/SaveModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MulderConfig.Save; - -public class SaveModel -{ - public Dictionary> AddonSelections { get; set; } - = new Dictionary>(StringComparer.OrdinalIgnoreCase); -} diff --git a/Save/SaveSaver.cs b/Save/SaveSaver.cs deleted file mode 100644 index dca23ab..0000000 --- a/Save/SaveSaver.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Newtonsoft.Json; - -namespace MulderConfig.Save; - -public sealed class SaveSaver(SaveLoader loader) -{ - public void SaveChoices(string addon, Dictionary choices) - { - var saves = loader.LoadAll(); - - saves[addon] = new Dictionary(choices, StringComparer.OrdinalIgnoreCase); - - var json = JsonConvert.SerializeObject(saves, Formatting.Indented); - File.WriteAllText(GetSavePath(), json); - - // keep loader cache in sync - loader.SetCache(saves); - } - - private static string GetSavePath() - { - return Path.Combine(Application.StartupPath, "MulderConfig.save.json"); - } -} diff --git a/Save/SaveValidator.cs b/Save/SaveValidator.cs deleted file mode 100644 index ed87161..0000000 --- a/Save/SaveValidator.cs +++ /dev/null @@ -1,56 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig.Save; - -public static class SaveValidator -{ - public static bool IsValid(ConfigModel config, Dictionary save) - { - foreach (var entry in save) - { - var group = config.OptionGroups.FirstOrDefault(g => - g.Name.Equals(entry.Key, StringComparison.OrdinalIgnoreCase)); - - if (group == null) - return false; - - if (group.Type == "radioGroup") - { - if (entry.Value is not string selected) - return false; - - if (group.Radios == null) - return false; - - var exists = group.Radios.Any(r => - r.Value.Equals(selected, StringComparison.OrdinalIgnoreCase)); - - if (!exists) - return false; - } - else if (group.Type == "checkboxGroup") - { - if (group.Checkboxes == null) - return false; - - if (entry.Value is IEnumerable values && entry.Value is not string) - { - foreach (var value in values) - { - var exists = group.Checkboxes.Any(c => - c.Value.Equals(value, StringComparison.OrdinalIgnoreCase)); - - if (!exists) - return false; - } - } - else - { - return false; - } - } - } - - return true; - } -} diff --git a/SteamAddonHandler.cs b/SteamAddonHandler.cs deleted file mode 100644 index 0eb78c4..0000000 --- a/SteamAddonHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig; - -public sealed class SteamAddonHandler(ConfigModel config, string[] args) -{ - public int? ResolveAddonId() - { - for (int i = 0; i < args.Length - 1; i++) - { - if (args[i].Equals("-addon", StringComparison.OrdinalIgnoreCase) - && int.TryParse(args[i + 1], out int addonId)) - { - return addonId; - } - } - - return null; - } - - public string? ResolveAddonTitle(int? steamAddonId) - { - if (steamAddonId is null) - return null; - - var addons = config.Addons; - if (addons is null || addons.Count == 0) - return null; - - return addons.FirstOrDefault(a => a.SteamId == steamAddonId)?.Title - ?? addons[0].Title; - } -} diff --git a/UI/Form1.Designer.cs b/UI/Form1.Designer.cs deleted file mode 100644 index 785fd0c..0000000 --- a/UI/Form1.Designer.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace MulderConfig.UI -{ - partial class Form1 - { - private System.ComponentModel.IContainer components = null; - - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Windows Form Designer generated code - - private void InitializeComponent() - { - comboBoxTitle = new ComboBox(); - panelOptions = new Panel(); - btnApply = new Button(); - btnSave = new Button(); - SuspendLayout(); - // - // comboBoxTitle - // - comboBoxTitle.BackColor = Color.FromArgb(89, 101, 119); - comboBoxTitle.Font = new Font("Segoe UI", 9F); - comboBoxTitle.ForeColor = SystemColors.HighlightText; - comboBoxTitle.FormattingEnabled = true; - comboBoxTitle.Location = new Point(12, 12); - comboBoxTitle.Name = "comboBoxTitle"; - comboBoxTitle.Size = new Size(418, 23); - comboBoxTitle.TabIndex = 0; - comboBoxTitle.SelectedIndexChanged += comboBoxTitle_SelectedIndexChanged; - // - // panelOptions - // - panelOptions.AutoSize = true; - panelOptions.BorderStyle = BorderStyle.FixedSingle; - panelOptions.ForeColor = SystemColors.Control; - panelOptions.Location = new Point(12, 53); - panelOptions.Name = "panelOptions"; - panelOptions.Padding = new Padding(20); - panelOptions.Size = new Size(600, 60); - panelOptions.TabIndex = 1; - // - // btnApply - // - btnApply.Location = new Point(537, 12); - btnApply.Name = "btnApply"; - btnApply.Size = new Size(75, 23); - btnApply.TabIndex = 2; - btnApply.Text = "Apply"; - btnApply.UseVisualStyleBackColor = true; - btnApply.Click += btnApply_Click; - // - // btnSave - // - btnSave.Location = new Point(436, 12); - btnSave.Name = "btnSave"; - btnSave.Size = new Size(95, 23); - btnSave.TabIndex = 3; - btnSave.Text = "Save Config"; - btnSave.UseVisualStyleBackColor = true; - btnSave.Click += btnSave_Click; - // - // Form1 - // - AutoScaleDimensions = new SizeF(7F, 15F); - AutoScaleMode = AutoScaleMode.Font; - AutoSize = true; - BackColor = Color.FromArgb(35, 35, 45); - ClientSize = new Size(624, 341); - Controls.Add(btnSave); - Controls.Add(btnApply); - Controls.Add(panelOptions); - Controls.Add(comboBoxTitle); - Name = "Form1"; - Padding = new Padding(0, 0, 0, 20); - StartPosition = FormStartPosition.CenterScreen; - Text = "Form1"; - Load += Form1_Load; - ResumeLayout(false); - PerformLayout(); - } - - #endregion - - private ComboBox comboBoxTitle; - private Panel panelOptions; - private Button btnApply; - private Button btnSave; - } -} diff --git a/UI/Form1.cs b/UI/Form1.cs deleted file mode 100644 index 00f0b55..0000000 --- a/UI/Form1.cs +++ /dev/null @@ -1,98 +0,0 @@ -using MulderConfig.Save; -using MulderConfig.Configuration; -using MulderConfig.Apply; - -namespace MulderConfig.UI -{ - public partial class Form1 : Form - { - private readonly string _title; - private readonly ConfigModel _config; - private readonly ApplyManager _applyManager; - private readonly FormBuilder _formBuilder; - private readonly FormValidator _formValidator; - private readonly FormSelectionProvider _formSelectionProvider; - private readonly FormController _formController; - private readonly SaveLoader _saveLoader; - private readonly SaveSaver _saveSaver; - private bool _isInitializing; - - public Form1( - string title, - ConfigModel config, - ApplyManager applyManager, - FormBuilder formBuilder, - FormValidator formValidator, - FormSelectionProvider formSelectionProvider, - SaveLoader saveLoader, - SaveSaver saveSaver) - { - _title = title; - _config = config; - _applyManager = applyManager; - _formBuilder = formBuilder; - _formValidator = formValidator; - _formSelectionProvider = formSelectionProvider; - _saveLoader = saveLoader; - _saveSaver = saveSaver; - - InitializeComponent(); - - _formController = new FormController(_formSelectionProvider, _formValidator, btnApply, btnSave); - } - - private void Form1_Load(object sender, EventArgs e) - { - _isInitializing = true; - - Text = _config.Game.Title; - _formBuilder.BuildComboBox(_config, comboBoxTitle); - - var initialIndex = comboBoxTitle.Items.IndexOf(_title); - if (initialIndex >= 0) - { - comboBoxTitle.SelectedIndex = initialIndex; - } - - _formSelectionProvider.SetTitle(comboBoxTitle.SelectedItem?.ToString() ?? "default"); - - _formBuilder.BuildForm(_config, panelOptions, _formController.UpdateButtons); - _formController.LoadSavedChoices(_saveLoader); - - _isInitializing = false; - } - - private void comboBoxTitle_SelectedIndexChanged(object sender, EventArgs e) - { - if (_isInitializing) - return; - - _formSelectionProvider.SetTitle(comboBoxTitle.SelectedItem?.ToString() ?? "default"); - _formController.LoadSavedChoices(_saveLoader); - } - - private void btnApply_Click(object sender, EventArgs e) - { - if (!_formValidator.IsValid()) - { - MessageBox.Show("Form is invalid", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - return; - } - - _applyManager.Apply(_formSelectionProvider); - MessageBox.Show("Done.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); - } - - private void btnSave_Click(object sender, EventArgs e) - { - if (!_formValidator.IsValid()) - { - MessageBox.Show("Form is invalid", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - return; - } - - _saveSaver.SaveChoices(_formSelectionProvider.GetTitle(), _formSelectionProvider.GetChoices()); - MessageBox.Show($"Configuration saved for {_formSelectionProvider.GetTitle()}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); - } - } -} diff --git a/UI/Form1.resx b/UI/Form1.resx deleted file mode 100644 index cae2060..0000000 --- a/UI/Form1.resx +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - diff --git a/UI/FormBuilder.cs b/UI/FormBuilder.cs deleted file mode 100644 index ef164f7..0000000 --- a/UI/FormBuilder.cs +++ /dev/null @@ -1,96 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig.UI; - -public class FormBuilder(FormValidator formValidator, FormSelectionProvider formSelectionProvider) -{ - public void BuildComboBox(ConfigModel config, ComboBox comboBox) - { - comboBox.Items.Clear(); - comboBox.Items.Add(config.Game.Title); - - if (config.Addons == null || config.Addons.Count == 0) { - comboBox.Enabled = false; - return; - } - - foreach (var addon in config.Addons) - { - comboBox.Items.Add(addon.Title); - } - } - - public void BuildForm(ConfigModel config, Panel panelOptions, Action updateButtons) - { - panelOptions.Controls.Clear(); - - int y = 10; - - foreach (var group in config.OptionGroups) - { - var groupBox = new GroupBox - { - Text = group.Name, - Left = 10, - Top = y, - Width = panelOptions.ClientSize.Width - 40, - Height = 10, - AutoSize = true, - ForeColor = Color.White, - }; - - panelOptions.Controls.Add(groupBox); - - int innerY = 20; - - if (group.Type == "radioGroup" && group.Radios != null) - { - foreach (var radioChoice in group.Radios) - { - var radioButton = new RadioButton - { - Text = radioChoice.Value, - Left = 10, - Top = innerY, - AutoSize = true - }; - - radioButton.CheckedChanged += (s, e) => - { - formValidator.ApplyWhenConstraints(); - updateButtons(); - }; - groupBox.Controls.Add(radioButton); - formSelectionProvider.AddRadioButton(group.Name, radioButton, radioChoice.Value); - innerY += 25; - } - } - else if (group.Type == "checkboxGroup" && group.Checkboxes != null) - { - foreach (var checkItem in group.Checkboxes) - { - var checkBox = new CheckBox - { - Text = checkItem.Value, - Left = 10, - Top = innerY, - AutoSize = true, - Tag = checkItem.Value - }; - - checkBox.CheckedChanged += (s, e) => - { - formValidator.ApplyWhenConstraints(); - updateButtons(); - }; - groupBox.Controls.Add(checkBox); - formSelectionProvider.AddCheckBox(checkBox, checkItem.Value); - innerY += 25; - } - } - - groupBox.Height = innerY + 10; - y += groupBox.Height + 8; - } - } -} diff --git a/UI/FormController.cs b/UI/FormController.cs deleted file mode 100644 index 8a264da..0000000 --- a/UI/FormController.cs +++ /dev/null @@ -1,73 +0,0 @@ -using MulderConfig.Save; - -namespace MulderConfig.UI; - -public sealed class FormController( - FormSelectionProvider selectionProvider, - FormValidator validator, - Button btnApply, - Button btnSave) -{ - public void LoadSavedChoices(SaveLoader saveLoader) - { - var saved = saveLoader.Load(selectionProvider.GetTitle()); - ResetChoices(); - ApplyChoices(saved); - - validator.ApplyWhenConstraints(); - UpdateButtons(); - } - - private void ResetChoices() - { - foreach (var grp in selectionProvider.RadioButtons) - { - foreach (var rb in grp.Value.Values) - { - rb.Enabled = true; - rb.Checked = false; - } - } - - foreach (var cb in selectionProvider.CheckBoxes.Values) - { - cb.Enabled = true; - cb.Checked = false; - } - } - - private void ApplyChoices(Dictionary savedChoices) - { - foreach (var entry in savedChoices) - { - if (entry.Value is IEnumerable values && entry.Value is not string) - { - foreach (var value in values) - { - if (selectionProvider.CheckBoxes.TryGetValue(value, out var cb)) - { - cb.Checked = true; - } - } - - continue; - } - - if (entry.Value is string selected) - { - var groupName = entry.Key; - if (selectionProvider.RadioButtons.TryGetValue(groupName, out var radios) && radios.TryGetValue(selected, out var rb)) - { - rb.Checked = true; - } - } - } - } - - public void UpdateButtons() - { - var isValid = validator.IsValid(); - btnApply.Enabled = isValid; - btnSave.Enabled = isValid; - } -} diff --git a/UI/FormSelectionProvider.cs b/UI/FormSelectionProvider.cs deleted file mode 100644 index ea8e79c..0000000 --- a/UI/FormSelectionProvider.cs +++ /dev/null @@ -1,59 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig.UI; - -public class FormSelectionProvider(ConfigModel config) : ISelectionProvider -{ - private string title = "default"; - public readonly Dictionary> RadioButtons = []; - public readonly Dictionary CheckBoxes = []; - - internal void AddRadioButton(string groupName, RadioButton radioButton, string value) - { - if (!RadioButtons.ContainsKey(groupName)) - RadioButtons[groupName] = []; - - RadioButtons[groupName][value] = radioButton; - } - - internal void AddCheckBox(CheckBox checkBox, string value) - { - CheckBoxes[value] = checkBox; - } - - public void SetTitle(string title) - { - this.title = title; - } - - public string GetTitle() - { - return title; - } - - public Dictionary GetChoices() - { - var choices = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var group in config.OptionGroups) - { - if (group.Type == "radioGroup" && RadioButtons.TryGetValue(group.Name, out var radios)) - { - var selectedRadio = radios.Values.FirstOrDefault(radio => radio.Checked); - if (selectedRadio != null) - choices[group.Name] = selectedRadio.Text; - } - else if (group.Type == "checkboxGroup" && group.Checkboxes != null) - { - var selectedCheckboxes = group.Checkboxes - .Where(checkbox => CheckBoxes.TryGetValue(checkbox.Value, out var checkbox2) && checkbox2.Checked) - .Select(checkbox => checkbox.Value) - .ToList(); - - choices[group.Name] = selectedCheckboxes; - } - } - - return choices; - } -} diff --git a/UI/FormValidator.cs b/UI/FormValidator.cs deleted file mode 100644 index 11d58c2..0000000 --- a/UI/FormValidator.cs +++ /dev/null @@ -1,72 +0,0 @@ -using MulderConfig.Logic; -using MulderConfig.Configuration; - -namespace MulderConfig.UI -{ - public class FormValidator(ConfigModel config, FormSelectionProvider formSelectionProvider) - { - public bool IsValid() - { - if (config.OptionGroups == null) - return false; - - foreach (var group in config.OptionGroups) - { - if (group.Type != "radioGroup") - continue; - - if (!formSelectionProvider.RadioButtons.TryGetValue(group.Name, out var radios)) - continue; - - if (!radios.Values.Any(rb => rb.Enabled && rb.Checked)) - return false; - } - - return true; - } - - public void ApplyWhenConstraints() - { - var selected = formSelectionProvider.GetChoices(); - selected["Title"] = formSelectionProvider.GetTitle(); - - foreach (var group in config.OptionGroups) - { - if (group.Type == "radioGroup" - && group.Radios != null - && formSelectionProvider.RadioButtons.TryGetValue(group.Name, out var radios)) - { - foreach (var radioRow in group.Radios) - { - if (radioRow.DisabledWhen == null) - continue; - - if (radios.TryGetValue(radioRow.Value, out var radioButton)) - { - var disable = WhenResolver.Match(radioRow.DisabledWhen, selected); - radioButton.Enabled = !disable; - if (disable) - radioButton.Checked = false; - } - } - } - else if (group.Type == "checkboxGroup" && group.Checkboxes != null) - { - foreach (var checkboxRow in group.Checkboxes) - { - if (checkboxRow.DisabledWhen == null) - continue; - - if (formSelectionProvider.CheckBoxes.TryGetValue(checkboxRow.Value, out var checkBox)) - { - var disable = WhenResolver.Match(checkboxRow.DisabledWhen, selected); - checkBox.Enabled = !disable; - if (disable) - checkBox.Checked = false; - } - } - } - } - } - } -} diff --git a/tests/.gitignore b/tests/.gitignore deleted file mode 100644 index cbbd0b5..0000000 --- a/tests/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -bin/ -obj/ \ No newline at end of file diff --git a/tests/Actions/FileOperationManagerTests.cs b/tests/Actions/FileOperationManagerTests.cs deleted file mode 100644 index e2e356e..0000000 --- a/tests/Actions/FileOperationManagerTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using System.IO; -using MulderConfig.Actions; -using MulderConfig.Configuration; -using Xunit; - -namespace MulderConfigTests.Actions; - -public class FileOperationManagerTests -{ - [Fact] - public void ExecuteOperations_Move_MovesDirectory() - { - var root = Path.Combine(Path.GetTempPath(), "MulderConfigTests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - - var sourceDir = Path.Combine(root, "srcDir"); - var targetDir = Path.Combine(root, "dstDir"); - Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(sourceDir, "a.txt"), "hello"); - - try - { - var mgr = new FileOperationManager(); - mgr.ExecuteOperations( - new List - { - new() - { - Operation = "move", - Source = sourceDir, - Target = targetDir - } - }, - selected: new Dictionary()); - - Assert.False(Directory.Exists(sourceDir)); - Assert.True(Directory.Exists(targetDir)); - Assert.True(File.Exists(Path.Combine(targetDir, "a.txt"))); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - } -} diff --git a/tests/Actions/LaunchManagerTests.cs b/tests/Actions/LaunchManagerTests.cs deleted file mode 100644 index 50d9570..0000000 --- a/tests/Actions/LaunchManagerTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using MulderConfig; -using MulderConfig.Actions; -using MulderConfig.Configuration; -using Newtonsoft.Json; -using Xunit; - -namespace MulderConfigTests.Actions; - -public class LaunchManagerTests -{ - private static ConfigModel ParseConfig(string json) - => JsonConvert.DeserializeObject(json)!; - - [Fact] - public void ResolveLaunch_ReturnsDefaults_WhenNoRuleMatches() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { - ""launch"": [ - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9.exe"", ""workDir"": "".\\"" }, - ""args"": [""-a""] - } - ], - ""operations"": [] - } - }"; - - var config = ParseConfig(json); - - var manager = new LaunchManager( - config, - title: "default", - choices: new Dictionary { ["Renderer"] = "DX11" }); - - var (exePath, workDir, _, args) = manager.ResolveLaunch(); - - Assert.Equal(System.IO.Path.Combine(System.Windows.Forms.Application.StartupPath, "Game.exe"), exePath); - Assert.Equal(System.Windows.Forms.Application.StartupPath, workDir); - Assert.Equal(string.Empty, args); - } - - [Fact] - public void ResolveLaunch_AppendsArgs_And_LastExecWins() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { - ""launch"": [ - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9.exe"", ""workDir"": "".\\"" }, - ""args"": [""-nosetup""] - }, - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9_alt.exe"", ""workDir"": ""C:\\tmp"" }, - ""args"": [""-novsync"", ""-borderless""] - } - ], - ""operations"": [] - } - }"; - - var config = ParseConfig(json); - - var manager = new LaunchManager( - config, - title: "default", - choices: new Dictionary { ["Renderer"] = "DX9" }); - - var (exePath, workDir, _, args) = manager.ResolveLaunch(); - - var expectedExePath = System.IO.Path.GetFullPath( - System.IO.Path.Combine(System.Windows.Forms.Application.StartupPath, "dx9_alt.exe")); - - Assert.Equal(expectedExePath, exePath); - Assert.Equal(System.IO.Path.GetFullPath("C:\\tmp"), workDir); - Assert.Equal("-nosetup -novsync -borderless", args); - } -} diff --git a/tests/Configuration/ConfigJsonTests.cs b/tests/Configuration/ConfigJsonTests.cs deleted file mode 100644 index 0eb38b7..0000000 --- a/tests/Configuration/ConfigJsonTests.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System.Collections.Generic; -using MulderConfig.Logic; -using MulderConfig.Configuration; -using Newtonsoft.Json; -using Xunit; - -namespace MulderConfigTests.Configuration; - -public class ConfigJsonTests -{ - private static ConfigModel ParseConfig(string json) - { - return JsonConvert.DeserializeObject(json)!; - } - - private static Dictionary Sel(params (string key, object val)[] items) - { - var d = new Dictionary(); - foreach (var (k, v) in items) d[k] = v; - return d; - } - - private static (string exe, string workDir, string args) ResolveLaunchLikeLaunchManager(ConfigModel config, Dictionary selected) - { - // This mirrors the intended semantics: - // - traverse actions.launch in JSON order - // - args are cumulative (append) - // - exec is atomic and last match wins - var exe = config.Game.OriginalExe; - var workDir = ".\\"; - var args = new List(); - - foreach (var rule in config.Actions.Launch) - { - if (!WhenResolver.Match(rule.When, selected)) - continue; - - if (rule.Exec != null) - { - exe = rule.Exec.Name; - workDir = rule.Exec.WorkDir; - } - - if (rule.Args != null) - args.AddRange(rule.Args); - } - - return (exe, workDir, string.Join(" ", args)); - } - - [Fact] - public void PartialJson_DeserializesLaunchAndOperations() - { - var json = @" - { - ""game"": { ""title"": ""Test Game"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { - ""launch"": [ - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9.exe"", ""workDir"": "".\\"" }, - ""args"": [""-a""] - } - ], - ""operations"": [ - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""operation"": ""rename"", - ""source"": ""a.dll"", - ""target"": ""b.dll"" - }, - { - ""operation"": ""replaceLine"", - ""files"": [""FalloutPrefs.ini""], - ""pattern"": ""^iSize W=.*$"", - ""replacement"": ""iSize W=1920"" - } - ] - } - }"; - - var config = ParseConfig(json); - - Assert.Equal("Test Game", config.Game.Title); - Assert.Equal("Game.exe", config.Game.OriginalExe); - - Assert.Single(config.Actions.Launch); - Assert.NotNull(config.Actions.Launch[0].Exec); - Assert.Equal("dx9.exe", config.Actions.Launch[0].Exec!.Name); - Assert.Equal(@".\", config.Actions.Launch[0].Exec!.WorkDir); - Assert.Equal(new[] { "-a" }, config.Actions.Launch[0].Args); - - Assert.Equal(2, config.Actions.Operations.Count); - Assert.Equal("rename", config.Actions.Operations[0].Operation); - Assert.Equal("a.dll", config.Actions.Operations[0].Source); - Assert.Equal("b.dll", config.Actions.Operations[0].Target); - - Assert.Equal("replaceLine", config.Actions.Operations[1].Operation); - Assert.Equal(new[] { "FalloutPrefs.ini" }, config.Actions.Operations[1].Files); - } - - [Fact] - public void LaunchRules_ArgsAppend_And_LastExecWins() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { - ""launch"": [ - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9.exe"", ""workDir"": "".\\"" }, - ""args"": [""-nosetup""] - }, - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9_alt.exe"", ""workDir"": ""C:\\tmp"" }, - ""args"": [""-novsync"", ""-borderless""] - } - ], - ""operations"": [] - } - }"; - - var config = ParseConfig(json); - var selected = Sel(("Renderer", "DX9")); - - var (exe, workDir, args) = ResolveLaunchLikeLaunchManager(config, selected); - - Assert.Equal("dx9_alt.exe", exe); - Assert.Equal(@"C:\tmp", workDir); - Assert.Equal("-nosetup -novsync -borderless", args); - } - - [Fact] - public void NullWhen_IsTreatedAsAlwaysApply() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { - ""launch"": [ { ""args"": [""-a""] } ], - ""operations"": [ { ""operation"": ""removeLine"", ""files"": [""a.ini""], ""pattern"": ""^x=.*$"" } ] - } - }"; - - var config = ParseConfig(json); - var selected = Sel(("Renderer", "Anything")); - - Assert.True(WhenResolver.Match(config.Actions.Launch[0].When, selected)); - Assert.True(WhenResolver.Match(config.Actions.Operations[0].When, selected)); - } - - [Fact] - public void MissingLaunchSection_DefaultsToEmpty_AndConfigIsValid() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""addons"": [ { ""title"": ""default"", ""steamId"": 1 } ], - ""optionGroups"": [], - ""actions"": { - ""operations"": [ { ""operation"": ""delete"", ""source"": ""tmp.txt"" } ] - } - }"; - - var config = ParseConfig(json); - - Assert.True(ConfigValidator.IsValid(config)); - Assert.NotNull(config.Actions.Launch); - Assert.Empty(config.Actions.Launch); - Assert.NotNull(config.Actions.Operations); - Assert.Single(config.Actions.Operations); - } - - [Fact] - public void MissingOperationsSection_DefaultsToEmpty_AndConfigIsValid() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""addons"": [ { ""title"": ""default"", ""steamId"": 1 } ], - ""optionGroups"": [], - ""actions"": { - ""launch"": [ { ""args"": [""-a""] } ] - } - }"; - - var config = ParseConfig(json); - - Assert.True(ConfigValidator.IsValid(config)); - Assert.NotNull(config.Actions.Operations); - Assert.Empty(config.Actions.Operations); - Assert.NotNull(config.Actions.Launch); - Assert.Single(config.Actions.Launch); - } - - [Fact] - public void NoActions_IsInvalid() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""addons"": [ { ""title"": ""default"", ""steamId"": 1 } ], - ""optionGroups"": [], - ""actions"": { } - }"; - - var config = ParseConfig(json); - Assert.False(ConfigValidator.IsValid(config)); - } - - [Fact] - public void EmptyLaunchAndOperations_IsInvalid() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""addons"": [ { ""title"": ""default"", ""steamId"": 1 } ], - ""optionGroups"": [], - ""actions"": { ""launch"": [], ""operations"": [] } - }"; - - var config = ParseConfig(json); - Assert.False(ConfigValidator.IsValid(config)); - } - - [Fact] - public void MissingAddons_IsValid() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { ""launch"": [ { ""args"": [""-a""] } ] } - }"; - - var config = ParseConfig(json); - Assert.True(ConfigValidator.IsValid(config)); - } -} diff --git a/tests/Configuration/ConfigValidatorTests.cs b/tests/Configuration/ConfigValidatorTests.cs deleted file mode 100644 index 6fffdb4..0000000 --- a/tests/Configuration/ConfigValidatorTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System.Collections.Generic; -using MulderConfig.Configuration; -using Xunit; - -namespace MulderConfigTests.Configuration; - -public class ConfigValidatorTests -{ - private static ConfigModel MinimalValidConfig() - { - return new ConfigModel - { - Game = new Game { Title = "Test", OriginalExe = "Game.exe" }, - Addons = new List { new() { Title = "default" } }, - OptionGroups = new List - { - new() - { - Name = "Renderer", - Type = "radioGroup", - Radios = new List { new() { Value = "DX9" } } - } - }, - Actions = new ActionRoot - { - Launch = new List - { - new() - { - Exec = new ExecSpec { Name = "Game.exe", WorkDir = ".\\" }, - Args = new List { "-a" } - } - }, - Operations = new List - { - new() - { - Operation = "delete", - Source = "tmp.txt" - } - } - } - }; - } - - [Fact] - public void IsValid_ReturnsTrue_ForMinimalValidConfig() - { - Assert.True(ConfigValidator.IsValid(MinimalValidConfig())); - } - - [Fact] - public void IsValid_ReturnsTrue_WhenAddonListMissing() - { - var cfg = MinimalValidConfig(); - cfg.Addons = null; - Assert.True(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsFalse_ForUnknownGroupType() - { - var cfg = MinimalValidConfig(); - cfg.OptionGroups[0].Type = "dropdown"; - Assert.False(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenRadioGroupHasNoRadios() - { - var cfg = MinimalValidConfig(); - cfg.OptionGroups[0].Radios = new List(); - Assert.False(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenLaunchExecMissingFields() - { - var cfg = MinimalValidConfig(); - cfg.Actions.Launch[0].Exec = new ExecSpec { Name = "", WorkDir = ".\\" }; - Assert.False(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenOperationMissingRequiredFields() - { - var cfg = MinimalValidConfig(); - cfg.Actions.Operations = new List - { - new() - { - Operation = "rename", - Source = "a.dll", - Target = null - } - }; - - Assert.False(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenSetReadOnlyHasNoFiles() - { - var cfg = MinimalValidConfig(); - cfg.Actions.Operations = new List - { - new() - { - Operation = "setReadOnly", - Files = new List() - } - }; - - Assert.False(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsTrue_WhenSetReadOnlyHasFiles() - { - var cfg = MinimalValidConfig(); - cfg.Actions.Operations = new List - { - new() - { - Operation = "setReadOnly", - Files = new List { "Fallout.ini" } - } - }; - - Assert.True(ConfigValidator.IsValid(cfg)); - } - -} diff --git a/tests/Logic/WhenResolverTests.cs b/tests/Logic/WhenResolverTests.cs deleted file mode 100644 index 83edcc7..0000000 --- a/tests/Logic/WhenResolverTests.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System.Collections.Generic; -using MulderConfig.Logic; -using MulderConfig.Configuration; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace MulderConfigTests.Logic; - -public class WhenResolverTests -{ - private static List ParseWhen(string json, string prop = "when") - { - var token = JToken.Parse(json)[prop]; - return token!.ToObject>()!; - } - - private static Dictionary Sel(params (string key, object val)[] items) - { - var d = new Dictionary(); - foreach (var (k, v) in items) d[k] = v; - return d; - } - - [Fact] - public void And_AllConditionsMatch_Succeeds() - { - // AND: all conditions must match - var json = @"{ ""when"": [ { ""Renderer"": ""DX9"", ""HDR"": ""Enabled"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX9"), ("HDR", "Enabled")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void And_OneConditionMiss_Fails() - { - // AND: if one condition does not match => fail - var json = @"{ ""when"": [ { ""Renderer"": ""DX9"", ""HDR"": ""Enabled"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX11"), ("HDR", "Enabled")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void And_NoConditionMatches_Fails() - { - // AND: if all conditions mismatch => fail - var json = @"{ ""when"": [ { ""Renderer"": ""DX9"", ""HDR"": ""Enabled"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX11"), ("HDR", "Disabled")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void Or_AllGroupMatch_Succeeds() - { - // OR: all groupes match => succeeds - var json = @"{ ""when"": [ { ""Resolution"": ""2560x1440"" }, { ""Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "2560x1440"), ("Renderer", "DXVK")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void Or_OneGroupMatches_Succeeds() - { - // OR: all groupes match => succeeds - var json = @"{ ""when"": [ { ""Resolution"": ""2560x1440"" }, { ""Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DXVK")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void Or_NoGroupMatches_Fails() - { - // OR: aucun groupe ne matche => false - var json = @"{ ""when"": [ { ""Resolution"": ""2560x1440"" }, { ""Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080"), ("Renderer", "D3D9")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void OrOfAndGroups_MixedExample_Succeeds() - { - // (Resolution contains 1920x AND Renderer == DXVK) OR (FOV Modifier != "None") - var json = @" - { - ""when"": [ - { ""*Resolution"": ""1920x"", ""Renderer"": ""DXVK"" }, - { ""!FOV Modifier"": ""None"" } - ] - }"; - var when = ParseWhen(json); - - // The first group fails (renderer != DXVK), but the second succeeds => OR => true - var selected = Sel(("Resolution", "1920x1080"), ("Renderer", "D3D9"), ("FOV Modifier", "lower")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void NotEquals_Succeeds() - { - var json = @"{ ""when"": [ { ""!Renderer"": ""DXVK"" } ] }"; - - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX9")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void NotEquals_Fails() - { - var json = @"{ ""when"": [ { ""!Resolution"": ""1920x1080"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void Contains_Succeeds() - { - var json = @"{ ""when"": [ { ""*Resolution"": ""1920x"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void Contains_Fails() - { - var json = @"{ ""when"": [ { ""*Resolution"": ""2560x"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void NotContains_Succeeds() - { - var json = @"{ ""when"": [ { ""!*Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX9")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void NotContains_Fails() - { - var json = @"{ ""when"": [ { ""!*Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "Vulkan DXVK Wrapper")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void EmptyExpected_MatchesNothingSelected() - { - var json = @"{ ""when"": [ { ""Switchable Mods"": """" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Switchable Mods", new List())); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void List_Contains_Succeeds() - { - var json = @"{ ""when"": [ { ""*Switchable Mods"": ""NV"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Switchable Mods", new List { "NVHR", "DXVK" })); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void List_NotContains_Fails() - { - var json = @"{ ""when"": [ { ""!*Switchable Mods"": ""Vulkan"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Switchable Mods", new List { "NVHR", "Vulkan DXVK Wrapper" })); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void MissingKey_Equals_Fails() - { - var json = @"{ ""when"": [ { ""Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void MissingKey_Contains_Fails() - { - var json = @"{ ""when"": [ { ""*Renderer"": ""DX"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void MissingKey_NotEquals_Succeeds() - { - var json = @"{ ""when"": [ { ""!Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void MissingKey_NotContains_Succeeds() - { - var json = @"{ ""when"": [ { ""!*Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void CaseInsensitive_Equals_And_Contains_Work() - { - var json = @"{ ""when"": [ { ""Renderer"": ""dxvk"", ""*Resolution"": ""1920X"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DXVK"), ("Resolution", "1920x1080")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void CaseInsensitive_NotEquals_And_NotContains_Work() - { - var json = @"{ ""when"": [ { ""!Renderer"": ""dxvk"", ""!*Resolution"": ""(21/9)"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX9"), ("Resolution", "1920x1080 (16/9)")); - - Assert.True(WhenResolver.Match(when, selected)); - } -} diff --git a/tests/MulderConfigTests.csproj b/tests/MulderConfigTests.csproj deleted file mode 100644 index 8ca2f66..0000000 --- a/tests/MulderConfigTests.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - net8.0-windows - false - - - - - - - - - - - - - \ No newline at end of file diff --git a/tests/Save/SaveValidatorTests.cs b/tests/Save/SaveValidatorTests.cs deleted file mode 100644 index 7ab7af2..0000000 --- a/tests/Save/SaveValidatorTests.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.Collections.Generic; -using MulderConfig.Configuration; -using MulderConfig.Save; -using Xunit; - -namespace MulderConfigTests.Save; - -public class SaveValidatorTests -{ - private static ConfigModel MakeConfig(params OptionGroup[] groups) - { - return new ConfigModel - { - Game = new Game { Title = "Test", OriginalExe = "Game.exe" }, - Addons = new List { new() { Title = "default" } }, - OptionGroups = new List(groups), - Actions = new ActionRoot { Launch = new List(), Operations = new List() } - }; - } - - [Fact] - public void IsValid_ReturnsTrue_ForValidRadioChoice() - { - var config = MakeConfig( - new OptionGroup - { - Name = "Renderer", - Type = "radioGroup", - Radios = new List - { - new() { Value = "DX9" }, - new() { Value = "DX11" }, - } - }); - - var saved = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Renderer"] = "DX9" - }; - - Assert.True(SaveValidator.IsValid(config, saved)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenGroupDoesNotExist() - { - var config = MakeConfig( - new OptionGroup { Name = "Renderer", Type = "radioGroup", Radios = new List { new() { Value = "DX9" } } }); - - var saved = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["OldGroup"] = "Whatever" - }; - - Assert.False(SaveValidator.IsValid(config, saved)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenRadioValueDoesNotExist() - { - var config = MakeConfig( - new OptionGroup - { - Name = "Renderer", - Type = "radioGroup", - Radios = new List { new() { Value = "DX9" } } - }); - - var saved = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Renderer"] = "DX12" - }; - - Assert.False(SaveValidator.IsValid(config, saved)); - } - - [Fact] - public void IsValid_ReturnsTrue_ForValidCheckboxChoices() - { - var config = MakeConfig( - new OptionGroup - { - Name = "Mods", - Type = "checkboxGroup", - Checkboxes = new List - { - new() { Value = "A" }, - new() { Value = "B" }, - new() { Value = "C" }, - } - }); - - var saved = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Mods"] = new List { "A", "C" } - }; - - Assert.True(SaveValidator.IsValid(config, saved)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenCheckboxValueDoesNotExist() - { - var config = MakeConfig( - new OptionGroup - { - Name = "Mods", - Type = "checkboxGroup", - Checkboxes = new List { new() { Value = "A" } } - }); - - var saved = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Mods"] = new List { "A", "B" } - }; - - Assert.False(SaveValidator.IsValid(config, saved)); - } - - [Fact] - public void IsValid_ReturnsFalse_ForWrongTypes() - { - var config = MakeConfig( - new OptionGroup - { - Name = "Renderer", - Type = "radioGroup", - Radios = new List { new() { Value = "DX9" } } - }, - new OptionGroup - { - Name = "Mods", - Type = "checkboxGroup", - Checkboxes = new List { new() { Value = "A" } } - }); - - Assert.False(SaveValidator.IsValid(config, new Dictionary { ["Renderer"] = new List { "DX9" } })); - Assert.False(SaveValidator.IsValid(config, new Dictionary { ["Mods"] = "A" })); - } -} From b50cbb97eb7e08636eabcf086c48f8d34cacb3b5 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 6 May 2026 17:39:34 +0200 Subject: [PATCH 76/77] fix release note --- .github/workflows/build.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 12a53e9..c7b32ae 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -64,6 +64,7 @@ jobs: uses: softprops/action-gh-release@v3 with: files: target/release/MulderConfig.exe + generate_release_notes: true prerelease: ${{ steps.generate_version.outputs.is_pre }} tag_name: ${{ steps.generate_version.outputs.version }} From f19dc45f7f1f9c82ac2340d41b6947c9d680cb51 Mon Sep 17 00:00:00 2001 From: mulderf0x <200639147+mulderf0x@users.noreply.github.com> Date: Wed, 6 May 2026 17:56:10 +0200 Subject: [PATCH 77/77] fix tag --- .github/workflows/build.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c7b32ae..889a410 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -67,6 +67,7 @@ jobs: generate_release_notes: true prerelease: ${{ steps.generate_version.outputs.is_pre }} tag_name: ${{ steps.generate_version.outputs.version }} + target_commitish: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Cleanup old pre-releases if: steps.generate_version.outputs.is_pre == 'true'