diff --git a/.gitignore b/.gitignore
index 2a7c213a..25c6b40c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -351,4 +351,10 @@ ASALocalRun/
.localhistory/
# BeatPulse healthcheck temp database
-healthchecksdb
\ No newline at end of file
+healthchecksdb
+
+# Linux build directories and Rust/CMake artifacts
+linux/build/
+linux/build-*/
+linux/target/
+Testing/
diff --git a/RawAccel.Contracts/Constants.cs b/RawAccel.Contracts/Constants.cs
new file mode 100644
index 00000000..79797e1c
--- /dev/null
+++ b/RawAccel.Contracts/Constants.cs
@@ -0,0 +1,32 @@
+namespace RawAccel.Contracts
+{
+ // Mirrors common/rawaccel-base.hpp; keep values in sync with the native side.
+ public static class RawAccelConstants
+ {
+ public const int PollRateMin = 125;
+ public const int PollRateMax = 8000;
+
+ // NORMALIZED_DPI: the unit curve math uses (counts/ms at 1000 DPI).
+ public const double NormalizedDpi = 1000.0;
+
+ public const double DefaultTimeMin = 1000.0 / PollRateMax / 2.0;
+ public const double DefaultTimeMax = 100.0;
+
+ public const double WriteDelayMs = 1000.0;
+
+ public const int MaxDevIdLen = 200;
+ public const int MaxNameLen = 256;
+
+ public const int LutRawDataCapacity = 514;
+ public const int LutPointsCapacity = LutRawDataCapacity / 2;
+
+ public const string SettingsKey = "Driver settings";
+
+ // TODO: make a versioning system to update all of the versions so
+ // 1.7.1 issue where driver is still on 1.7.0 doesnt happen again.
+ public const int VersionMajor = 1;
+ public const int VersionMinor = 7;
+ public const int VersionPatch = 0;
+ public const string VersionString = "1.7.0";
+ }
+}
diff --git a/RawAccel.Contracts/Enums.cs b/RawAccel.Contracts/Enums.cs
new file mode 100644
index 00000000..0924f000
--- /dev/null
+++ b/RawAccel.Contracts/Enums.cs
@@ -0,0 +1,26 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+
+namespace RawAccel.Contracts
+{
+ // JSON names match wrapper.cpp so settings.json round-trips unchanged.
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum AccelMode
+ {
+ classic,
+ jump,
+ natural,
+ synchronous,
+ power,
+ lut,
+ noaccel,
+ }
+
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum CapMode
+ {
+ in_out,
+ input,
+ output,
+ }
+}
diff --git a/RawAccel.Contracts/RawAccel.Contracts.csproj b/RawAccel.Contracts/RawAccel.Contracts.csproj
new file mode 100644
index 00000000..4bbd8d0d
--- /dev/null
+++ b/RawAccel.Contracts/RawAccel.Contracts.csproj
@@ -0,0 +1,15 @@
+
+
+
+ netstandard2.0
+ RawAccel.Contracts
+ 10
+ enable
+ false
+
+
+
+
+
+
+
diff --git a/RawAccel.Contracts/RawAccelAccelArgs.cs b/RawAccel.Contracts/RawAccelAccelArgs.cs
new file mode 100644
index 00000000..515737af
--- /dev/null
+++ b/RawAccel.Contracts/RawAccelAccelArgs.cs
@@ -0,0 +1,43 @@
+using Newtonsoft.Json;
+
+namespace RawAccel.Contracts
+{
+ // Mirrors AccelArgs in wrapper.cpp.
+ public class RawAccelAccelArgs
+ {
+ // Max LUT (input, output) pairs; resolves to ra::LUT_POINTS_CAPACITY.
+ public const int MaxLutPoints = RawAccelConstants.LutPointsCapacity;
+
+ public AccelMode mode { get; set; } = AccelMode.noaccel;
+
+ [JsonProperty("Gain / Velocity")]
+ public bool gain { get; set; } = true;
+
+ public double inputOffset { get; set; }
+ public double outputOffset { get; set; }
+ public double acceleration { get; set; } = 0.005;
+ public double decayRate { get; set; } = 0.1;
+ public double gamma { get; set; } = 1.0;
+ public double motivity { get; set; } = 1.5;
+ public double exponentClassic { get; set; } = 2.0;
+ public double scale { get; set; } = 1.0;
+ public double exponentPower { get; set; } = 0.05;
+ public double limit { get; set; } = 1.5;
+ public double syncSpeed { get; set; } = 5.0;
+ public double smooth { get; set; } = 0.5;
+
+ [JsonProperty("Cap / Jump")]
+ public Vec2 cap { get; set; } = new Vec2 { x = 15.0, y = 1.5 };
+
+ [JsonProperty("Cap mode")]
+ public CapMode capMode { get; set; } = CapMode.output;
+
+ // Native marshalling bookkeeping; not serialized.
+ [JsonIgnore]
+ public int length { get; set; }
+
+ // Carries only the populated points (length samples); native resizes
+ // to capacity. Empty not null: wrapper's OnDeserialized derefs Length.
+ public float[] data { get; set; } = System.Array.Empty();
+ }
+}
diff --git a/RawAccel.Contracts/RawAccelConfig.cs b/RawAccel.Contracts/RawAccelConfig.cs
new file mode 100644
index 00000000..f8af9a92
--- /dev/null
+++ b/RawAccel.Contracts/RawAccelConfig.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+
+namespace RawAccel.Contracts
+{
+ // Root JSON contract, consumed unchanged by both the Windows wrapper
+ // (IOCTL) and Linux agent (socket) paths. These POCOs mirror the
+ // wrapper.cpp types; the JsonProperty names are the wire format
+ // (settings.json + driver), so renaming a field breaks compatibility.
+ public class RawAccelConfig
+ {
+ public string version { get; set; } = string.Empty;
+
+ public RawAccelDeviceConfig defaultDeviceConfig { get; set; } = new RawAccelDeviceConfig();
+
+ public List profiles { get; set; } = new List();
+
+ public List devices { get; set; } = new List();
+ }
+}
diff --git a/RawAccel.Contracts/RawAccelDeviceConfig.cs b/RawAccel.Contracts/RawAccelDeviceConfig.cs
new file mode 100644
index 00000000..0ccfa5f3
--- /dev/null
+++ b/RawAccel.Contracts/RawAccelDeviceConfig.cs
@@ -0,0 +1,32 @@
+using Newtonsoft.Json;
+
+namespace RawAccel.Contracts
+{
+ // Mirrors DeviceConfig in wrapper.cpp. ShouldSerialize* hide default
+ // values to keep the JSON compact.
+ public class RawAccelDeviceConfig
+ {
+ public bool disable { get; set; }
+
+ public bool setExtraInfo { get; set; }
+
+ [JsonProperty("Use constant time interval based on polling rate")]
+ public bool pollTimeLock { get; set; }
+
+ [JsonProperty("DPI (normalizes input speed unit: counts/ms -> in/s)")]
+ public int dpi { get; set; } = 1000;
+
+ [JsonProperty("Polling rate Hz (keep at 0 for automatic adjustment)")]
+ public int pollingRate { get; set; }
+
+ public double minimumTime { get; set; } = RawAccelConstants.DefaultTimeMin;
+
+ public double maximumTime { get; set; } = RawAccelConstants.DefaultTimeMax;
+
+ public bool ShouldSerializesetExtraInfo() => setExtraInfo;
+
+ public bool ShouldSerializeminimumTime() => minimumTime != RawAccelConstants.DefaultTimeMin;
+
+ public bool ShouldSerializemaximumTime() => maximumTime != RawAccelConstants.DefaultTimeMax;
+ }
+}
diff --git a/RawAccel.Contracts/RawAccelDeviceSettings.cs b/RawAccel.Contracts/RawAccelDeviceSettings.cs
new file mode 100644
index 00000000..513c3e85
--- /dev/null
+++ b/RawAccel.Contracts/RawAccelDeviceSettings.cs
@@ -0,0 +1,15 @@
+namespace RawAccel.Contracts
+{
+ // Mirrors DeviceSettings in wrapper.cpp. Length limits (MaxNameLen /
+ // MaxDevIdLen) are enforced by the conversion layer, not here.
+ public class RawAccelDeviceSettings
+ {
+ public string name { get; set; } = string.Empty;
+
+ public string profile { get; set; } = string.Empty;
+
+ public string id { get; set; } = string.Empty;
+
+ public RawAccelDeviceConfig config { get; set; } = new RawAccelDeviceConfig();
+ }
+}
diff --git a/RawAccel.Contracts/RawAccelProfile.cs b/RawAccel.Contracts/RawAccelProfile.cs
new file mode 100644
index 00000000..74e8bc5f
--- /dev/null
+++ b/RawAccel.Contracts/RawAccelProfile.cs
@@ -0,0 +1,50 @@
+using Newtonsoft.Json;
+
+namespace RawAccel.Contracts
+{
+ // Mirrors Profile in wrapper.cpp. Relaxed here (partial JSON tolerated);
+ // the native side does the strict all-fields-present validation.
+ public class RawAccelProfile
+ {
+ public string name { get; set; } = "default";
+
+ [JsonProperty("Stretches domain for horizontal vs vertical inputs")]
+ public Vec2 domainXY { get; set; } = new Vec2 { x = 1.0, y = 1.0 };
+
+ [JsonProperty("Stretches accel range for horizontal vs vertical inputs")]
+ public Vec2 rangeXY { get; set; } = new Vec2 { x = 1.0, y = 1.0 };
+
+ [JsonProperty("Whole or horizontal accel parameters")]
+ public RawAccelAccelArgs argsX { get; set; } = new RawAccelAccelArgs();
+
+ [JsonProperty("Vertical accel parameters")]
+ public RawAccelAccelArgs argsY { get; set; } = new RawAccelAccelArgs();
+
+ [JsonProperty("Input speed calculation parameters")]
+ public RawAccelSpeedArgs inputSpeedArgs { get; set; } = new RawAccelSpeedArgs();
+
+ [JsonProperty("Output DPI")]
+ public double outputDPI { get; set; } = 1000.0;
+
+ [JsonProperty("Y/X output DPI ratio (vertical sens multiplier)")]
+ public double yxOutputDPIRatio { get; set; } = 1.0;
+
+ [JsonProperty("L/R output DPI ratio (left sens multiplier)")]
+ public double lrOutputDPIRatio { get; set; } = 1.0;
+
+ [JsonProperty("U/D output DPI ratio (up sens multiplier)")]
+ public double udOutputDPIRatio { get; set; } = 1.0;
+
+ [JsonProperty("Degrees of rotation")]
+ public double rotation { get; set; }
+
+ [JsonProperty("Degrees of angle snapping")]
+ public double snap { get; set; }
+
+ [JsonIgnore]
+ public double minimumSpeed { get; set; }
+
+ [JsonProperty("Input Speed Cap")]
+ public double maximumSpeed { get; set; }
+ }
+}
diff --git a/RawAccel.Contracts/RawAccelSpeedArgs.cs b/RawAccel.Contracts/RawAccelSpeedArgs.cs
new file mode 100644
index 00000000..09d311d6
--- /dev/null
+++ b/RawAccel.Contracts/RawAccelSpeedArgs.cs
@@ -0,0 +1,22 @@
+using Newtonsoft.Json;
+
+namespace RawAccel.Contracts
+{
+ // Mirrors SpeedArgs in wrapper.cpp.
+ public class RawAccelSpeedArgs
+ {
+ [JsonProperty("Whole/combined accel (set false for 'by component' mode)")]
+ public bool combineMagnitudes { get; set; } = true;
+
+ public double lpNorm { get; set; } = 2.0;
+
+ [JsonProperty("Time in ms after which an input is weighted at half its original value.")]
+ public double inputSmoothHalflife { get; set; }
+
+ [JsonProperty("Time in ms after which scale is weighted at half its original value.")]
+ public double scaleSmoothHalflife { get; set; }
+
+ [JsonProperty("Time in ms after which an output is weighted at half its original value.")]
+ public double outputSmoothHalflife { get; set; }
+ }
+}
diff --git a/RawAccel.Contracts/Vec2.cs b/RawAccel.Contracts/Vec2.cs
new file mode 100644
index 00000000..c94bfec2
--- /dev/null
+++ b/RawAccel.Contracts/Vec2.cs
@@ -0,0 +1,9 @@
+namespace RawAccel.Contracts
+{
+ // 2D vector for cap, domainXY, rangeXY. Mirrors Vec2 in wrapper.cpp.
+ public struct Vec2
+ {
+ public T x;
+ public T y;
+ }
+}
diff --git a/grapher/Models/Options/LUT/LUTPanelOptions.cs b/grapher/Models/Options/LUT/LUTPanelOptions.cs
index 5955ee77..eedcfa80 100644
--- a/grapher/Models/Options/LUT/LUTPanelOptions.cs
+++ b/grapher/Models/Options/LUT/LUTPanelOptions.cs
@@ -185,7 +185,7 @@ private static (Vec2[], int length) UserTextToPoints(string userText)
{
x = float.Parse(pointSplit[0]);
}
- catch (Exception)
+ catch (Exception ex)
{
throw new ApplicationException($"X-value for point at index {index} is malformed. Expected: float. Given: {pointSplit[0]}", ex);
}
@@ -206,7 +206,7 @@ private static (Vec2[], int length) UserTextToPoints(string userText)
{
y = float.Parse(pointSplit[1]);
}
- catch (Exception)
+ catch (Exception ex)
{
throw new ApplicationException($"Y-value for point at index {index} is malformed. Expected: float. Given: {pointSplit[1]}", ex);
}
diff --git a/userinterface/App.axaml.cs b/userinterface/App.axaml.cs
index 23acc90f..87799ec9 100644
--- a/userinterface/App.axaml.cs
+++ b/userinterface/App.axaml.cs
@@ -36,7 +36,7 @@ public override void Initialize()
AvaloniaXamlLoader.Load(this);
}
-#if DEBUG
+#if DEBUG && WINDOWS
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AllocConsole();
@@ -82,7 +82,7 @@ private static void AttachConsoleStreams()
public override void OnFrameworkInitializationCompleted()
{
-#if DEBUG
+#if DEBUG && WINDOWS
// Attach a console so backend ILogger output is visible alongside the UI window.
AllocConsole();
AttachConsoleStreams();
@@ -104,7 +104,7 @@ public override void OnFrameworkInitializationCompleted()
#endif
});
- string settingsDirectory = System.AppDomain.CurrentDomain.BaseDirectory;
+ string settingsDirectory = ResolveSettingsDirectory();
services.AddSingleton(sp =>
{
var devicesRW = sp.GetRequiredService();
@@ -123,6 +123,7 @@ public override void OnFrameworkInitializationCompleted()
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddTransient();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -131,8 +132,6 @@ public override void OnFrameworkInitializationCompleted()
Services = BackEndComposer.Compose(services);
- EditableSettingLog.Configure(Services.GetRequiredService());
-
IBackEnd backEnd = Services.GetRequiredService();
backEnd.Load();
backEnd.ImportSystemDevices();
@@ -173,6 +172,21 @@ public override void OnFrameworkInitializationCompleted()
}
};
+ // ShutdownRequested only fires on a normal window close. A Ctrl+C under
+ // `dotnet run` sends SIGINT, which skips that but still hits ProcessExit;
+ // mirror the save there so dev sessions don't drop unsaved edits.
+ AppDomain.CurrentDomain.ProcessExit += (_, _) =>
+ {
+ try
+ {
+ backEnd.SaveToDisk();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[PROCESS_EXIT] SaveToDisk failed: {ex.Message}");
+ }
+ };
+
// Preload libraries that cause first-page stutter
_ = PreloadLibrariesAsync();
@@ -368,17 +382,9 @@ public static void OpenDiscordUrl()
}
}
- /*
- * This was originally intended to preload libraries that cause stutter
- * but it seems to not have much effect. Will leave it here for now.
- *
- * Could also do these
- * System.Runtime.Intrinsics
- * System.Text.Json
- * System.Text.Encodings.Web
- * System.Text.Encoding.Extensions
- * System.IO.Pipelines
- */
+ // Preloads libraries that can cause first-use stutter. Limited effect in
+ // practice; kept for now. Candidates: System.Runtime.Intrinsics,
+ // System.Text.Json/Encodings.Web/Encoding.Extensions, System.IO.Pipelines.
private async Task PreloadLibrariesAsync()
{
try
@@ -417,6 +423,27 @@ await Task.Run(() =>
}
}
+ // On Linux, settings live under $XDG_CONFIG_HOME/rawaccel ($HOME/.config/rawaccel
+ // if unset); other OSes write next to the executable.
+ private static string ResolveSettingsDirectory()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ return AppDomain.CurrentDomain.BaseDirectory;
+ }
+
+ var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
+ if (string.IsNullOrEmpty(xdg) || !Path.IsPathRooted(xdg))
+ {
+ var home = Environment.GetFolderPath(
+ Environment.SpecialFolder.UserProfile);
+ xdg = Path.Combine(home, ".config");
+ }
+ var dir = Path.Combine(xdg, "rawaccel");
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
private void ApplyStartupSettings()
{
try
diff --git a/userinterface/Program.cs b/userinterface/Program.cs
index b0cf6d8f..c282fe0c 100644
--- a/userinterface/Program.cs
+++ b/userinterface/Program.cs
@@ -13,8 +13,8 @@ internal sealed class Program
[STAThread]
public static void Main(string[] args)
{
- // This is for crash logging. It Installs global exception sinks BEFORE
- // Avalonia starts so a crash during startup or on a worker thread should still get
+ // This is for crash logging. It Installs global exception sinks BEFORE
+ // Avalonia starts so a crash during startup or on a worker thread should still get
// written to logs/crash.log before the process exits.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
WriteCrashLog("AppDomain.UnhandledException", e.ExceptionObject as Exception);
diff --git a/userinterface/Properties/Resources/Strings.Designer.cs b/userinterface/Properties/Resources/Strings.Designer.cs
index fc7cedd9..dfddc0f5 100644
--- a/userinterface/Properties/Resources/Strings.Designer.cs
+++ b/userinterface/Properties/Resources/Strings.Designer.cs
@@ -779,6 +779,15 @@ public static string MainWindowSettingsAppliedSuccess {
return ResourceManager.GetString("MainWindowSettingsAppliedSuccess", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Failed to apply settings. Check the log for details..
+ ///
+ public static string MainWindowSettingsAppliedFailure {
+ get {
+ return ResourceManager.GetString("MainWindowSettingsAppliedFailure", resourceCulture);
+ }
+ }
///
/// Looks up a localized string similar to Add Entry.
diff --git a/userinterface/Properties/Resources/Strings.ja-JP.resx b/userinterface/Properties/Resources/Strings.ja-JP.resx
index b270df74..e4799256 100644
--- a/userinterface/Properties/Resources/Strings.ja-JP.resx
+++ b/userinterface/Properties/Resources/Strings.ja-JP.resx
@@ -194,6 +194,9 @@
設定を反映しました!
+
+ 設定の反映に失敗しました。ログを確認してください。
+
言語を{0}に変更しました
diff --git a/userinterface/Properties/Resources/Strings.resx b/userinterface/Properties/Resources/Strings.resx
index 13cb5595..b356aa53 100644
--- a/userinterface/Properties/Resources/Strings.resx
+++ b/userinterface/Properties/Resources/Strings.resx
@@ -194,6 +194,9 @@
Settings applied successfully!
+
+ Failed to apply settings. Check the log for details.
+
Language changed to {0}
diff --git a/userinterface/Services/MouseSpeedPollingService.cs b/userinterface/Services/MouseSpeedPollingService.cs
new file mode 100644
index 00000000..37c39fd9
--- /dev/null
+++ b/userinterface/Services/MouseSpeedPollingService.cs
@@ -0,0 +1,101 @@
+using Avalonia.Threading;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using userspace_backend.Driver;
+
+namespace userinterface.Services
+{
+ // Background poller for the driver's current input-speed telemetry. Transient,
+ // so each chart ViewModel owns one. No-op when the driver is null; when the
+ // agent is down the driver returns Zero cheaply, so the loop is safe to run.
+ public sealed class MouseSpeedPollingService : IDisposable
+ {
+ // ~30 Hz. The chart animates at ~100 ms, so the indicator only needs to
+ // feel live, not frame-perfect. Each poll opens a fresh AF_UNIX socket.
+ private const int PollIntervalMs = 33;
+
+ private readonly IRawAccelDriver? driver;
+ private readonly ILogger logger;
+ private readonly object gate = new();
+
+ private CancellationTokenSource? cts;
+ private Action? onSample;
+
+ public MouseSpeedPollingService(
+ IRawAccelDriver? driver,
+ ILogger logger)
+ {
+ this.driver = driver;
+ this.logger = logger;
+ }
+
+ public bool IsRunning { get; private set; }
+
+ // Begins polling. onSample is always invoked on the UI thread. Idempotent.
+ public void Start(Action onSample)
+ {
+ lock (gate)
+ {
+ if (IsRunning) return;
+ if (driver is null) return;
+
+ this.onSample = onSample;
+ cts = new CancellationTokenSource();
+ IsRunning = true;
+ var token = cts.Token;
+ _ = Task.Run(() => LoopAsync(token));
+ }
+ }
+
+ public void Stop()
+ {
+ lock (gate)
+ {
+ if (!IsRunning) return;
+ IsRunning = false;
+ try { cts?.Cancel(); } catch { /* already disposed */ }
+ cts?.Dispose();
+ cts = null;
+ onSample = null;
+ }
+ }
+
+ private async Task LoopAsync(CancellationToken token)
+ {
+ while (!token.IsCancellationRequested)
+ {
+ MouseSpeedSample sample = MouseSpeedSample.Zero;
+ try
+ {
+ sample = driver!.GetCurrentMouseSpeedSample();
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "mouse speed poll failed");
+ }
+
+ if (token.IsCancellationRequested) break;
+
+ var cb = onSample;
+ if (cb != null)
+ {
+ // Post (not Invoke) so the loop never blocks on UI work.
+ Dispatcher.UIThread.Post(() => cb(sample));
+ }
+
+ try
+ {
+ await Task.Delay(PollIntervalMs, token);
+ }
+ catch (TaskCanceledException)
+ {
+ break;
+ }
+ }
+ }
+
+ public void Dispose() => Stop();
+ }
+}
diff --git a/userinterface/Services/SettingsService.cs b/userinterface/Services/SettingsService.cs
index 882af62e..313e9c3d 100644
--- a/userinterface/Services/SettingsService.cs
+++ b/userinterface/Services/SettingsService.cs
@@ -83,7 +83,7 @@ public bool TrySave(out string? errorMessage)
errorMessage = null;
try
{
- backEnd.Apply();
+ backEnd.SaveToDisk();
return true;
}
catch (Exception ex)
diff --git a/userinterface/ViewModels/Controls/EditableBoolViewModel.cs b/userinterface/ViewModels/Controls/EditableBoolViewModel.cs
index a817a38a..1a7a1100 100644
--- a/userinterface/ViewModels/Controls/EditableBoolViewModel.cs
+++ b/userinterface/ViewModels/Controls/EditableBoolViewModel.cs
@@ -13,10 +13,16 @@ public partial class EditableBoolViewModel : ViewModelBase
private readonly LocalizationService localizationService;
- public EditableBoolViewModel(BE.IEditableSetting settingBE, LocalizationService localizationService)
+ // When true, toggling commits straight to the backend (so the chart reacts
+ // immediately). Default false keeps the deferred-commit behavior.
+ private readonly bool autoCommit;
+ private bool suppressAutoCommit;
+
+ public EditableBoolViewModel(BE.IEditableSetting settingBE, LocalizationService localizationService, bool autoCommit = false)
{
SettingBE = settingBE;
this.localizationService = localizationService;
+ this.autoCommit = autoCommit;
ResetValueFromBackEnd();
// Subscribe to language changes to update the Name property
@@ -40,8 +46,14 @@ public bool TrySetFromInterface()
return wasSet;
}
- private void ResetValueFromBackEnd() =>
+ private void ResetValueFromBackEnd()
+ {
+ // Suppress auto-commit while mirroring the backend value in, or the
+ // change event would re-commit and recurse.
+ suppressAutoCommit = true;
ValueInDisplay = bool.TryParse(SettingBE.InterfaceValue, out bool result) && result;
+ suppressAutoCommit = false;
+ }
private string GetLocalizedName()
{
@@ -68,6 +80,10 @@ private void OnLanguageChanged(object? sender, PropertyChangedEventArgs e)
partial void OnValueInDisplayChanged(bool value)
{
OnPropertyChanged(nameof(Value));
+ if (autoCommit && !suppressAutoCommit)
+ {
+ TrySetFromInterface();
+ }
}
}
}
\ No newline at end of file
diff --git a/userinterface/ViewModels/MainWindowViewModel.cs b/userinterface/ViewModels/MainWindowViewModel.cs
index 6f974e87..d8f383fb 100644
--- a/userinterface/ViewModels/MainWindowViewModel.cs
+++ b/userinterface/ViewModels/MainWindowViewModel.cs
@@ -219,9 +219,9 @@ private async void CollapseProfiles()
}
}
- public void Apply()
+ public bool Apply()
{
- BackEnd.Apply();
+ return BackEnd.Apply();
}
private void ToggleTheme()
diff --git a/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs b/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs
index 2b4fe88e..360ccf42 100644
--- a/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs
+++ b/userinterface/ViewModels/Profile/AnisotropyProfileSettingsViewModel.cs
@@ -14,6 +14,9 @@ public AnisotropyProfileSettingsViewModel(BE.IAnisotropyModel anisotropyBE, Loca
RangeX = new EditableFieldViewModel(AnisotropyBE.RangeX);
RangeY = new EditableFieldViewModel(AnisotropyBE.RangeY);
LPNorm = new NamedEditableFieldViewModel(AnisotropyBE.LPNorm, localizationService);
+ // autoCommit so the toggle reaches the backend immediately; the chart
+ // watches this to switch between one and two current-speed lines.
+ CombineXY = new EditableBoolViewModel(AnisotropyBE.CombineXYComponents, localizationService, autoCommit: true);
}
protected BE.IAnisotropyModel AnisotropyBE { get; }
@@ -27,5 +30,7 @@ public AnisotropyProfileSettingsViewModel(BE.IAnisotropyModel anisotropyBE, Loca
public EditableFieldViewModel RangeY { get; set; }
public NamedEditableFieldViewModel LPNorm { get; set; }
+
+ public EditableBoolViewModel CombineXY { get; set; }
}
}
\ No newline at end of file
diff --git a/userinterface/ViewModels/Profile/ProfileChartViewModel.cs b/userinterface/ViewModels/Profile/ProfileChartViewModel.cs
index ffbdc07c..aae3ac25 100644
--- a/userinterface/ViewModels/Profile/ProfileChartViewModel.cs
+++ b/userinterface/ViewModels/Profile/ProfileChartViewModel.cs
@@ -2,6 +2,7 @@
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Media.Immutable;
+using Avalonia.Threading;
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
@@ -18,6 +19,7 @@
using userinterface.Interfaces;
using userinterface.Services;
using userspace_backend.Display;
+using userspace_backend.Driver;
using userspace_backend.Model.EditableSettings;
using BE = userspace_backend.Model;
@@ -45,6 +47,13 @@ public partial class ProfileChartViewModel : ViewModelBase, IAsyncInitializable
private const int StandardStrokeThickness = 1;
private const float SubStrokeThickness = 0.5f;
+ // Speed-line smoothing: a UI-thread timer eases the displayed line toward
+ // the latest ~30 Hz poll target. TimeConstant = glide speed (larger is
+ // smoother/laggier); SettleEpsilon = chart-unit "arrived" threshold.
+ private const int TweenIntervalMs = 16; // ~60 Hz
+ private const double TweenTimeConstantMs = 60.0;
+ private const double SpeedSettleEpsilon = 0.05;
+
// Color transparency values
private const byte SubSeparatorAlpha = 100;
@@ -65,33 +74,77 @@ public partial class ProfileChartViewModel : ViewModelBase, IAsyncInitializable
private readonly IThemeService themeService;
private readonly LocalizationService localizationService;
private readonly PreviewChartRenderer previewRenderer;
+ private readonly MouseSpeedPollingService speedPoller;
private BE.IProfileModel currentProfileModel = null!;
-
+
+ // Tween state: target* is the latest poll sample, disp* the eased position
+ // rendered. tweenTimer pumps disp -> target and self-stops once settled.
+ private DispatcherTimer? tweenTimer;
+ private DateTime lastTweenTick;
+ private double targetSpeedX, targetSpeedY, targetSpeedCombined;
+ private double dispSpeedX, dispSpeedY, dispSpeedCombined;
+
// Cached paint objects to avoid recreation
private SolidColorPaint? cachedXStroke;
private SolidColorPaint? cachedYStroke;
-
+
+ // Active profile's "combine X and Y" flag: one (combined) or two (per-axis)
+ // current-speed lines.
+ private IEditableSettingSpecific CombineXY { get; set; } = null!;
+
// Sync object for thread safety - single allocation
private readonly object syncObject = new object();
- public ProfileChartViewModel(IThemeService themeService, LocalizationService localizationService, PreviewChartRenderer previewRenderer)
+ public ProfileChartViewModel(IThemeService themeService, LocalizationService localizationService, PreviewChartRenderer previewRenderer, MouseSpeedPollingService speedPoller)
{
this.themeService = themeService ?? throw new ArgumentNullException(nameof(themeService));
this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
this.previewRenderer = previewRenderer ?? throw new ArgumentNullException(nameof(previewRenderer));
+ this.speedPoller = speedPoller ?? throw new ArgumentNullException(nameof(speedPoller));
RecreateAxesCommand = new RelayCommand(() =>
{
EnsureInteractiveChartLoaded();
RecreateAxes();
});
- FitToDataCommand = new RelayCommand(() =>
+ FitToDataCommand = new RelayCommand(() =>
{
EnsureInteractiveChartLoaded();
FitToData();
});
+ ToggleSpeedLinesCommand = new RelayCommand(() => ShowSpeedLines = !ShowSpeedLines);
}
+ private bool showSpeedLines = true;
+
+ // Whether the live current-speed line(s) are shown; toggled from the button bar.
+ public bool ShowSpeedLines
+ {
+ get => showSpeedLines;
+ set
+ {
+ if (showSpeedLines == value) return;
+ showSpeedLines = value;
+ OnPropertyChanged(nameof(ShowSpeedLines));
+ OnPropertyChanged(nameof(SpeedLinesIconOpacity));
+ if (showSpeedLines)
+ {
+ // Reflect current positions immediately, then ease toward target.
+ RebuildSpeedSections();
+ EnsureTweenRunning();
+ }
+ else
+ {
+ StopTween();
+ Sections = Array.Empty();
+ OnPropertyChanged(nameof(Sections));
+ }
+ }
+ }
+
+ // Dims the toggle button's icon when the lines are hidden.
+ public double SpeedLinesIconOpacity => ShowSpeedLines ? 1.0 : 0.35;
+
public bool IsInitialized { get; private set; }
public bool IsInitializing { get; private set; }
@@ -123,6 +176,10 @@ public void Initialize(BE.IProfileModel profileModel)
YXRatio = profileModel.YXRatio;
YXRatio.PropertyChanged += OnYXRatioChanged;
+
+ CombineXY = profileModel.Acceleration.Anisotropy.CombineXYComponents;
+ CombineXY.PropertyChanged += OnCombineXYChanged;
+ RebuildSpeedSections();
}
@@ -293,10 +350,13 @@ private async void TransitionToInteractiveMode()
// Small delay to ensure chart is rendered
await Task.Delay(100);
-
+
// Fade in interactive chart
ChartOpacity = 1.0;
OnPropertyChanged(nameof(ChartOpacity));
+
+ // Begin polling live mouse speed now that the chart is on screen.
+ StartSpeedPollingIfPossible();
}
public Task SwitchToProfileAsync(BE.IProfileModel profileModel)
@@ -304,6 +364,11 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel)
if (currentProfileModel == profileModel && IsInitialized)
return Task.CompletedTask;
+ if (YXRatio != null)
+ YXRatio.PropertyChanged -= OnYXRatioChanged;
+ if (CombineXY != null)
+ CombineXY.PropertyChanged -= OnCombineXYChanged;
+
currentProfileModel = profileModel;
XCurvePreview = profileModel.XCurvePreview;
YCurvePreview = profileModel.YCurvePreview;
@@ -311,6 +376,10 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel)
YXRatio.PropertyChanged += OnYXRatioChanged;
+ CombineXY = profileModel.Acceleration.Anisotropy.CombineXYComponents;
+ CombineXY.PropertyChanged += OnCombineXYChanged;
+ RebuildSpeedSections();
+
// Update chart data synchronously for instant response
CreateSeries();
@@ -319,6 +388,11 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel)
public ObservableCollection Series { get; set; } = new ObservableCollection();
+ // Vertical current-speed line(s) bound to CartesianChart.Sections: one in
+ // combined mode, two (X/Y) in separate. Reassigned fresh each update because
+ // LiveCharts won't redraw a section mutated in place.
+ public IEnumerable Sections { get; private set; } = Array.Empty();
+
public Axis[] XAxes { get; set; } = new Axis[] { new Axis { Name = "Loading...", MinLimit = 0, MaxLimit = 1 } };
public Axis[] YAxes { get; set; } = new Axis[] { new Axis { Name = "Loading...", MinLimit = 0, MaxLimit = 1 } };
@@ -331,6 +405,8 @@ public Task SwitchToProfileAsync(BE.IProfileModel profileModel)
public ICommand FitToDataCommand { get; }
+ public ICommand ToggleSpeedLinesCommand { get; }
+
// ================================================================================================
// PUBLIC METHODS
// ================================================================================================
@@ -380,7 +456,18 @@ public void Dispose()
localizationService.PropertyChanged -= OnLocalizationChanged;
if (YXRatio != null)
YXRatio.PropertyChanged -= OnYXRatioChanged;
-
+ if (CombineXY != null)
+ CombineXY.PropertyChanged -= OnCombineXYChanged;
+
+ // Stop and release the live-speed poller and its tween pump.
+ speedPoller.Dispose();
+ if (tweenTimer != null)
+ {
+ tweenTimer.Stop();
+ tweenTimer.Tick -= OnTweenTick;
+ tweenTimer = null;
+ }
+
// Dispose cached paint objects
if (cachedXStroke != null)
{
@@ -392,7 +479,7 @@ public void Dispose()
cachedYStroke.Dispose();
cachedYStroke = null;
}
-
+
// Clear preview renderer cache for memory cleanup
previewRenderer.ClearCache();
}
@@ -481,19 +568,158 @@ private void CreateSeries()
}
}
+ // ================================================================================================
+ // LIVE CURRENT-SPEED INDICATOR LINES
+ // ================================================================================================
+
+ // Vertical zero-width line (Xi == Xj) at the given speed; non-positive
+ // speed -> NaN bounds, which render nothing. Fresh paint per call: a shared
+ // one gets disposed by LiveCharts when its section is removed.
+ private static RectangularSection MakeSpeedLine(double speed, SKColor color)
+ {
+ double x = speed > 0 ? speed : double.NaN;
+ return new RectangularSection
+ {
+ Xi = x,
+ Xj = x,
+ Fill = null,
+ Stroke = new SolidColorPaint(color) { StrokeThickness = MainStrokeThickness },
+ };
+ }
+
+ // Builds the indicator line(s) for the sample and reassigns Sections.
+ private void PublishSpeedSections(MouseSpeedSample sample)
+ {
+ if (!ShowSpeedLines)
+ {
+ Sections = Array.Empty();
+ OnPropertyChanged(nameof(Sections));
+ return;
+ }
+
+ bool combined = CombineXY?.CurrentValidatedValue ?? true;
+
+ // X/Y lines match the curve colors; combined uses a neutral theme color.
+ Sections = combined
+ ? new[] { MakeSpeedLine(sample.Combined, themeService.GetCachedColor(AxisLabelsBrush)) }
+ : new[]
+ {
+ MakeSpeedLine(sample.X, SKColors.CornflowerBlue),
+ MakeSpeedLine(sample.Y, SKColors.OrangeRed),
+ };
+ OnPropertyChanged(nameof(Sections));
+ }
+
+ // Republishes section(s) at the current eased positions. Called on init and
+ // when the combine-X/Y mode or show toggle changes.
+ private void RebuildSpeedSections() =>
+ PublishSpeedSections(new MouseSpeedSample(dispSpeedX, dispSpeedY, dispSpeedCombined));
+
+ // Poller callback (UI thread): record the new target; the tween eases toward it.
+ private void ApplySpeedSample(MouseSpeedSample sample)
+ {
+ targetSpeedX = sample.X;
+ targetSpeedY = sample.Y;
+ targetSpeedCombined = sample.Combined;
+ EnsureTweenRunning();
+ }
+
+ // Starts the tween pump if there's anything to animate; no-op once settled.
+ private void EnsureTweenRunning()
+ {
+ if (!IsInteractiveMode || !ShowSpeedLines) return;
+ if (IsSpeedSettled()) return;
+
+ if (tweenTimer == null)
+ {
+ tweenTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TweenIntervalMs) };
+ tweenTimer.Tick += OnTweenTick;
+ }
+ if (!tweenTimer.IsEnabled)
+ {
+ lastTweenTick = DateTime.UtcNow;
+ tweenTimer.Start();
+ }
+ }
+
+ private void StopTween() => tweenTimer?.Stop();
+
+ // True when every axis' displayed position has effectively reached its target.
+ private bool IsSpeedSettled() =>
+ SpeedAxisSettled(dispSpeedX, targetSpeedX) &&
+ SpeedAxisSettled(dispSpeedY, targetSpeedY) &&
+ SpeedAxisSettled(dispSpeedCombined, targetSpeedCombined);
+
+ private static bool SpeedAxisSettled(double disp, double target) =>
+ Math.Abs(disp - target) < SpeedSettleEpsilon;
+
+ // Frame-rate-independent exponential ease toward target; a fading line
+ // (target <= 0) snaps to 0 so MakeSpeedLine hides it.
+ private static double EaseSpeedAxis(double disp, double target, double alpha)
+ {
+ double next = disp + (target - disp) * alpha;
+ if (target <= 0 && next < SpeedSettleEpsilon) next = 0;
+ return next;
+ }
+
+ private void OnTweenTick(object? sender, EventArgs e)
+ {
+ var now = DateTime.UtcNow;
+ double dtMs = (now - lastTweenTick).TotalMilliseconds;
+ lastTweenTick = now;
+ if (dtMs <= 0) dtMs = TweenIntervalMs;
+
+ double alpha = 1.0 - Math.Exp(-dtMs / TweenTimeConstantMs);
+ if (alpha < 0) alpha = 0;
+ else if (alpha > 1) alpha = 1;
+
+ dispSpeedX = EaseSpeedAxis(dispSpeedX, targetSpeedX, alpha);
+ dispSpeedY = EaseSpeedAxis(dispSpeedY, targetSpeedY, alpha);
+ dispSpeedCombined = EaseSpeedAxis(dispSpeedCombined, targetSpeedCombined, alpha);
+
+ PublishSpeedSections(new MouseSpeedSample(dispSpeedX, dispSpeedY, dispSpeedCombined));
+
+ if (IsSpeedSettled())
+ {
+ // Snap off residual sub-epsilon error, then idle until the next target.
+ dispSpeedX = targetSpeedX;
+ dispSpeedY = targetSpeedY;
+ dispSpeedCombined = targetSpeedCombined;
+ StopTween();
+ }
+ }
+
+ private void StartSpeedPollingIfPossible()
+ {
+ if (IsInteractiveMode)
+ {
+ speedPoller.Start(ApplySpeedSample);
+ }
+ }
+
// ================================================================================================
// EVENT HANDLERS
// ================================================================================================
private void OnYXRatioChanged(object? sender, PropertyChangedEventArgs e)
{
- if (e.PropertyName == nameof(EditableSetting.CurrentValidatedValue))
+ if (e.PropertyName == nameof(IEditableSettingSpecific.CurrentValidatedValue))
{
CreateSeries();
OnPropertyChanged(nameof(Series));
}
}
+ private void OnCombineXYChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ // ModelValue is the observable property that raises change events;
+ // CurrentValidatedValue is a plain getter that never notifies.
+ if (e.PropertyName != nameof(IEditableSettingSpecific.ModelValue))
+ return;
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(RebuildSpeedSections);
+ }
+
// ================================================================================================
// CHART AXES CREATION
// ================================================================================================
@@ -598,6 +824,8 @@ private void OnThemeChanged(object? sender, EventArgs e)
{
TooltipTextPaint.Color = themeService.GetCachedColor(AxisTitleBrush);
TooltipBackgroundPaint.Color = themeService.GetCachedColor(TooltipBackgroundBrush).WithAlpha(TooltipBackgroundAlpha);
+ // The combined-speed line reads its theme color fresh on each poll
+ // (see PublishSpeedSections), so no paint update is needed here.
var currentXMin = XAxes?[0]?.MinLimit;
var currentXMax = XAxes?[0]?.MaxLimit;
diff --git a/userinterface/Views/MainWindow.axaml.cs b/userinterface/Views/MainWindow.axaml.cs
index d49df169..4631aefa 100644
--- a/userinterface/Views/MainWindow.axaml.cs
+++ b/userinterface/Views/MainWindow.axaml.cs
@@ -90,10 +90,7 @@ public async void ApplyButtonHandler(object? sender, RoutedEventArgs args)
LoadingProgressBar.IsVisible = true;
}
- if (viewModel.ApplyCommand.CanExecute(null))
- {
- viewModel.ApplyCommand.Execute(null);
- }
+ bool success = viewModel.Apply();
await Task.Delay(1000);
@@ -102,7 +99,14 @@ public async void ApplyButtonHandler(object? sender, RoutedEventArgs args)
LoadingProgressBar.IsVisible = false;
}
- NotificationService.ShowSuccessToast("MainWindowSettingsAppliedSuccess");
+ if (success)
+ {
+ NotificationService.ShowSuccessToast("MainWindowSettingsAppliedSuccess");
+ }
+ else
+ {
+ NotificationService.ShowErrorToast("MainWindowSettingsAppliedFailure");
+ }
if (ApplyButtonControl != null)
{
diff --git a/userinterface/Views/Profile/AnisotropyProfileSettingsView.axaml b/userinterface/Views/Profile/AnisotropyProfileSettingsView.axaml
index fe4dd823..3c502f36 100644
--- a/userinterface/Views/Profile/AnisotropyProfileSettingsView.axaml
+++ b/userinterface/Views/Profile/AnisotropyProfileSettingsView.axaml
@@ -73,6 +73,8 @@
+
+
diff --git a/userinterface/Views/Profile/ProfileChartView.axaml b/userinterface/Views/Profile/ProfileChartView.axaml
index 90a8386f..570df364 100644
--- a/userinterface/Views/Profile/ProfileChartView.axaml
+++ b/userinterface/Views/Profile/ProfileChartView.axaml
@@ -82,9 +82,11 @@
Classes="interactive-chart"
SyncContext="{Binding Sync}"
Series="{Binding Series}"
+ Sections="{Binding Sections, Mode=OneWay}"
XAxes="{Binding XAxes}"
YAxes="{Binding YAxes}"
TooltipPosition="Top"
+ TooltipFindingStrategy="CompareOnlyXTakeClosest"
TooltipTextPaint="{Binding TooltipTextPaint}"
TooltipBackgroundPaint="{Binding TooltipBackgroundPaint}"
TooltipTextSize="12"
@@ -108,6 +110,12 @@
ToolTip.Tip="{ext:Localized ProfileFitToData}">
+
diff --git a/userinterface/userinterface.csproj b/userinterface/userinterface.csproj
index a2d034f6..ccab82c4 100644
--- a/userinterface/userinterface.csproj
+++ b/userinterface/userinterface.csproj
@@ -1,17 +1,21 @@
WinExe
- net8.0-windows10.0.22621.0
+ net8.0-windows10.0.22621.0
+ net8.0
enable
- true
true
rawaccel
- Assets\mouse.ico
- app.manifest
..\x64\
x64
+
+ true
+ Assets\mouse.ico
+ app.manifest
+
+
diff --git a/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs b/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs
new file mode 100644
index 00000000..535dc149
--- /dev/null
+++ b/userspace-backend-tests/DisplayTests/CurvePreviewDisposalTests.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using RawAccel.Contracts;
+using userspace_backend.Display;
+using userspace_backend.Driver;
+
+namespace userspace_backend_tests.DisplayTests
+{
+ // Regression tests for the IAccelInstance disposal contract. With no IDisposable,
+ // CurvePreview.GeneratePoints leaked a native ra_curve handle (Linux ShimInstance)
+ // per refresh. These pin dispose-what-you-create and that IDisposable stays.
+ [TestClass]
+ public class CurvePreviewDisposalTests
+ {
+ private sealed class FakeInstance : IAccelInstance
+ {
+ public int AccelerateCount { get; private set; }
+ public int DisposeCount { get; private set; }
+ public bool ThrowOnAccelerate { get; set; }
+
+ public (double x, double y) Accelerate(
+ double x, double y, double dpiFactor, double timeMs)
+ {
+ AccelerateCount++;
+ if (ThrowOnAccelerate) throw new InvalidOperationException("boom");
+ return (x, y);
+ }
+
+ public void Dispose() => DisposeCount++;
+ }
+
+ private sealed class FakeEvaluator : IAccelEvaluator
+ {
+ public List Created { get; } = new();
+ public bool NextThrowsOnAccelerate { get; set; }
+
+ public IAccelInstance CreateInstance(RawAccelProfile profile)
+ {
+ var instance = new FakeInstance { ThrowOnAccelerate = NextThrowsOnAccelerate };
+ Created.Add(instance);
+ return instance;
+ }
+ }
+
+ [TestMethod]
+ public void IAccelInstance_ImplementsIDisposable()
+ {
+ // Guards the contract: removing ": IDisposable" from the interface
+ // (which reintroduces the leak) fails here instead of silently.
+ Assert.IsTrue(typeof(IDisposable).IsAssignableFrom(typeof(IAccelInstance)));
+ }
+
+ [TestMethod]
+ public void GeneratePoints_DisposesInstanceItCreated()
+ {
+ var evaluator = new FakeEvaluator();
+ var preview = new CurvePreview(evaluator);
+
+ preview.GeneratePoints(new RawAccelProfile());
+
+ Assert.AreEqual(1, evaluator.Created.Count, "expected one instance per call");
+ FakeInstance instance = evaluator.Created[0];
+ Assert.IsTrue(instance.AccelerateCount > 0, "instance should have been used");
+ Assert.AreEqual(1, instance.DisposeCount, "instance must be disposed exactly once");
+ }
+
+ [TestMethod]
+ public void GeneratePoints_DisposesOneInstancePerCall()
+ {
+ var evaluator = new FakeEvaluator();
+ var preview = new CurvePreview(evaluator);
+
+ const int refreshes = 5;
+ for (int i = 0; i < refreshes; i++)
+ {
+ preview.GeneratePoints(new RawAccelProfile());
+ }
+
+ Assert.AreEqual(refreshes, evaluator.Created.Count);
+ foreach (FakeInstance instance in evaluator.Created)
+ {
+ Assert.AreEqual(1, instance.DisposeCount,
+ "every refresh's instance must be disposed exactly once");
+ }
+ }
+
+ [TestMethod]
+ public void GeneratePoints_DisposesInstance_WhenAccelerateThrows()
+ {
+ // Proves the call site uses using/try-finally, not just a trailing
+ // Dispose() that an exception would skip past.
+ var evaluator = new FakeEvaluator { NextThrowsOnAccelerate = true };
+ var preview = new CurvePreview(evaluator);
+ preview.SetPoints(new[] { new CurvePoint { MouseSpeed = 5.0 } });
+
+ Assert.ThrowsException(
+ () => preview.GeneratePoints(new RawAccelProfile()));
+
+ Assert.AreEqual(1, evaluator.Created.Count);
+ Assert.AreEqual(1, evaluator.Created[0].DisposeCount,
+ "instance must be disposed even when evaluation throws");
+ }
+ }
+}
diff --git a/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs b/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs
new file mode 100644
index 00000000..4d92c9d9
--- /dev/null
+++ b/userspace-backend-tests/IOTests/BackEndLoaderRoundTripTests.cs
@@ -0,0 +1,231 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using userspace_backend;
+using userspace_backend.Data;
+using userspace_backend.Data.Profiles;
+using userspace_backend.Data.Profiles.Accel;
+using userspace_backend.Data.Profiles.Accel.Formula;
+using userspace_backend.IO;
+
+namespace userspace_backend_tests.IOTests
+{
+ // End-to-end: write devices/mappings/profiles/settings via BackEndLoader, then
+ // read them back with a fresh loader and verify values survive. Catches files
+ // that silently fail to write or fields dropped by the serializer.
+ [TestClass]
+ public class BackEndLoaderRoundTripTests
+ {
+ private string tempDir = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ tempDir = Path.Combine(Path.GetTempPath(),
+ $"rawaccel-roundtrip-{System.Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempDir);
+ }
+
+ [TestCleanup]
+ public void Teardown()
+ {
+ if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
+ }
+
+ private BackEndLoader MakeLoader() => new(
+ tempDir,
+ new DevicesReaderWriter(),
+ new MappingsReaderWriter(),
+ new ProfileReaderWriter(),
+ new SettingsReaderWriter());
+
+ [TestMethod]
+ public void WriteSettings_LandsOnDisk_WithCorrectValues()
+ {
+ var loader = MakeLoader();
+ var settings = new Settings
+ {
+ Theme = "Dark",
+ Language = "fr-FR",
+ ShowConfirmModals = false,
+ ShowToastNotifications = true,
+ };
+
+ loader.WriteSettings(settings);
+
+ var settingsFile = Path.Combine(tempDir, "settings.json");
+ Assert.IsTrue(File.Exists(settingsFile),
+ $"settings.json not created at {settingsFile}");
+
+ var loaded = MakeLoader().LoadSettings();
+ Assert.IsNotNull(loaded);
+ Assert.AreEqual("Dark", loaded!.Theme);
+ Assert.AreEqual("fr-FR", loaded.Language);
+ Assert.IsFalse(loaded.ShowConfirmModals);
+ Assert.IsTrue(loaded.ShowToastNotifications);
+ }
+
+ [TestMethod]
+ public void WriteProfiles_LandsOnDisk_WithClassicAccel()
+ {
+ var loader = MakeLoader();
+ var profile = new userspace_backend.Data.Profile
+ {
+ Name = "TestProfile",
+ OutputDPI = 1600,
+ YXRatio = 1.25,
+ Acceleration = new ClassicAccel
+ {
+ Acceleration = 0.05,
+ Exponent = 2.3,
+ Offset = 1.5,
+ Cap = 4.0,
+ Gain = true,
+ },
+ Hidden = new Hidden
+ {
+ RotationDegrees = 0,
+ AngleSnappingDegrees = 0,
+ LeftRightRatio = 1,
+ UpDownRatio = 1,
+ SpeedCap = 0,
+ OutputSmoothingHalfLife = 0,
+ },
+ };
+
+ var profilesDir = Path.Combine(tempDir, "profiles");
+ Directory.CreateDirectory(profilesDir);
+ var profileRW = new ProfileReaderWriter();
+ File.WriteAllText(
+ Path.Combine(profilesDir, "TestProfile.json"),
+ profileRW.Serialize(profile));
+
+ var profileFile = Path.Combine(profilesDir, "TestProfile.json");
+ Assert.IsTrue(File.Exists(profileFile),
+ $"profile JSON not created at {profileFile}");
+ var contents = File.ReadAllText(profileFile);
+ StringAssert.Contains(contents, "TestProfile");
+ StringAssert.Contains(contents, "Classic");
+
+ var loaded = MakeLoader().LoadProfiles().ToList();
+ Assert.AreEqual(1, loaded.Count);
+ var p = loaded[0];
+ Assert.AreEqual("TestProfile", p.Name);
+ Assert.AreEqual(1600, p.OutputDPI);
+ Assert.AreEqual(1.25, p.YXRatio);
+ Assert.IsInstanceOfType(p.Acceleration, typeof(ClassicAccel));
+ var ca = (ClassicAccel)p.Acceleration;
+ Assert.AreEqual(0.05, ca.Acceleration);
+ Assert.AreEqual(2.3, ca.Exponent);
+ Assert.AreEqual(1.5, ca.Offset);
+ Assert.AreEqual(4.0, ca.Cap);
+ Assert.IsTrue(ca.Gain);
+ }
+
+ [TestMethod]
+ public void WriteDevicesAndMappings_LandOnDisk()
+ {
+ var devicesRW = new DevicesReaderWriter();
+ var mappingsRW = new MappingsReaderWriter();
+
+ var devices = new List
+ {
+ new()
+ {
+ Name = "Test Mouse",
+ HWID = "HID\\VID_ABCD&PID_1234",
+ DPI = 1600,
+ PollingRate = 1000,
+ DeviceGroup = "Default",
+ },
+ };
+
+ File.WriteAllText(
+ Path.Combine(tempDir, "devices.json"),
+ devicesRW.Serialize(devices));
+
+ var mappings = new MappingSet
+ {
+ Mappings = new[]
+ {
+ new Mapping
+ {
+ Name = "Default",
+ GroupsToProfiles = new Mapping.GroupsToProfilesMapping
+ {
+ { "Default", "TestProfile" },
+ },
+ },
+ },
+ ActiveMappingIndex = 0,
+ };
+
+ File.WriteAllText(
+ Path.Combine(tempDir, "mappings.json"),
+ mappingsRW.Serialize(mappings));
+
+ Assert.IsTrue(File.Exists(Path.Combine(tempDir, "devices.json")));
+ Assert.IsTrue(File.Exists(Path.Combine(tempDir, "mappings.json")));
+
+ var fresh = MakeLoader();
+ var loadedDevices = fresh.LoadDevices().ToList();
+ Assert.AreEqual(1, loadedDevices.Count);
+ Assert.AreEqual("Test Mouse", loadedDevices[0].Name);
+ Assert.AreEqual(1600, loadedDevices[0].DPI);
+
+ var loadedMappings = fresh.LoadMappings();
+ Assert.AreEqual(1, loadedMappings.Mappings.Length);
+ Assert.AreEqual("Default", loadedMappings.Mappings[0].Name);
+ Assert.AreEqual("TestProfile",
+ loadedMappings.Mappings[0].GroupsToProfiles["Default"]);
+ }
+
+ [TestMethod]
+ public void LoadSettings_ReturnsNull_WhenFileMissing()
+ {
+ var loader = MakeLoader();
+ Assert.IsNull(loader.LoadSettings());
+ }
+
+ // Regression: Profile.Acceleration serialized as the base class (no curve
+ // params or formula discriminator), so a Classic profile round-tripped back
+ // as NoAcceleration with all settings lost. Prove the fields now survive.
+ [TestMethod]
+ public void Profile_ClassicAccel_SurvivesRoundTrip()
+ {
+ var profile = new userspace_backend.Data.Profile
+ {
+ Name = "Classic",
+ OutputDPI = 1600,
+ YXRatio = 1,
+ Acceleration = new ClassicAccel
+ {
+ Acceleration = 0.05,
+ Exponent = 2.3,
+ Offset = 1.5,
+ Cap = 4.0,
+ Gain = true,
+ },
+ Hidden = new Hidden(),
+ };
+
+ var rw = new ProfileReaderWriter();
+ string json = rw.Serialize(profile);
+
+ StringAssert.Contains(json, "Formula/Classic",
+ "Serialized profile missing formula discriminator: " + json);
+ StringAssert.Contains(json, "\"Exponent\"",
+ "Serialized profile missing curve params: " + json);
+
+ var roundTripped = rw.Deserialize(json);
+ Assert.IsInstanceOfType(roundTripped.Acceleration, typeof(ClassicAccel));
+ var ca = (ClassicAccel)roundTripped.Acceleration;
+ Assert.AreEqual(0.05, ca.Acceleration);
+ Assert.AreEqual(2.3, ca.Exponent);
+ Assert.AreEqual(1.5, ca.Offset);
+ Assert.AreEqual(4.0, ca.Cap);
+ Assert.IsTrue(ca.Gain);
+ }
+ }
+}
diff --git a/userspace-backend-tests/IOTests/DevicesReaderWriterTests.cs b/userspace-backend-tests/IOTests/DevicesReaderWriterTests.cs
index 6b191a58..399c1151 100644
--- a/userspace-backend-tests/IOTests/DevicesReaderWriterTests.cs
+++ b/userspace-backend-tests/IOTests/DevicesReaderWriterTests.cs
@@ -11,7 +11,7 @@ namespace userspace_backend_tests.IOTests
[TestClass]
public class DevicesReaderWriterTests
{
- public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), @"TestFiles\DevicesReaderWriter");
+ public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestFiles", "DevicesReaderWriter");
public static string ExpectedOutputs = Path.Combine(TestDirectory, "ExpectedOutputs");
public static string TestInputs = Path.Combine(TestDirectory, "Inputs");
diff --git a/userspace-backend-tests/IOTests/MappingReaderWriterTests.cs b/userspace-backend-tests/IOTests/MappingReaderWriterTests.cs
index 0a5149bf..3e2826c6 100644
--- a/userspace-backend-tests/IOTests/MappingReaderWriterTests.cs
+++ b/userspace-backend-tests/IOTests/MappingReaderWriterTests.cs
@@ -13,7 +13,7 @@ namespace userspace_backend_tests.IOTests
[TestClass]
public class MappingReaderWriterTests
{
- public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), @"TestFiles\MappingReaderWriter");
+ public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestFiles", "MappingReaderWriter");
public static string ExpectedOutputs = Path.Combine(TestDirectory, "ExpectedOutputs");
public static string TestInputs = Path.Combine(TestDirectory, "Inputs");
diff --git a/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs b/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs
index b268735b..7276d809 100644
--- a/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs
+++ b/userspace-backend-tests/IOTests/ProfileReaderWriterTests.cs
@@ -15,7 +15,7 @@ namespace userspace_backend_tests.IOTests
[TestClass]
public class ProfileReaderWriterTests
{
- public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), @"TestFiles\ProfileReaderWriter");
+ public static string TestDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestFiles", "ProfileReaderWriter");
public static string ExpectedOutputs = Path.Combine(TestDirectory, "ExpectedOutputs");
public static string TestInputs = Path.Combine(TestDirectory, "Inputs");
@@ -27,9 +27,8 @@ public void GivenValidInput_Writes()
Name = "default",
OutputDPI = 1200,
YXRatio = 1.3333,
- Acceleration = new Acceleration()
+ Acceleration = new NoAcceleration()
{
- Type = Acceleration.AccelerationDefinitionType.None,
Anisotropy = new Anisotropy()
{
Domain = new Vector2()
diff --git a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs
index beb8dd8b..8d7498bb 100644
--- a/userspace-backend-tests/ModelTests/BackEndApplyTests.cs
+++ b/userspace-backend-tests/ModelTests/BackEndApplyTests.cs
@@ -1,15 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
+using RawAccel.Contracts;
using System;
using System.Collections.Generic;
using System.Linq;
using userspace_backend;
using userspace_backend.Data.Profiles;
using userspace_backend.Data.Profiles.Accel;
+using userspace_backend.Data.Profiles.Accel.Formula;
+using userspace_backend.Driver;
using userspace_backend.IO;
using userspace_backend.Model;
using userspace_backend.Model.AccelDefinitions;
using userspace_backend.Model.AccelDefinitions.Formula;
+using userspace_backend.Model.EditableSettings;
using DATA = userspace_backend.Data;
namespace userspace_backend_tests.ModelTests
@@ -56,19 +60,43 @@ private sealed class StubSystemDevice : ISystemDevice
public string HWID { get; init; } = string.Empty;
}
- private sealed class CapturingDriverConfigActivator : IDriverConfigActivator
+ // Captures what BackEnd hands its driver. Cross-platform IRawAccelDriver so
+ // tests run on Windows and Linux without wrapper.dll or the agent socket.
+ private sealed class CapturingDriver : IRawAccelDriver
{
- public DriverConfig? CapturedConfig { get; private set; }
- public int WriteCount { get; private set; }
+ public RawAccelConfig? CapturedConfig { get; private set; }
+ public int ApplyCount { get; private set; }
- public void Write(DriverConfig config)
+ public bool IsAvailable => true;
+
+ public bool Apply(RawAccelConfig config)
{
CapturedConfig = config;
- WriteCount++;
+ ApplyCount++;
+ return true;
+ }
+
+ public RawAccelConfig Read() => CapturedConfig ?? new RawAccelConfig();
+
+ public void Deactivate() { }
+
+ public MouseSpeedSample GetCurrentMouseSpeedSample() => MouseSpeedSample.Zero;
+ }
+
+ private sealed class FakeAccelEvaluator : IAccelEvaluator
+ {
+ public IAccelInstance CreateInstance(RawAccelProfile profile) => new IdentityInstance();
+
+ private sealed class IdentityInstance : IAccelInstance
+ {
+ public (double x, double y) Accelerate(
+ double x, double y, double dpiFactor, double timeMs) => (x, y);
+
+ public void Dispose() { }
}
}
- private static (IBackEnd backEnd, CapturingDriverConfigActivator activator) BuildBackEndWithDefaults(
+ private static (IBackEnd backEnd, CapturingDriver driver) BuildBackEndWithDefaults(
IList? systemDevices = null)
{
var services = new ServiceCollection();
@@ -78,26 +106,115 @@ private static (IBackEnd backEnd, CapturingDriverConfigActivator activator) Buil
{
Devices = systemDevices ?? new List(),
});
- var activator = new CapturingDriverConfigActivator();
- services.AddSingleton(activator);
+ var driver = new CapturingDriver();
+ services.AddSingleton(driver);
+ services.AddSingleton(new FakeAccelEvaluator());
var sp = BackEndComposer.Compose(services);
var backEnd = sp.GetRequiredService();
backEnd.Load();
- return (backEnd, activator);
+ return (backEnd, driver);
}
- private static DriverConfig ApplyAndCapture(IBackEnd backEnd, CapturingDriverConfigActivator activator)
+ private static RawAccelConfig ApplyAndCapture(IBackEnd backEnd, CapturingDriver driver)
{
backEnd.Apply();
- Assert.IsNotNull(activator.CapturedConfig, "Apply should have written a DriverConfig to the activator.");
- return activator.CapturedConfig!;
+ Assert.IsNotNull(driver.CapturedConfig, "Apply should have handed a RawAccelConfig to the driver.");
+ return driver.CapturedConfig!;
+ }
+
+ [TestMethod]
+ public void FormulaDIKeys_AreDistinctPerFormula_SoExponentDefaultsDoNotCollide()
+ {
+ // Regression: Power/Jump keyed their DI settings under
+ // nameof(ClassicAccelerationDefinitionModel), so last-registration-wins
+ // made Classic's Exponent resolve to Power's default (0.05, not 2).
+ Assert.AreNotEqual(
+ ClassicAccelerationDefinitionModel.ExponentDIKey,
+ PowerAccelerationDefinitionModel.ExponentDIKey,
+ "Classic and Power exponent DI keys must be distinct.");
+ Assert.AreNotEqual(
+ ClassicAccelerationDefinitionModel.CapDIKey,
+ PowerAccelerationDefinitionModel.CapDIKey,
+ "Classic and Power cap DI keys must be distinct.");
+
+ var services = new ServiceCollection();
+ services.AddSingleton(new StubBackEndLoader());
+ services.AddSingleton(new StubSystemDevicesRetriever());
+ services.AddSingleton(new CapturingDriver());
+ services.AddSingleton(new FakeAccelEvaluator());
+ var sp = BackEndComposer.Compose(services);
+
+ var classicExponent = sp.GetRequiredKeyedService>(
+ ClassicAccelerationDefinitionModel.ExponentDIKey);
+ var powerExponent = sp.GetRequiredKeyedService>(
+ PowerAccelerationDefinitionModel.ExponentDIKey);
+
+ Assert.AreEqual(2.0, classicExponent.ModelValue, "Classic exponent default should be 2.");
+ Assert.AreEqual(0.05, powerExponent.ModelValue, "Power exponent default should be 0.05.");
+ }
+
+ [TestMethod]
+ public void RemovingReferencedProfile_ReassignsMappingToDefault()
+ {
+ var (backEnd, _) = BuildBackEndWithDefaults();
+
+ Assert.IsTrue(backEnd.Profiles.TryAddNewDefaultProfile("Gaming"));
+ backEnd.Devices.DeviceGroups.AddOrGetDeviceGroup("MyGroup");
+
+ MappingModel mapping = backEnd.Mappings.GetActiveMapping()!;
+ Assert.IsNotNull(mapping);
+ Assert.IsTrue(mapping.TryAddMapping("MyGroup", "Gaming"));
+
+ MappingGroup group = mapping.IndividualMappings.Single(g =>
+ string.Equals(g.DeviceGroup, "MyGroup", StringComparison.InvariantCultureIgnoreCase));
+ Assert.AreEqual("Gaming", group.Profile.Name.ModelValue);
+
+ // Delete the referenced profile.
+ Assert.IsTrue(backEnd.Profiles.TryGetProfile("Gaming", out IProfileModel? gaming) && gaming != null);
+ Assert.IsTrue(backEnd.Profiles.RemoveProfile(gaming!));
+
+ // The mapping entry must fall back to the default profile, not keep a dangling reference.
+ Assert.AreEqual("Default", group.Profile.Name.ModelValue);
+ }
+
+ [TestMethod]
+ public void RenamingProfileToExistingName_IsRejected_CaseInsensitive()
+ {
+ // ProfileNameValidator enforces uniqueness across profiles. A "Default"
+ // profile already exists after Load.
+ var (backEnd, _) = BuildBackEndWithDefaults();
+ Assert.IsTrue(backEnd.Profiles.TryAddNewDefaultProfile("Gaming"));
+ Assert.IsTrue(backEnd.Profiles.TryGetProfile("Gaming", out IProfileModel? gaming) && gaming != null);
+
+ Assert.IsFalse(gaming!.Name.TryUpdateModelDirectly("Default"), "Renaming onto an existing name must be rejected.");
+ Assert.IsFalse(gaming.Name.TryUpdateModelDirectly("default"), "Uniqueness must be case-insensitive.");
+ Assert.AreEqual("Gaming", gaming.Name.ModelValue);
+
+ // A genuinely unique name is still accepted.
+ Assert.IsTrue(gaming.Name.TryUpdateModelDirectly("Gaming2"));
+ Assert.AreEqual("Gaming2", gaming.Name.ModelValue);
+ }
+
+ [TestMethod]
+ public void ProfileName_EmptyOrTooLong_IsRejected()
+ {
+ // ProfileNameValidator keeps the prior non-empty / max-length guard.
+ var (backEnd, _) = BuildBackEndWithDefaults();
+ Assert.IsTrue(backEnd.Profiles.TryAddNewDefaultProfile("Gaming"));
+ Assert.IsTrue(backEnd.Profiles.TryGetProfile("Gaming", out IProfileModel? gaming) && gaming != null);
+
+ Assert.IsFalse(gaming!.Name.TryUpdateModelDirectly(string.Empty), "Empty name must be rejected.");
+ Assert.IsFalse(
+ gaming.Name.TryUpdateModelDirectly(new string('a', MaxNameLengthValidator.MaxNameLength + 1)),
+ "Name longer than the max length must be rejected.");
+ Assert.AreEqual("Gaming", gaming.Name.ModelValue);
}
[TestMethod]
public void EnsureDefaultMapping_FreshInstall_CreatesMappingWithDefaultEntry()
{
- var (backEnd, activator) = BuildBackEndWithDefaults();
+ var (backEnd, driver) = BuildBackEndWithDefaults();
Assert.IsTrue(
backEnd.Mappings.TryGetMapping("Default", out MappingModel? mapping) && mapping != null,
@@ -108,7 +225,7 @@ public void EnsureDefaultMapping_FreshInstall_CreatesMappingWithDefaultEntry()
Assert.AreEqual(DeviceGroups.DefaultDeviceGroup, mapping.IndividualMappings[0].DeviceGroup);
Assert.AreEqual("Default", mapping.IndividualMappings[0].Profile.Name.ModelValue);
- var cfg = ApplyAndCapture(backEnd, activator);
+ var cfg = ApplyAndCapture(backEnd, driver);
Assert.AreEqual(1, cfg.profiles.Count);
Assert.AreEqual(1, cfg.devices.Count);
}
@@ -121,8 +238,9 @@ public void EnsureDefaultMapping_StaleEmptyMapping_SelfHealsWithDefaultEntry()
var services = new ServiceCollection();
services.AddSingleton(staleLoader);
services.AddSingleton(new StubSystemDevicesRetriever());
- var activator = new CapturingDriverConfigActivator();
- services.AddSingleton(activator);
+ var driver = new CapturingDriver();
+ services.AddSingleton(driver);
+ services.AddSingleton(new FakeAccelEvaluator());
var sp = BackEndComposer.Compose(services);
var backEnd = sp.GetRequiredService();
backEnd.Load();
@@ -134,7 +252,7 @@ public void EnsureDefaultMapping_StaleEmptyMapping_SelfHealsWithDefaultEntry()
1, mapping!.IndividualMappings.Count,
"Stale empty Default mapping must self-heal to one DefaultDeviceGroup -> Default entry.");
- var cfg = ApplyAndCapture(backEnd, activator);
+ var cfg = ApplyAndCapture(backEnd, driver);
Assert.AreEqual(1, cfg.profiles.Count);
Assert.AreEqual(1, cfg.devices.Count);
}
@@ -168,8 +286,8 @@ public void WriteSettings(DATA.Settings settings) { }
[TestMethod]
public void Apply_DefaultState_ProducesOneProfileAndOneDevice()
{
- var (backEnd, activator) = BuildBackEndWithDefaults();
- var cfg = ApplyAndCapture(backEnd, activator);
+ var (backEnd, driver) = BuildBackEndWithDefaults();
+ var cfg = ApplyAndCapture(backEnd, driver);
Assert.AreEqual(1, cfg.profiles.Count, "Expected exactly one profile in the DriverConfig.");
Assert.AreEqual(1, cfg.devices.Count, "Expected exactly one device in the DriverConfig.");
@@ -183,8 +301,8 @@ public void Apply_DefaultState_ProducesOneProfileAndOneDevice()
[TestMethod]
public void Apply_DefaultState_DeviceReferencesDefaultProfileByName()
{
- var (backEnd, activator) = BuildBackEndWithDefaults();
- var cfg = ApplyAndCapture(backEnd, activator);
+ var (backEnd, driver) = BuildBackEndWithDefaults();
+ var cfg = ApplyAndCapture(backEnd, driver);
var device = cfg.devices[0];
var profile = cfg.profiles[0];
@@ -197,25 +315,25 @@ public void Apply_DefaultState_DeviceReferencesDefaultProfileByName()
[TestMethod]
public void Apply_ProfileOutputDpiEdit_FlowsIntoDriverConfig()
{
- var (backEnd, activator) = BuildBackEndWithDefaults();
+ var (backEnd, driver) = BuildBackEndWithDefaults();
var profile = backEnd.Profiles.Elements[0];
Assert.IsTrue(profile.OutputDPI.TryUpdateModelDirectly(1600), "OutputDPI update should succeed.");
- var cfg = ApplyAndCapture(backEnd, activator);
+ var cfg = ApplyAndCapture(backEnd, driver);
Assert.AreEqual(1600, cfg.profiles[0].outputDPI);
}
[TestMethod]
public void Apply_DeviceDpiEdit_DoesNotAffectPollingRate()
{
- var (backEnd, activator) = BuildBackEndWithDefaults();
+ var (backEnd, driver) = BuildBackEndWithDefaults();
var device = backEnd.Devices.Elements[0];
Assert.IsTrue(device.DPI.TryUpdateModelDirectly(3200), "DPI update should succeed.");
Assert.IsTrue(device.PollRate.TryUpdateModelDirectly(500), "PollRate update should succeed.");
- var cfg = ApplyAndCapture(backEnd, activator);
+ var cfg = ApplyAndCapture(backEnd, driver);
Assert.AreEqual(3200, cfg.devices[0].config.dpi);
Assert.AreEqual(500, cfg.devices[0].config.pollingRate);
}
@@ -280,8 +398,9 @@ public void ReloadSystemDevices_RemovesDisconnectedAndAddsNew()
services.AddSingleton(new StubBackEndLoader());
var retrieverStub = new StubSystemDevicesRetriever { Devices = initial };
services.AddSingleton(retrieverStub);
- services.AddSingleton(new CapturingDriverConfigActivator());
+ services.AddSingleton(new CapturingDriver());
+ services.AddSingleton(new FakeAccelEvaluator());
var sp = BackEndComposer.Compose(services);
var backEnd = sp.GetRequiredService();
backEnd.Load();
@@ -308,12 +427,10 @@ public void ReloadSystemDevices_RemovesDisconnectedAndAddsNew()
[TestMethod]
public void Apply_ProfileCurveCoefficientEdit_FlowsIntoDriverConfig()
{
- // Regression for the "stale curve on Apply" bug: editing a coefficient inside
- // the currently-selected curve sub-model (Formula -> Classic -> Acceleration)
- // must propagate through EditableSettingsSelector.AnySettingChanged up to
- // ProfileModel.RecalculateDriverData so CurrentValidatedDriverProfile refreshes
- // before BackEnd.Apply() reads it via MapToDriverConfig.
- var (backEnd, activator) = BuildBackEndWithDefaults();
+ // Regression ("stale curve on Apply"): editing a coefficient in the selected
+ // sub-model must propagate via AnySettingChanged to RecalculateDriverData so
+ // CurrentValidatedDriverProfile refreshes before Apply reads it.
+ var (backEnd, driver) = BuildBackEndWithDefaults();
var profile = backEnd.Profiles.Elements[0];
Assert.IsTrue(
@@ -337,8 +454,8 @@ public void Apply_ProfileCurveCoefficientEdit_FlowsIntoDriverConfig()
classic.Acceleration.TryUpdateModelDirectly(expectedAcceleration),
"Classic.Acceleration update should succeed.");
- var cfg = ApplyAndCapture(backEnd, activator);
- Assert.AreEqual(AccelMode.classic, cfg.profiles[0].argsX.mode,
+ var cfg = ApplyAndCapture(backEnd, driver);
+ Assert.AreEqual(RawAccel.Contracts.AccelMode.classic, cfg.profiles[0].argsX.mode,
"DriverConfig should reflect the chosen Classic formula.");
Assert.AreEqual(expectedAcceleration, cfg.profiles[0].argsX.acceleration,
"DriverConfig should reflect the tweaked Classic.Acceleration coefficient. " +
@@ -346,6 +463,290 @@ public void Apply_ProfileCurveCoefficientEdit_FlowsIntoDriverConfig()
"propagating nested sub-model changes up to ProfileModel.");
}
+ [TestMethod]
+ public void Apply_SingleCurve_PopulatesBothAxes()
+ {
+ // Regression: MapToDriver set only argsX, leaving argsY at noaccel. In the
+ // default by-component mode the native math reads Y from argsY, so vertical
+ // accel was dead. The single model curve must drive both argsX and argsY.
+ var (backEnd, driver) = BuildBackEndWithDefaults();
+ var profile = backEnd.Profiles.Elements[0];
+
+ Assert.IsTrue(
+ profile.Acceleration.DefinitionType.TryUpdateModelDirectly(
+ Acceleration.AccelerationDefinitionType.Formula),
+ "Flipping DefinitionType to Formula should succeed.");
+
+ var formulaAccel = (FormulaAccelModel)profile.Acceleration.GetSelectable(
+ Acceleration.AccelerationDefinitionType.Formula);
+
+ Assert.IsTrue(
+ formulaAccel.FormulaType.TryUpdateModelDirectly(
+ FormulaAccel.AccelerationFormulaType.Classic),
+ "Flipping FormulaType to Classic should succeed.");
+
+ var classic = (ClassicAccelerationDefinitionModel)formulaAccel.GetSelectable(
+ FormulaAccel.AccelerationFormulaType.Classic);
+
+ const double expectedAcceleration = 0.123;
+ Assert.IsTrue(
+ classic.Acceleration.TryUpdateModelDirectly(expectedAcceleration),
+ "Classic.Acceleration update should succeed.");
+
+ var cfg = ApplyAndCapture(backEnd, driver);
+
+ // X is the historically-tested axis; Y is the regression guard.
+ Assert.AreEqual(RawAccel.Contracts.AccelMode.classic, cfg.profiles[0].argsY.mode,
+ "argsY must carry the same accel mode as argsX, or vertical acceleration " +
+ "is dead in by-component mode (argsY left at the noaccel default).");
+ Assert.AreEqual(expectedAcceleration, cfg.profiles[0].argsY.acceleration,
+ "argsY must carry the same curve coefficient as argsX.");
+ Assert.AreEqual(cfg.profiles[0].argsX.mode, cfg.profiles[0].argsY.mode,
+ "The single model curve must drive both axes identically.");
+ Assert.AreEqual(cfg.profiles[0].argsX.acceleration, cfg.profiles[0].argsY.acceleration,
+ "The single model curve must drive both axes identically.");
+ }
+
+ // Regression: a saved ClassicAccel used to StackOverflow on Load via
+ // EditableSettingsSelectable.TryMapFromData recursing into itself.
+ private sealed class ClassicAccelLoader : IBackEndLoader
+ {
+ public IEnumerable LoadDevices() => Array.Empty();
+ public DATA.MappingSet LoadMappings() => new DATA.MappingSet
+ {
+ Mappings = Array.Empty(),
+ ActiveMappingIndex = 0,
+ };
+ public IEnumerable LoadProfiles() => new[]
+ {
+ new DATA.Profile
+ {
+ Name = "Default",
+ OutputDPI = 1000,
+ YXRatio = 1,
+ Acceleration = new ClassicAccel
+ {
+ Acceleration = 0.05,
+ Exponent = 2.3,
+ Offset = 1.5,
+ Cap = 4.0,
+ Gain = true,
+ },
+ Hidden = new Hidden(),
+ },
+ };
+ public DATA.Settings? LoadSettings() => null;
+ public void WriteSettingsToDisk(
+ IEnumerable devices,
+ MappingsModel mappings,
+ IEnumerable profiles) { }
+ public void WriteSettings(DATA.Settings settings) { }
+ }
+
+ [TestMethod]
+ public void Load_ProfileWithClassicAccel_DoesNotRecurse()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(new ClassicAccelLoader());
+ services.AddSingleton(new StubSystemDevicesRetriever());
+ services.AddSingleton(new CapturingDriver());
+ services.AddSingleton(new FakeAccelEvaluator());
+ var sp = BackEndComposer.Compose(services);
+ var backEnd = sp.GetRequiredService();
+
+ backEnd.Load();
+
+ var profile = backEnd.Profiles.Elements.Single();
+ Assert.AreEqual(Acceleration.AccelerationDefinitionType.Formula,
+ profile.Acceleration.DefinitionType.ModelValue);
+ var formula = (FormulaAccelModel)profile.Acceleration.GetSelectable(
+ Acceleration.AccelerationDefinitionType.Formula);
+ Assert.AreEqual(FormulaAccel.AccelerationFormulaType.Classic,
+ formula.FormulaType.ModelValue);
+ var classic = (ClassicAccelerationDefinitionModel)formula.GetSelectable(
+ FormulaAccel.AccelerationFormulaType.Classic);
+ Assert.AreEqual(0.05, classic.Acceleration.ModelValue);
+ Assert.AreEqual(2.3, classic.Exponent.ModelValue);
+ Assert.AreEqual(1.5, classic.Offset.ModelValue);
+ Assert.AreEqual(4.0, classic.Cap.ModelValue);
+ }
+
+ // Regression: an old fallback wrote Anisotropy Domain/Range = {0,0} for a
+ // missing block, flattening the preview curve (domain=0 -> input 0, range=0
+ // -> scale 1). Loading legacy all-zero Anisotropy must sanitize to identity.
+ private sealed class ZeroAnisotropyLoader : IBackEndLoader
+ {
+ public IEnumerable LoadDevices() => Array.Empty();
+ public DATA.MappingSet LoadMappings() => new DATA.MappingSet
+ {
+ Mappings = Array.Empty(),
+ ActiveMappingIndex = 0,
+ };
+ public IEnumerable LoadProfiles() => new[]
+ {
+ new DATA.Profile
+ {
+ Name = "Default",
+ OutputDPI = 1000,
+ YXRatio = 1,
+ Acceleration = new ClassicAccel
+ {
+ Acceleration = 0.01,
+ Anisotropy = new Anisotropy
+ {
+ Domain = new Vector2 { X = 0, Y = 0 },
+ Range = new Vector2 { X = 0, Y = 0 },
+ LPNorm = 2,
+ CombineXYComponents = false,
+ },
+ },
+ Hidden = new Hidden(),
+ },
+ };
+ public DATA.Settings? LoadSettings() => null;
+ public void WriteSettingsToDisk(
+ IEnumerable devices,
+ MappingsModel mappings,
+ IEnumerable profiles) { }
+ public void WriteSettings(DATA.Settings settings) { }
+ }
+
+ [TestMethod]
+ public void Load_ProfileWithZeroAnisotropy_SanitizesToIdentityWeights()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(new ZeroAnisotropyLoader());
+ services.AddSingleton(new StubSystemDevicesRetriever());
+ services.AddSingleton(new CapturingDriver());
+ services.AddSingleton(new FakeAccelEvaluator());
+ var sp = BackEndComposer.Compose(services);
+ var backEnd = sp.GetRequiredService();
+
+ backEnd.Load();
+
+ var aniso = backEnd.Profiles.Elements.Single().Acceleration.Anisotropy;
+ Assert.AreEqual(1.0, aniso.DomainX.ModelValue,
+ "DomainX must be sanitized to identity; zero collapses input speed to 0.");
+ Assert.AreEqual(1.0, aniso.DomainY.ModelValue);
+ Assert.AreEqual(1.0, aniso.RangeX.ModelValue,
+ "RangeX must be sanitized to identity; zero collapses curve scale to 1.");
+ Assert.AreEqual(1.0, aniso.RangeY.ModelValue);
+ }
+
+ // Boot with a Default profile of Type=None, switch
+ // DefinitionType to Formula, pick Classic, edit a coefficient. The chained
+ // Apply must see the Classic args in the driver's RawAccelConfig.
+ [TestMethod]
+ public void TypeChange_FromNoneToClassic_FlowsThroughApply()
+ {
+ var (backEnd, driver) = BuildBackEndWithDefaults();
+ var profile = backEnd.Profiles.Elements[0];
+
+ Assert.AreEqual(Acceleration.AccelerationDefinitionType.None,
+ profile.Acceleration.DefinitionType.ModelValue,
+ "Test precondition: fresh-install profile is Type=None.");
+
+ Assert.IsTrue(profile.Acceleration.DefinitionType.TryUpdateModelDirectly(
+ Acceleration.AccelerationDefinitionType.Formula));
+
+ var formula = (FormulaAccelModel)profile.Acceleration.GetSelectable(
+ Acceleration.AccelerationDefinitionType.Formula);
+
+ Assert.IsTrue(formula.FormulaType.TryUpdateModelDirectly(
+ FormulaAccel.AccelerationFormulaType.Classic));
+
+ var classic = (ClassicAccelerationDefinitionModel)formula.GetSelectable(
+ FormulaAccel.AccelerationFormulaType.Classic);
+ Assert.IsTrue(classic.Acceleration.TryUpdateModelDirectly(0.42));
+
+ var cfg = ApplyAndCapture(backEnd, driver);
+ Assert.AreEqual(RawAccel.Contracts.AccelMode.classic, cfg.profiles[0].argsX.mode,
+ "Apply must see Classic mode after the user-driven type change.");
+ Assert.AreEqual(0.42, cfg.profiles[0].argsX.acceleration,
+ "Apply must see the edited Classic coefficient, not stale state.");
+ }
+
+ // Regression: a user-created device group (e.g. "DeviceGroup0") was lost on
+ // reload because DeviceGroupModels (the master list behind the UI dropdown and
+ // TryAddMapping) is never rehydrated from devices.json/mappings.json. The
+ // dropdown showed only "Default" and TryAddMapping rejected the saved mapping.
+ private sealed class CustomDeviceGroupLoader : IBackEndLoader
+ {
+ public IEnumerable LoadDevices() => new[]
+ {
+ new DATA.Device
+ {
+ Name = "Mouse In Custom Group",
+ HWID = @"HID\VID_1111&PID_2222",
+ DPI = 1000,
+ PollingRate = 1000,
+ DeviceGroup = "DeviceGroup0",
+ },
+ };
+ public DATA.MappingSet LoadMappings() => new DATA.MappingSet
+ {
+ Mappings = new[]
+ {
+ new DATA.Mapping
+ {
+ Name = "Default",
+ GroupsToProfiles = new DATA.Mapping.GroupsToProfilesMapping
+ {
+ { "Default", "Default" },
+ { "DeviceGroup0", "Default" },
+ },
+ },
+ },
+ ActiveMappingIndex = 0,
+ };
+ public IEnumerable LoadProfiles() => new[]
+ {
+ new DATA.Profile
+ {
+ Name = "Default",
+ OutputDPI = 1000,
+ YXRatio = 1,
+ Acceleration = new userspace_backend.Data.Profiles.Accel.NoAcceleration(),
+ Hidden = new Hidden(),
+ },
+ };
+ public DATA.Settings? LoadSettings() => null;
+ public void WriteSettingsToDisk(
+ IEnumerable devices,
+ MappingsModel mappings,
+ IEnumerable profiles) { }
+ public void WriteSettings(DATA.Settings settings) { }
+ }
+
+ [TestMethod]
+ public void Load_CustomDeviceGroupInDevicesAndMappings_RestoresGroupList()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(new CustomDeviceGroupLoader());
+ services.AddSingleton(new StubSystemDevicesRetriever());
+ services.AddSingleton(new CapturingDriver());
+ services.AddSingleton(new FakeAccelEvaluator());
+ var sp = BackEndComposer.Compose(services);
+ var backEnd = sp.GetRequiredService();
+
+ backEnd.Load();
+
+ CollectionAssert.Contains(
+ backEnd.Devices.DeviceGroups.DeviceGroupModels,
+ "DeviceGroup0",
+ "DeviceGroup0 should be restored into the master list from devices.json / mappings.json.");
+ CollectionAssert.Contains(
+ backEnd.Devices.DeviceGroups.DeviceGroupModels,
+ DeviceGroups.DefaultDeviceGroup);
+
+ Assert.IsTrue(
+ backEnd.Mappings.TryGetMapping("Default", out MappingModel? mapping) && mapping != null);
+ var groupNames = mapping!.IndividualMappings.Select(m => m.DeviceGroup).ToList();
+ CollectionAssert.Contains(groupNames, "DeviceGroup0",
+ "MappingModel must keep the DeviceGroup0 row after Load (was silently dropped before restore).");
+ CollectionAssert.Contains(groupNames, DeviceGroups.DefaultDeviceGroup);
+ }
+
[TestMethod]
public void ImportSystemDevices_SyncsInterfaceValueSoUiReflectsRealValues()
{
diff --git a/userspace-backend-tests/ModelTests/EditableSettingsListTests.cs b/userspace-backend-tests/ModelTests/EditableSettingsListTests.cs
index 2eaf163b..141a61d7 100644
--- a/userspace-backend-tests/ModelTests/EditableSettingsListTests.cs
+++ b/userspace-backend-tests/ModelTests/EditableSettingsListTests.cs
@@ -222,6 +222,36 @@ public void EditableSettingsList_AddRemoveElements()
Assert.AreEqual(2, testObject.Elements.Count);
}
+ [TestMethod]
+ public void EditableSettingsList_MapFromData_RemovesStaleElementsAndUpdatesKept()
+ {
+ // Regression: the list previously only added/updated on data load and
+ // never removed elements that the new data no longer contained.
+ (IEditableSettingsTestList testObject, _) = InitTestObject("Property", 2, "Name", "seed");
+
+ testObject.TryMapFromData(new[]
+ {
+ new TestData { Name = "A", Property = 1 },
+ new TestData { Name = "B", Property = 2 },
+ new TestData { Name = "C", Property = 3 },
+ });
+ Assert.AreEqual(3, testObject.Elements.Count);
+
+ // Reload with a subset: C must be dropped, A and B kept and updated.
+ testObject.TryMapFromData(new[]
+ {
+ new TestData { Name = "A", Property = 10 },
+ new TestData { Name = "B", Property = 20 },
+ });
+
+ Assert.AreEqual(2, testObject.Elements.Count);
+ CollectionAssert.AreEquivalent(
+ new[] { "A", "B" },
+ testObject.Elements.Select(e => e.NameSetting.ModelValue).ToList());
+ Assert.IsFalse(testObject.TryGetElement("C", out _), "Stale element C should have been removed.");
+ Assert.IsTrue(testObject.TryGetElement("A", out var a) && a!.PropertySetting.ModelValue == 10);
+ }
+
#endregion Tests
}
}
diff --git a/userspace-backend-tests/ModelTests/EditableSettingsTests.cs b/userspace-backend-tests/ModelTests/EditableSettingsTests.cs
index 517e06ca..56495f60 100644
--- a/userspace-backend-tests/ModelTests/EditableSettingsTests.cs
+++ b/userspace-backend-tests/ModelTests/EditableSettingsTests.cs
@@ -138,6 +138,20 @@ void TestObject_PropertyChanged(object? sender, System.ComponentModel.PropertyCh
Assert.AreEqual(0, propertyChangedHookCalls);
}
+ [TestMethod]
+ public void EditableSetting_HasChanged_ReflectsWhetherValueDiffersFromLastWritten()
+ {
+ // Regression: HasChanged() previously returned CompareTo == 0, i.e. true
+ // when the value was UNCHANGED (inverted relative to its name).
+ EditableSettingV2 testObject = InitTestObject("Test Setting", 0);
+
+ Assert.IsFalse(testObject.HasChanged(), "An untouched setting must not report as changed.");
+
+ Assert.IsTrue(testObject.TryUpdateModelDirectly(500));
+
+ Assert.IsTrue(testObject.HasChanged(), "After changing away from the initial value, must report as changed.");
+ }
+
#endregion Tests
}
}
diff --git a/userspace-backend-tests/ModelTests/LookupTableDataTests.cs b/userspace-backend-tests/ModelTests/LookupTableDataTests.cs
new file mode 100644
index 00000000..6a430c48
--- /dev/null
+++ b/userspace-backend-tests/ModelTests/LookupTableDataTests.cs
@@ -0,0 +1,37 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using userspace_backend.Model.AccelDefinitions;
+
+namespace userspace_backend_tests.ModelTests
+{
+ [TestClass]
+ public class LookupTableDataTests
+ {
+ // Regression: CompareTo did `obj as double[]` on a LookupTableData, so the
+ // cast was always null and it returned -1 even for identical tables.
+ [TestMethod]
+ public void CompareTo_EqualData_ReportsEqual()
+ {
+ var a = new LookupTableData(new double[] { 1, 2, 3, 4 });
+ var b = new LookupTableData(new double[] { 1, 2, 3, 4 });
+
+ Assert.AreEqual(0, a.CompareTo(b));
+ }
+
+ [TestMethod]
+ public void CompareTo_DifferentData_ReportsNotEqual()
+ {
+ var a = new LookupTableData(new double[] { 1, 2, 3, 4 });
+ var c = new LookupTableData(new double[] { 1, 2, 3, 5 });
+
+ Assert.AreNotEqual(0, a.CompareTo(c));
+ }
+
+ [TestMethod]
+ public void CompareTo_Null_ReportsNotEqual()
+ {
+ var a = new LookupTableData(new double[] { 1, 2 });
+
+ Assert.AreNotEqual(0, a.CompareTo(null));
+ }
+ }
+}
diff --git a/userspace-backend-tests/ModelTests/SystemDevicesTests.cs b/userspace-backend-tests/ModelTests/SystemDevicesTests.cs
index 6120402a..0877d61d 100644
--- a/userspace-backend-tests/ModelTests/SystemDevicesTests.cs
+++ b/userspace-backend-tests/ModelTests/SystemDevicesTests.cs
@@ -1,30 +1,28 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
-using System;
using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
using userspace_backend.Model;
namespace userspace_backend_tests.ModelTests
{
+ // Cross-platform tests for the SystemDevices abstraction. The Windows-only
+ // RawInput retriever is covered by WindowsSystemDevicesTests.
[TestClass]
public class SystemDevicesTests
{
private class TestDevicesRetriever : ISystemDevicesRetriever
{
- public List Devices { get; set; }
+ public List Devices { get; set; } = new List();
public IList GetSystemDevices() => Devices;
}
private class TestSystemDevice : ISystemDevice
{
- public string Name { get; set; }
+ public string Name { get; set; } = string.Empty;
- public string HWID { get; set; }
+ public string HWID { get; set; } = string.Empty;
}
[TestMethod]
@@ -52,27 +50,10 @@ public void SystemDevicesProvider_ProvidesDevices()
var serviceProvider = services.BuildServiceProvider();
SystemDevicesProvider testObject = serviceProvider.GetRequiredService();
-
+
Assert.IsNotNull(testObject);
Assert.AreEqual(testSystemDevices.Count, testObject.SystemDevices.Count);
CollectionAssert.AreEquivalent(testSystemDevices, testObject.SystemDevices);
}
-
- // This test may need to be excluded if built from deviceless server.
- [TestMethod]
- public void SystemDevicesRetriever_RetrievesDevices()
- {
- var services = new ServiceCollection();
- services.AddSingleton();
- var serviceProvider = services.BuildServiceProvider();
-
- SystemDevicesRetriever testObject = serviceProvider.GetRequiredService();
- Assert.IsNotNull(testObject);
-
- // These devices will be different per user, but should not be null as long as the user has something giving mouse input.
- IList retrievedDevices = testObject.GetSystemDevices();
- Assert.IsNotNull(retrievedDevices);
- Assert.IsTrue(retrievedDevices.Count > 0);
- }
}
}
diff --git a/userspace-backend-tests/ModelTests/TestParsersAndValidators.cs b/userspace-backend-tests/ModelTests/TestParsersAndValidators.cs
new file mode 100644
index 00000000..643ed36f
--- /dev/null
+++ b/userspace-backend-tests/ModelTests/TestParsersAndValidators.cs
@@ -0,0 +1,19 @@
+using userspace_backend.Model.EditableSettings;
+
+namespace userspace_backend_tests.ModelTests
+{
+ /// Shared parser instances for model tests.
+ internal static class UserInputParsers
+ {
+ public static IUserInputParser IntParser { get; } = new IntParser();
+
+ public static IUserInputParser StringParser { get; } = new StringParser();
+ }
+
+ internal static class ModelValueValidators
+ {
+ public static IModelValueValidator DefaultIntValidator { get; } = new DefaultModelValueValidator();
+
+ public static IModelValueValidator DefaultStringValidator { get; } = new DefaultModelValueValidator();
+ }
+}
diff --git a/userspace-backend-tests/ModelTests/ValidationTests.cs b/userspace-backend-tests/ModelTests/ValidationTests.cs
new file mode 100644
index 00000000..bf0dc313
--- /dev/null
+++ b/userspace-backend-tests/ModelTests/ValidationTests.cs
@@ -0,0 +1,62 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using userspace_backend.Model.EditableSettings;
+
+namespace userspace_backend_tests.ModelTests
+{
+ [TestClass]
+ public class ValidationTests
+ {
+ [TestMethod]
+ public void RangeValidator_InclusiveMin_RejectsBelowAcceptsAtAndAbove()
+ {
+ var v = new RangeValidator(min: 1);
+
+ Assert.IsFalse(v.Validate(0), "0 is below an inclusive min of 1.");
+ Assert.IsFalse(v.Validate(-5));
+ Assert.IsTrue(v.Validate(1), "1 equals an inclusive min of 1.");
+ Assert.IsTrue(v.Validate(1000));
+ }
+
+ [TestMethod]
+ public void RangeValidator_ExclusiveMin_RejectsBoundary()
+ {
+ var v = new RangeValidator(min: 0, minInclusive: false);
+
+ Assert.IsFalse(v.Validate(0), "0 is excluded when min is exclusive.");
+ Assert.IsFalse(v.Validate(-0.1));
+ Assert.IsTrue(v.Validate(0.0001));
+ Assert.IsTrue(v.Validate(2));
+ }
+
+ [TestMethod]
+ public void RangeValidator_InclusiveMinZero_AllowsZero()
+ {
+ var v = new RangeValidator(min: 0);
+
+ Assert.IsTrue(v.Validate(0));
+ Assert.IsFalse(v.Validate(-1));
+ }
+
+ [TestMethod]
+ public void RangeValidator_MaxBound_IsInclusiveByDefault()
+ {
+ var v = new RangeValidator(min: 1, max: 10);
+
+ Assert.IsTrue(v.Validate(10));
+ Assert.IsFalse(v.Validate(11));
+ }
+
+ [TestMethod]
+ public void DoubleParser_RejectsNaNAndInfinity()
+ {
+ var parser = new DoubleParser();
+
+ Assert.IsFalse(parser.TryParse("NaN", out _));
+ Assert.IsFalse(parser.TryParse("Infinity", out _));
+ Assert.IsFalse(parser.TryParse("-Infinity", out _));
+
+ Assert.IsTrue(parser.TryParse("1.5", out double value));
+ Assert.AreEqual(1.5, value);
+ }
+ }
+}
diff --git a/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs b/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs
new file mode 100644
index 00000000..48804f8f
--- /dev/null
+++ b/userspace-backend-tests/ModelTests/WindowsSystemDevicesTests.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Collections.Generic;
+using userspace_backend.Driver.Windows;
+using userspace_backend.Model;
+
+namespace userspace_backend_tests.ModelTests
+{
+ // Windows-only RawInput retriever test, split from SystemDevicesTests so the
+ // cross-platform test runs on Linux (excluded from non-Windows builds via csproj).
+ // Asserts at least one mouse is returned; skip on headless build servers.
+ [TestClass]
+ public class WindowsSystemDevicesTests
+ {
+ [TestMethod]
+ public void WindowsSystemDevicesRetriever_RetrievesDevices()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ var serviceProvider = services.BuildServiceProvider();
+
+ var testObject = serviceProvider.GetRequiredService();
+ Assert.IsNotNull(testObject);
+
+ // These devices will be different per user, but should not be null
+ // as long as the user has something giving mouse input.
+ IList retrievedDevices = testObject.GetSystemDevices();
+ Assert.IsNotNull(retrievedDevices);
+ Assert.IsTrue(retrievedDevices.Count > 0);
+ }
+ }
+}
diff --git a/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs b/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs
index fa7f6ae0..9f11b282 100644
--- a/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs
+++ b/userspace-backend-tests/SerializationTests/AccelerationSerializationTests.cs
@@ -82,6 +82,30 @@ public void DeserializeFormulaLinearAccel()
Assert.AreEqual(0.001, actualLinearAccel.Acceleration);
}
+ // Ensures all formula types deserialize to their expected runtime type.
+ [TestMethod]
+ [DataRow("Classic", typeof(ClassicAccel))]
+ [DataRow("Linear", typeof(LinearAccel))]
+ [DataRow("Synchronous", typeof(SynchronousAccel))]
+ [DataRow("Power", typeof(PowerAccel))]
+ [DataRow("Natural", typeof(NaturalAccel))]
+ [DataRow("Jump", typeof(JumpAccel))]
+ public void DeserializeFormulaAccel_AllSubtypes(string formulaType, Type expectedRuntimeType)
+ {
+ string textToDeserialize = $$"""
+ {
+ "Acceleration": {
+ "Type": "Formula/{{formulaType}}",
+ "Gain": false
+ }
+ }
+ """;
+
+ var deserialized = JsonSerializer.Deserialize(textToDeserialize);
+ Assert.IsNotNull(deserialized.Acceleration);
+ Assert.IsInstanceOfType(deserialized.Acceleration, expectedRuntimeType);
+ }
+
[TestMethod]
public void DeserializeLookupTableVelocity()
{
@@ -157,12 +181,12 @@ public void SerializeLookupTableVelocity()
],
"Anisotropy": {
"Domain": {
- "X": 0,
- "Y": 0
+ "X": 1,
+ "Y": 1
},
"Range": {
- "X": 0,
- "Y": 0
+ "X": 1,
+ "Y": 1
},
"LPNorm": 2,
"CombineXYComponents": false
diff --git a/userspace-backend-tests/userspace-backend-tests.csproj b/userspace-backend-tests/userspace-backend-tests.csproj
index 38203f6e..9332b22d 100644
--- a/userspace-backend-tests/userspace-backend-tests.csproj
+++ b/userspace-backend-tests/userspace-backend-tests.csproj
@@ -25,20 +25,20 @@
+
+
+
+
-
-
-
-
-
-
+
+
+
Always
diff --git a/userspace-backend/BackEnd.cs b/userspace-backend/BackEnd.cs
index 68a08548..67b45ccb 100644
--- a/userspace-backend/BackEnd.cs
+++ b/userspace-backend/BackEnd.cs
@@ -1,13 +1,16 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
-using userspace_backend.Data.Profiles;
-using userspace_backend.IO;
+using RawAccel.Contracts;
+using userspace_backend.Driver;
using userspace_backend.Model;
using DATA = userspace_backend.Data;
+using RaProfile = RawAccel.Contracts.RawAccelProfile;
+using RaDeviceSettings = RawAccel.Contracts.RawAccelDeviceSettings;
+using RaDeviceConfig = RawAccel.Contracts.RawAccelDeviceConfig;
namespace userspace_backend
{
@@ -15,7 +18,7 @@ public interface IBackEnd
{
void Load();
- void Apply();
+ bool Apply();
void SaveToDisk();
@@ -35,11 +38,11 @@ public interface IBackEnd
public class BackEnd : IBackEnd
{
private readonly ILogger logger;
- private readonly IDriverConfigActivator driverConfigActivator;
+ private readonly IRawAccelDriver driver;
public BackEnd(
IBackEndLoader backEndLoader,
- IDriverConfigActivator driverConfigActivator,
+ IRawAccelDriver driver,
IProfilesModel profilesModel,
DevicesModel devicesModel,
MappingsModel mappingsModel,
@@ -47,7 +50,7 @@ public BackEnd(
ILogger? logger = null)
{
BackEndLoader = backEndLoader;
- this.driverConfigActivator = driverConfigActivator;
+ this.driver = driver;
Devices = devicesModel;
Mappings = mappingsModel;
Profiles = profilesModel;
@@ -69,13 +72,16 @@ public BackEnd(
public void Load()
{
- IEnumerable devicesData = BackEndLoader.LoadDevices();
- LoadDevicesFromData(devicesData);
+ List devicesData = BackEndLoader.LoadDevices().ToList();
+ Devices.TryMapFromData(devicesData);
- IEnumerable profilesData = BackEndLoader.LoadProfiles();
- LoadProfilesFromData(profilesData);
+ List profilesData = BackEndLoader.LoadProfiles().ToList();
+ Profiles.TryMapFromData(profilesData);
DATA.MappingSet mappingData = BackEndLoader.LoadMappings();
+
+ RestoreDeviceGroupsFromData(devicesData, mappingData);
+
LoadMappingsFromData(mappingData);
Settings = BackEndLoader.LoadSettings() ?? new DATA.Settings();
@@ -86,19 +92,33 @@ public void Load()
EnsureDefaultMappingExists();
}
- protected void LoadDevicesFromData(IEnumerable devicesData)
+ protected void RestoreDeviceGroupsFromData(
+ IEnumerable devicesData,
+ DATA.MappingSet mappingData)
{
- Devices.TryMapFromData(devicesData);
- }
+ foreach (DATA.Device device in devicesData)
+ {
+ if (!string.IsNullOrEmpty(device.DeviceGroup))
+ {
+ Devices.DeviceGroups.AddOrGetDeviceGroup(device.DeviceGroup);
+ }
+ }
- protected void LoadProfilesFromData(IEnumerable profileData)
- {
- Profiles.TryMapFromData(profileData);
+ foreach (DATA.Mapping mapping in mappingData?.Mappings ?? [])
+ {
+ foreach (string group in mapping.GroupsToProfiles.Keys)
+ {
+ if (!string.IsNullOrEmpty(group))
+ {
+ Devices.DeviceGroups.AddOrGetDeviceGroup(group);
+ }
+ }
+ }
}
protected void LoadMappingsFromData(DATA.MappingSet mappingData)
{
- // Clear existing mappings and reload from data
+ // Clear existing mappings and reload
Mappings.Mappings.Clear();
foreach (var mapping in mappingData.Mappings)
{
@@ -108,7 +128,6 @@ protected void LoadMappingsFromData(DATA.MappingSet mappingData)
protected void EnsureDefaultDeviceGroupExists()
{
- // If no device groups exist, create a "Default" group
if (Devices.DeviceGroups.DeviceGroupModels.Count == 0)
{
Devices.DeviceGroups.AddOrGetDeviceGroup(DeviceGroups.DefaultDeviceGroup);
@@ -122,19 +141,20 @@ protected void EnsureDefaultDeviceExists()
return;
}
- // When the OS reports connected input devices, skip the placeholder:
- // ImportSystemDevices will populate real devices instead.
+ // OS reported devices => skip the placeholder; ImportSystemDevices
+ // populates real ones.
if (Devices.SystemDevices.SystemDevices.Count > 0)
{
return;
}
+ // TODO: Niche case -- maybe skip the default entirely to surface
+ // that something is wrong.
var defaultDevice = ServiceProvider.GetRequiredService();
defaultDevice.Name.TryUpdateModelDirectly("Default");
defaultDevice.HardwareID.TryUpdateModelDirectly("DEFAULT_DEVICE_ID");
defaultDevice.DeviceGroup.TryUpdateModelDirectly(DeviceGroups.DefaultDeviceGroup);
- // DPI, PollRate, and Ignore already have sensible defaults from DI (1000, 1000, false)
-
+
Devices.TryInsert(0, defaultDevice);
}
@@ -147,6 +167,7 @@ public void ImportSystemDevices()
continue;
}
+ // When reloading new devices list this will trigger
bool alreadyPresent = Devices.Elements.Any(d =>
string.Equals(d.HardwareID.ModelValue, systemDevice.HWID, StringComparison.OrdinalIgnoreCase));
if (alreadyPresent)
@@ -158,7 +179,7 @@ public void ImportSystemDevices()
device.Name.TryUpdateModelDirectly(systemDevice.Name);
device.HardwareID.TryUpdateModelDirectly(systemDevice.HWID);
device.DeviceGroup.TryUpdateModelDirectly(DeviceGroups.DefaultDeviceGroup);
- // DPI / PollRate / Ignore keep their DI-provided defaults.
+ // DPI / PollRate / Use their DI-provided defaults.
Devices.TryAdd(device);
}
}
@@ -187,7 +208,6 @@ public void ReloadSystemDevices()
protected void EnsureDefaultProfileExists()
{
- // If no profiles exist, create a default profile
if (Profiles.Elements.Count == 0)
{
var defaultProfile = ServiceProvider.GetRequiredService();
@@ -198,8 +218,8 @@ protected void EnsureDefaultProfileExists()
protected void EnsureDefaultMappingExists()
{
- // Ensure a Default mapping object exists in the list.
- if (!Mappings.TryGetMapping("Default", out _))
+ // Create a Default mapping when none exist at all (fresh install).
+ if (Mappings.Mappings.Count == 0)
{
Mappings.TryAddMapping(new DATA.Mapping
{
@@ -208,7 +228,8 @@ protected void EnsureDefaultMappingExists()
});
}
- // Explicitly wire the DefaultDeviceGroup to "Default" profile entry.
+ // Self-heal: an existing Default mapping missing the DefaultDeviceGroup
+ // entry (e.g. stale mappings.json) gets one. TryAddMapping is idempotent.
if (Mappings.TryGetMapping("Default", out MappingModel? defaultMapping) && defaultMapping != null)
{
defaultMapping.TryAddMapping(DeviceGroups.DefaultDeviceGroup, "Default");
@@ -221,19 +242,19 @@ protected void EnsureDefaultMappingExists()
}
}
- public void Apply()
+ public bool Apply()
{
logger.LogInformation("Apply clicked");
MappingModel? mappingToApply = Mappings.GetMappingToSetActive();
if (mappingToApply == null)
{
- logger.LogWarning("Apply: no active mapping to apply");
+ logger.LogError("Apply: Invalid state, no active mapping to apply");
WriteSettingsToDisk();
- return;
+ return false;
}
- DriverConfig? config = null;
+ RawAccelConfig? config = null;
try
{
config = MapToDriverConfig(mappingToApply);
@@ -242,26 +263,35 @@ public void Apply()
}
catch (Exception ex)
{
- logger.LogError(ex, "Apply: error building DriverConfig");
+ logger.LogError(ex, "Apply: error building RawAccelConfig");
}
+ bool driverApplied = false;
if (config != null)
{
try
{
- driverConfigActivator.Write(config);
- logger.LogInformation("Apply: driver.Activate() succeeded");
+ driverApplied = driver.Apply(config);
+ if (driverApplied)
+ {
+ logger.LogInformation("Apply: driver.Apply() succeeded");
+ }
+ else
+ {
+ logger.LogError("Apply: driver.Apply() failed");
+ }
}
catch (Exception ex)
{
- logger.LogError(ex, "Apply: driver.Activate() failed");
+ logger.LogError(ex, "Apply: driver.Apply() threw");
}
}
WriteSettingsToDisk();
+ return driverApplied;
}
- private void LogDriverConfigSummary(MappingModel mapping, DriverConfig config)
+ private void LogDriverConfigSummary(MappingModel mapping, RawAccelConfig config)
{
int profileCount = config.profiles?.Count ?? 0;
int deviceCount = config.devices?.Count ?? 0;
@@ -274,7 +304,7 @@ private void LogDriverConfigSummary(MappingModel mapping, DriverConfig config)
if (config.profiles != null)
{
- foreach (Profile p in config.profiles)
+ foreach (RaProfile p in config.profiles)
{
logger.LogInformation(
" profile: name={Name} outputDPI={OutputDPI} yxRatio={YxRatio} rotation={Rotation} " +
@@ -287,7 +317,7 @@ private void LogDriverConfigSummary(MappingModel mapping, DriverConfig config)
if (config.devices != null)
{
- foreach (DeviceSettings d in config.devices)
+ foreach (RaDeviceSettings d in config.devices)
{
logger.LogInformation(
" device: id={Id} name={Name} profile={Profile} disable={Disable} dpi={Dpi} pollingRate={PollingRate}",
@@ -296,21 +326,23 @@ private void LogDriverConfigSummary(MappingModel mapping, DriverConfig config)
}
}
- private void LogDriverConfigJson(DriverConfig config)
+ private void LogDriverConfigJson(RawAccelConfig config)
{
try
{
string json = Newtonsoft.Json.JsonConvert.SerializeObject(
config,
Newtonsoft.Json.Formatting.Indented);
- logger.LogDebug("Apply: DriverConfig JSON{NewLine}{Json}", Environment.NewLine, json);
+ logger.LogDebug("Apply: RawAccelConfig JSON{NewLine}{Json}", Environment.NewLine, json);
}
catch (Exception ex)
{
- logger.LogWarning(ex, "Apply: could not serialize DriverConfig to JSON");
+ logger.LogWarning(ex, "Apply: could not serialize RawAccelConfig to JSON");
}
}
+ // TODO: These functions can be factored out later
+ // Leave here for test/debug
protected void WriteSettingsToDisk()
{
BackEndLoader.WriteSettingsToDisk(
@@ -334,48 +366,54 @@ public void SaveToDisk()
}
}
- protected DriverConfig MapToDriverConfig(MappingModel mappingModel)
+ protected RawAccelConfig MapToDriverConfig(MappingModel mappingModel)
{
- IEnumerable configDevices = MapToDriverDevices(mappingModel);
- IEnumerable configProfiles = MapToDriverProfiles(mappingModel);
-
- DriverConfig config = DriverConfig.GetDefault();
- config.profiles = configProfiles.ToList();
- config.devices = configDevices.ToList();
- config.accels = configProfiles.Select(p => new ManagedAccel(p)).ToList();
- return config;
+ IEnumerable configDevices = MapToDriverDevices(mappingModel);
+ IEnumerable configProfiles = MapToDriverProfiles(mappingModel);
+
+ return new RawAccelConfig
+ {
+ version = RawAccelConstants.VersionString,
+ defaultDeviceConfig = new RaDeviceConfig(),
+ profiles = configProfiles.ToList(),
+ devices = configDevices.ToList(),
+ };
}
- protected IEnumerable MapToDriverDevices(MappingModel mapping)
+ protected IEnumerable MapToDriverDevices(MappingModel mapping)
{
return mapping.IndividualMappings.SelectMany(
dg => MapToDriverDevices(dg.DeviceGroup, dg.Profile.Name.ModelValue));
}
- protected IEnumerable MapToDriverProfiles(MappingModel mapping)
+ protected IEnumerable MapToDriverProfiles(MappingModel mapping)
{
IEnumerable ProfilesToMap = mapping.IndividualMappings.Select(m => m.Profile).Distinct();
return ProfilesToMap.Select(p => p.CurrentValidatedDriverProfile);
}
- protected IEnumerable MapToDriverDevices(string dg, string profileName)
+ protected IEnumerable MapToDriverDevices(string dg, string profileName)
{
IEnumerable deviceModels = Devices.Elements.Where(d => d.DeviceGroup.ModelValue.Equals(dg));
return deviceModels.Select(dm => MapToDriverDevice(dm, profileName));
}
- protected DeviceSettings MapToDriverDevice(IDeviceModel deviceModel, string profileName)
+ protected RaDeviceSettings MapToDriverDevice(IDeviceModel deviceModel, string profileName)
{
- return new DeviceSettings()
+ return new RaDeviceSettings()
{
id = deviceModel.HardwareID.ModelValue,
name = deviceModel.Name.ModelValue,
profile = profileName,
- config = new DeviceConfig()
+ config = new RaDeviceConfig()
{
disable = deviceModel.Ignore.ModelValue,
dpi = deviceModel.DPI.ModelValue,
pollingRate = deviceModel.PollRate.ModelValue,
+ // Driver defaults for poll-time clamping + extra-info passthrough,
+ // not yet exposed in the UI. maximumTime/minimumTime bound the
+ // per-packet time delta (ms) the driver trusts. Keep in sync if
+ // these ever become user-configurable.
pollTimeLock = false,
setExtraInfo = false,
maximumTime = 200,
diff --git a/userspace-backend/BackEndComposer.cs b/userspace-backend/BackEndComposer.cs
index d8f3655e..67199f42 100644
--- a/userspace-backend/BackEndComposer.cs
+++ b/userspace-backend/BackEndComposer.cs
@@ -1,14 +1,18 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
using System;
+using System.Runtime.InteropServices;
using DATA = userspace_backend.Data;
using userspace_backend.Display;
+using userspace_backend.Driver;
using userspace_backend.IO;
using userspace_backend.Model;
using userspace_backend.Model.AccelDefinitions;
using userspace_backend.Model.AccelDefinitions.Formula;
using userspace_backend.Model.EditableSettings;
using userspace_backend.Model.ProfileComponents;
+using userspace_backend.Data.Profiles.Accel.Formula;
using static userspace_backend.Data.Profiles.Accel.FormulaAccel;
using static userspace_backend.Data.Profiles.Accel.LookupTableAccel;
using static userspace_backend.Data.Profiles.Acceleration;
@@ -20,7 +24,7 @@ public static class BackEndComposer
{
public static IServiceProvider Compose(IServiceCollection services)
{
- services.TryAddSingleton();
+ RegisterPlatformServices(services);
services.TryAddSingleton();
#region Parsers
@@ -29,9 +33,9 @@ public static IServiceProvider Compose(IServiceCollection services)
services.AddSingleton, IntParser>();
services.AddSingleton, DoubleParser>();
services.AddSingleton, BoolParser>();
- services.AddSingleton, AccelerationDefinitionTypeParser>();
- services.AddSingleton, AccelerationFormulaTypeParser>();
- services.AddSingleton, LookupTableTypeParser>();
+ services.AddSingleton, EnumParser>();
+ services.AddSingleton, EnumParser>();
+ services.AddSingleton, EnumParser>();
services.AddSingleton, LookupTableDataParser>();
#endregion Parsers
@@ -50,137 +54,53 @@ public static IServiceProvider Compose(IServiceCollection services)
services.AddKeyedSingleton, DefaultModelValueValidator>(
DefaultModelValueValidator.AllChangeInvalidDIKey);
- services.AddKeyedSingleton, MaxNameLengthValidator>(
- ProfileModel.NameDIKey);
+ services.AddKeyedSingleton>(
+ ProfileModel.NameDIKey,
+ (sp, key) => new ProfileNameValidator(sp.GetRequiredService()));
#endregion Validators
#region Hidden
services.AddTransient();
- services.AddKeyedTransient>(
- HiddenModel.RotationDegreesDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Rotation",
- initialValue: 0,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- HiddenModel.AngleSnappingDegreesDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Angle Snapping",
- initialValue: 0,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- HiddenModel.LeftRightRatioDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "L/R Ratio",
- initialValue: 1,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- HiddenModel.UpDownRatioDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "U/D Ratio",
- initialValue: 1,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- HiddenModel.SpeedCapDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Speed Cap",
- initialValue: 0,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- HiddenModel.OutputSmoothingHalfLifeDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Output Smoothing Half-Life",
- initialValue: 0,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
+ AddEditableSetting(services, HiddenModel.RotationDegreesDIKey, "Rotation", 0);
+ AddEditableSetting(services, HiddenModel.AngleSnappingDegreesDIKey, "Angle Snapping", 0);
+ AddEditableSetting(services, HiddenModel.LeftRightRatioDIKey, "L/R Ratio", 1);
+ AddEditableSetting(services, HiddenModel.UpDownRatioDIKey, "U/D Ratio", 1);
+ AddEditableSetting(services, HiddenModel.SpeedCapDIKey, "Speed Cap", 0);
+ AddEditableSetting(services, HiddenModel.OutputSmoothingHalfLifeDIKey, "Output Smoothing Half-Life", 0,
+ validatorFactory: sp => new RangeValidator(min: 0));
#endregion Hidden
#region Coalescion
services.AddTransient();
- services.AddKeyedTransient>(
- CoalescionModel.InputSmoothingHalfLifeDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Input Smoothing Half-Life",
- initialValue: 0,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- CoalescionModel.ScaleSmoothingHalfLifeDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Scale Smoothing Half-Life",
- initialValue: 0,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
+ AddEditableSetting(services, CoalescionModel.InputSmoothingHalfLifeDIKey, "Input Smoothing Half-Life", 0,
+ validatorFactory: sp => new RangeValidator(min: 0));
+ AddEditableSetting(services, CoalescionModel.ScaleSmoothingHalfLifeDIKey, "Scale Smoothing Half-Life", 0,
+ validatorFactory: sp => new RangeValidator(min: 0));
#endregion Coalescion
#region Anisotropy
services.AddTransient();
- services.AddKeyedTransient>(
- AnisotropyModel.DomainXDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Domain X",
- initialValue: 1,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- AnisotropyModel.DomainYDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Domain Y",
- initialValue: 1,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- AnisotropyModel.RangeXDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Range X",
- initialValue: 1,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- AnisotropyModel.RangeYDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Range Y",
- initialValue: 1,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- AnisotropyModel.LPNormDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "LP Norm",
- initialValue: 2,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
- services.AddKeyedTransient>(
- AnisotropyModel.CombineXYComponentsDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Combine X and Y Components",
- initialValue: false,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
+ AddEditableSetting(services, AnisotropyModel.DomainXDIKey, "Domain X", 1);
+ AddEditableSetting(services, AnisotropyModel.DomainYDIKey, "Domain Y", 1);
+ AddEditableSetting(services, AnisotropyModel.RangeXDIKey, "Range X", 1);
+ AddEditableSetting(services, AnisotropyModel.RangeYDIKey, "Range Y", 1);
+ AddEditableSetting(services, AnisotropyModel.LPNormDIKey, "LP Norm", 2,
+ validatorFactory: sp => new RangeValidator(min: 0, minInclusive: false));
+ AddEditableSetting(services, AnisotropyModel.CombineXYComponentsDIKey, "Combine X and Y Components", false,
+ localizationKey: "AnisotropyCombineXY");
#endregion Anisotropy
#region Acceleration
services.AddTransient();
- services.AddKeyedTransient>(
- AccelerationModel.SelectionDIKey, (IServiceProvider services, object? key) =>
- new EditableSettingV2(
- displayName: "Definition Type",
- initialValue: AccelerationDefinitionType.None,
- parser: services.GetRequiredService>(),
- validator: services.GetRequiredService>()));
+ AddEditableSetting(services, AccelerationModel.SelectionDIKey, "Definition Type", AccelerationDefinitionType.None);
// Register selector options for AccelerationDefinitionType
services.AddKeyedTransient>(
@@ -198,21 +118,9 @@ public static IServiceProvider Compose(IServiceCollection services)
#region FormulaAccel
services.AddTransient