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