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(); - services.AddKeyedTransient>( - FormulaAccelModel.SelectionDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Formula Type", - initialValue: AccelerationFormulaType.Synchronous, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>(), - autoUpdateFromInterface: true)); - services.AddKeyedTransient>( - FormulaAccelModel.GainDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Apply to Gain", - initialValue: false, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, FormulaAccelModel.SelectionDIKey, "Formula Type", AccelerationFormulaType.Synchronous, + autoUpdateFromInterface: true); + AddEditableSetting(services, FormulaAccelModel.GainDIKey, "Apply to Gain", false); // Register selector options for AccelerationFormulaType services.AddKeyedTransient>( @@ -239,20 +147,8 @@ public static IServiceProvider Compose(IServiceCollection services) #region LookupTable services.AddTransient(); - services.AddKeyedTransient>( - LookupTableDefinitionModel.ApplyAsDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Apply as", - initialValue: LookupTableType.Velocity, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - LookupTableDefinitionModel.DataDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Data", - initialValue: new LookupTableData(), - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, LookupTableDefinitionModel.ApplyAsDIKey, "Apply as", LookupTableType.Velocity); + AddEditableSetting(services, LookupTableDefinitionModel.DataDIKey, "Data", new LookupTableData()); #endregion LookupTable @@ -265,210 +161,67 @@ public static IServiceProvider Compose(IServiceCollection services) #region SynchronousAccel services.AddTransient(); - services.AddKeyedTransient>( - SynchronousAccelerationDefinitionModel.SyncSpeedDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Sync Speed", - 15, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - SynchronousAccelerationDefinitionModel.MotivityDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Motivity", - 1.4, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - SynchronousAccelerationDefinitionModel.GammaDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Gamma", - 1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - SynchronousAccelerationDefinitionModel.SmoothnessDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Smoothness", - 0.5, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.SyncSpeedDIKey, "Sync Speed", FormulaDefaults.SyncSpeed); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.MotivityDIKey, "Motivity", FormulaDefaults.Motivity); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.GammaDIKey, "Gamma", FormulaDefaults.Gamma); + AddEditableSetting(services, SynchronousAccelerationDefinitionModel.SmoothnessDIKey, "Smoothness", FormulaDefaults.Smoothness); #endregion SynchronousAccel #region LinearAccel services.AddTransient(); - services.AddKeyedTransient>( - LinearAccelerationDefinitionModel.AccelerationDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Acceleration", - 0.01, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - LinearAccelerationDefinitionModel.OffsetDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Offset", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - LinearAccelerationDefinitionModel.CapDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Cap", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, LinearAccelerationDefinitionModel.AccelerationDIKey, "Acceleration", FormulaDefaults.LinearAcceleration); + AddEditableSetting(services, LinearAccelerationDefinitionModel.OffsetDIKey, "Offset", FormulaDefaults.LinearOffset); + AddEditableSetting(services, LinearAccelerationDefinitionModel.CapDIKey, "Cap", FormulaDefaults.LinearCap); #endregion LinearAccel #region ClassicAccel services.AddTransient(); - services.AddKeyedTransient>( - ClassicAccelerationDefinitionModel.AccelerationDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Acceleration", - 0.01, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - ClassicAccelerationDefinitionModel.ExponentDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Exponent", - 2, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - ClassicAccelerationDefinitionModel.OffsetDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Offset", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - ClassicAccelerationDefinitionModel.CapDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Cap", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.AccelerationDIKey, "Acceleration", FormulaDefaults.ClassicAcceleration); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.ExponentDIKey, "Exponent", FormulaDefaults.ClassicExponent); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.OffsetDIKey, "Offset", FormulaDefaults.ClassicOffset); + AddEditableSetting(services, ClassicAccelerationDefinitionModel.CapDIKey, "Cap", FormulaDefaults.ClassicCap); #endregion ClassicAccel #region PowerAccel services.AddTransient(); - services.AddKeyedTransient>( - PowerAccelerationDefinitionModel.ScaleDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Scale", - 1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - PowerAccelerationDefinitionModel.ExponentDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Exponent", - 0.05, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - PowerAccelerationDefinitionModel.OutputOffsetDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Output Offset", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - PowerAccelerationDefinitionModel.CapDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Cap", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, PowerAccelerationDefinitionModel.ScaleDIKey, "Scale", FormulaDefaults.PowerScale); + AddEditableSetting(services, PowerAccelerationDefinitionModel.ExponentDIKey, "Exponent", FormulaDefaults.PowerExponent); + AddEditableSetting(services, PowerAccelerationDefinitionModel.OutputOffsetDIKey, "Output Offset", FormulaDefaults.PowerOutputOffset); + AddEditableSetting(services, PowerAccelerationDefinitionModel.CapDIKey, "Cap", FormulaDefaults.PowerCap); #endregion PowerAccel #region JumpAccel services.AddTransient(); - services.AddKeyedTransient>( - JumpAccelerationDefinitionModel.SmoothDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Smooth", - 0.5, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - JumpAccelerationDefinitionModel.InputDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Input", - 15, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - JumpAccelerationDefinitionModel.OutputDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Output", - 1.5, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, JumpAccelerationDefinitionModel.SmoothDIKey, "Smooth", FormulaDefaults.JumpSmooth); + AddEditableSetting(services, JumpAccelerationDefinitionModel.InputDIKey, "Input", FormulaDefaults.JumpInput); + AddEditableSetting(services, JumpAccelerationDefinitionModel.OutputDIKey, "Output", FormulaDefaults.JumpOutput); #endregion JumpAccel #region NaturalAccel services.AddTransient(); - services.AddKeyedTransient>( - NaturalAccelerationDefinitionModel.DecayRateDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Decay Rate", - 0.1, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - NaturalAccelerationDefinitionModel.InputOffsetDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Input Offset", - 0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - NaturalAccelerationDefinitionModel.LimitDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Limit", - 1.5, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, NaturalAccelerationDefinitionModel.DecayRateDIKey, "Decay Rate", FormulaDefaults.NaturalDecayRate); + AddEditableSetting(services, NaturalAccelerationDefinitionModel.InputOffsetDIKey, "Input Offset", FormulaDefaults.NaturalInputOffset); + AddEditableSetting(services, NaturalAccelerationDefinitionModel.LimitDIKey, "Limit", FormulaDefaults.NaturalLimit); #endregion NaturalAccel #region Profile services.AddTransient(); - services.AddKeyedTransient>( - ProfileModel.NameDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Name", - "Empty", - parser: services.GetRequiredService>(), - validator: services.GetRequiredKeyedService>(ProfileModel.NameDIKey))); - services.AddKeyedTransient>( - ProfileModel.OutputDPIDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Output DPI", - 1000, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - ProfileModel.YXRatioDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Y/X Ratio", - 1.0, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, ProfileModel.NameDIKey, "Name", "Empty", + validatorFactory: sp => sp.GetRequiredKeyedService>(ProfileModel.NameDIKey)); + AddEditableSetting(services, ProfileModel.OutputDPIDIKey, "Output DPI", 1000); + AddEditableSetting(services, ProfileModel.YXRatioDIKey, "Y/X Ratio", 1.0); #endregion Profile @@ -496,54 +249,19 @@ public static IServiceProvider Compose(IServiceCollection services) { var systemDevicesProvider = sp.GetRequiredService(); var deviceGroups = sp.GetRequiredService(); - var devicesModel = new DevicesModel(sp, systemDevicesProvider); - devicesModel.DeviceGroups = deviceGroups; - return devicesModel; + return new DevicesModel(sp, systemDevicesProvider, deviceGroups); }); + // TODO: HWID should never be exposed to user. services.AddTransient(); - services.AddKeyedTransient>( - DeviceModel.NameDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Name", - initialValue: "name", - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - DeviceModel.HardwareIDDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Hardware ID", - initialValue: "hwid", - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - DeviceModel.DPIDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "DPI", - initialValue: 1000, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - DeviceModel.PollRateDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Polling Rate", - initialValue: 1000, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - DeviceModel.IgnoreDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Ignore", - initialValue: false, - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); - services.AddKeyedTransient>( - DeviceModel.DeviceGroupDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Device Group", - initialValue: "default", - parser: services.GetRequiredService>(), - validator: services.GetRequiredService>())); + AddEditableSetting(services, DeviceModel.NameDIKey, "Name", "name"); + AddEditableSetting(services, DeviceModel.HardwareIDDIKey, "Hardware ID", "hwid"); + AddEditableSetting(services, DeviceModel.DPIDIKey, "DPI", 1000, + validatorFactory: sp => new RangeValidator(min: 1)); + AddEditableSetting(services, DeviceModel.PollRateDIKey, "Polling Rate", 1000, + validatorFactory: sp => new RangeValidator(min: 1)); + AddEditableSetting(services, DeviceModel.IgnoreDIKey, "Ignore", false); + AddEditableSetting(services, DeviceModel.DeviceGroupDIKey, "Device Group", "default"); #endregion DeviceGroup @@ -565,13 +283,8 @@ public static IServiceProvider Compose(IServiceCollection services) }); services.AddTransient(); - services.AddKeyedTransient>( - MappingModel.NameDIKey, (IServiceProvider services, object? key) => - new EditableSettingV2( - displayName: "Name", - initialValue: "name", - parser: services.GetRequiredService>(), - validator: services.GetRequiredService())); + AddEditableSetting(services, MappingModel.NameDIKey, "Name", "name", + validatorFactory: sp => sp.GetRequiredService()); #endregion Mapping @@ -588,13 +301,82 @@ public static IServiceProvider Compose(IServiceCollection services) services.AddSingleton(); - services.TryAddSingleton(); - services.AddSingleton(); #endregion BackEnd return services.BuildServiceProvider(); } + + // Registers a keyed EditableSettingV2 with DI-supplied parser and validator. + // Override the type-keyed default validator with validatorFactory. + private static void AddEditableSetting( + IServiceCollection services, + object diKey, + string displayName, + T initialValue, + bool autoUpdateFromInterface = false, + string? localizationKey = null, + Func>? validatorFactory = null) + where T : IComparable + { + services.AddKeyedTransient>( + diKey, + (sp, key) => new EditableSettingV2( + displayName: displayName, + initialValue: initialValue, + parser: sp.GetRequiredService>(), + validator: validatorFactory is null + ? sp.GetRequiredService>() + : validatorFactory(sp), + autoUpdateFromInterface: autoUpdateFromInterface, + localizationKey: localizationKey!, + logger: sp.GetService() + ?.CreateLogger(EditableSettingV2.LoggerCategoryName))); + } + + // TODO: Reflection because wrapper.dll is .NET Framework 4.7.2 C++/CLI + // (net8.0 can't load it in-proc) and Driver/Windows/*.cs is Compile-Removed + // off Windows. After wrapper moves to net8.0-windows, reference directly + // and delete RegisterWindowsServicesByReflection. + // + // TODO: While migrating wrapper, namespace its public C++/CLI types + // (Profile, AccelArgs, DeviceSettings, DeviceConfig, AccelMode, CapMode, + // SpeedArgs) -- they sit in the global namespace, collide with Contracts + // on Windows (CS0576), and force the Ra-prefixed aliases. Update consumers + // (grapher, writer, wrapper-tests, wrapper-deps, this backend) so the + // aliases can revert. + private static void RegisterPlatformServices(IServiceCollection services) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Windows impls live under Driver/Windows/ and depend on wrapper.dll; + // reflection so this method compiles where those types are absent. + RegisterWindowsServicesByReflection(services); + } + // No platform driver on non-Windows. Callers (e.g. test fixtures) must + // register driver/evaluator/devices retriever before Compose; otherwise + // DI fails at first use rather than at composition. + } + + private static void RegisterWindowsServicesByReflection(IServiceCollection services) + { + var asm = typeof(BackEndComposer).Assembly; + + var driverType = asm.GetType("userspace_backend.Driver.Windows.WindowsRawAccelDriver"); + var evaluatorType = asm.GetType("userspace_backend.Driver.Windows.ManagedAccelEvaluator"); + var devicesType = asm.GetType("userspace_backend.Driver.Windows.WindowsSystemDevicesRetriever"); + + if (driverType is null || evaluatorType is null || devicesType is null) + { + throw new InvalidOperationException( + "Windows driver/evaluator/devices types missing from this build; " + + "ensure userspace-backend was built on Windows so wrapper.dll is referenced."); + } + + services.TryAddSingleton(typeof(IRawAccelDriver), driverType); + services.TryAddSingleton(typeof(IAccelEvaluator), evaluatorType); + services.TryAddSingleton(typeof(ISystemDevicesRetriever), devicesType); + } } } diff --git a/userspace-backend/BackEndLoader.cs b/userspace-backend/BackEndLoader.cs index 47385a4c..42671c3e 100644 --- a/userspace-backend/BackEndLoader.cs +++ b/userspace-backend/BackEndLoader.cs @@ -58,8 +58,7 @@ public BackEndLoader( return []; } string devicesText = File.ReadAllText(devicesFile); - IEnumerable devicesData = DevicesReaderWriter.Deserialize(devicesText); - return devicesData; + return DevicesReaderWriter.Deserialize(devicesText) ?? []; } public DATA.MappingSet LoadMappings() @@ -70,8 +69,7 @@ public DATA.MappingSet LoadMappings() return new DATA.MappingSet { Mappings = [] }; } string mappingsText = File.ReadAllText(mappingsFile); - DATA.MappingSet mappingsData = MappingsReaderWriter.Deserialize(mappingsText); - return mappingsData; + return MappingsReaderWriter.Deserialize(mappingsText) ?? new DATA.MappingSet { Mappings = [] }; } public IEnumerable LoadProfiles() @@ -87,8 +85,11 @@ public DATA.MappingSet LoadMappings() foreach (string profileFile in profileFiles) { string profileText = File.ReadAllText(profileFile); - DATA.Profile profileData = ProfileReaderWriter.Deserialize(profileText); - profiles.Add(profileData); + DATA.Profile? profileData = ProfileReaderWriter.Deserialize(profileText); + if (profileData != null) + { + profiles.Add(profileData); + } } return profiles; @@ -133,39 +134,68 @@ protected void WriteDevices(IEnumerable devices) { IEnumerable devicesData = devices.Select(d => d.MapToData()); string devicesFileText = DevicesReaderWriter.Serialize(devicesData); - string devicesFilePath = GetDevicesFile(SettingsDirectory); - File.WriteAllText(devicesFilePath, devicesFileText); + WriteFileAtomic(GetDevicesFile(SettingsDirectory), devicesFileText); } protected void WriteMappings(MappingsModel mappings) { DATA.MappingSet mappingsData = mappings.MapToData(); string mappingsFileText = MappingsReaderWriter.Serialize(mappingsData); - string mappingsFilePath = GetMappingsFile(SettingsDirectory); - File.WriteAllText(mappingsFilePath, mappingsFileText); + WriteFileAtomic(GetMappingsFile(SettingsDirectory), mappingsFileText); } - + protected void WriteProfiles(IEnumerable profiles) { string profilesDirectory = GetProfilesDirectory(SettingsDirectory); Directory.CreateDirectory(profilesDirectory); + HashSet writtenFiles = []; foreach (var profile in profiles) { DATA.Profile profileData = profile.MapToData(); string profileFileText = ProfileReaderWriter.Serialize(profileData); string profileFilePath = GetProfileFile(profilesDirectory, profileData.Name); - File.WriteAllText(profileFilePath, profileFileText); + WriteFileAtomic(profileFilePath, profileFileText); + writtenFiles.Add(profileFilePath); + } + + // Remove profile files left behind by profiles that no longer exist. + foreach (string existing in Directory.GetFiles(profilesDirectory, "*.json")) + { + if (!writtenFiles.Contains(existing)) + { + File.Delete(existing); + } } } + // Temp-then-rename so a crash mid-write can't corrupt the previous good file. + private static void WriteFileAtomic(string path, string contents) + { + string tempPath = path + ".tmp"; + File.WriteAllText(tempPath, contents); + File.Move(tempPath, path, overwrite: true); + } + protected static string GetDevicesFile(string settingsDirectory) => Path.Combine(settingsDirectory, "devices.json"); protected static string GetMappingsFile(string settingsDirectory) => Path.Combine(settingsDirectory, "mappings.json"); protected static string GetProfilesDirectory(string settingsDirectory) => Path.Combine(settingsDirectory, "profiles"); - protected static string GetProfileFile(string profileDirectory, string profileName) => Path.Combine(profileDirectory, $"{profileName}.json"); + protected static string GetProfileFile(string profileDirectory, string profileName) => Path.Combine(profileDirectory, $"{SanitizeFileName(profileName)}.json"); + + // User-supplied profile names may contain filename-illegal chars + // ('/', '\\', ':', ...); replace so the write doesn't throw. The on-disk + // name isn't authoritative -- load reads Name from the file body. + private static string SanitizeFileName(string name) + { + foreach (char invalid in Path.GetInvalidFileNameChars()) + { + name = name.Replace(invalid, '_'); + } + return name; + } protected static string GetSettingsFile(string settingsDirectory) => Path.Combine(settingsDirectory, "settings.json"); } diff --git a/userspace-backend/Bootstrapper.cs b/userspace-backend/Bootstrapper.cs index 9829c5d6..c66a4c62 100644 --- a/userspace-backend/Bootstrapper.cs +++ b/userspace-backend/Bootstrapper.cs @@ -11,13 +11,13 @@ namespace userspace_backend // TODO: remove before release public class Bootstrapper : IBackEndLoader { - public DATA.Device[] DevicesToLoad { get; set; } + public DATA.Device[] DevicesToLoad { get; set; } = []; - public DATA.MappingSet MappingsToLoad { get; set; } + public DATA.MappingSet MappingsToLoad { get; set; } = new DATA.MappingSet { Mappings = [] }; - public DATA.Profile[] ProfilesToLoad { get; set; } + public DATA.Profile[] ProfilesToLoad { get; set; } = []; - public DATA.Settings SettingsToLoad { get; set; } + public DATA.Settings? SettingsToLoad { get; set; } // Allows us to test parts of BackEndLoader as desired public BackEndLoader BackEndLoader { get; set; } diff --git a/userspace-backend/Common/DriverHelpers.cs b/userspace-backend/Common/DriverHelpers.cs deleted file mode 100644 index d5687f5e..00000000 --- a/userspace-backend/Common/DriverHelpers.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using userspace_backend.Model; - -namespace userspace_backend.Common -{ - public static class DriverHelpers - { - public static Profile MapProfileModelToDriver(ProfileModel model) - { - return new Profile() - { - name = model.Name.ModelValue, - outputDPI = model.OutputDPI.ModelValue, - yxOutputDPIRatio = model.YXRatio.ModelValue, - argsX = model.Acceleration.MapToDriver(), - domainXY = new Vec2 - { - x = model.Acceleration.Anisotropy.DomainX.ModelValue, - y = model.Acceleration.Anisotropy.DomainY.ModelValue, - }, - rangeXY = new Vec2 - { - x = model.Acceleration.Anisotropy.RangeX.ModelValue, - y = model.Acceleration.Anisotropy.RangeY.ModelValue, - }, - rotation = model.Hidden.RotationDegrees.ModelValue, - lrOutputDPIRatio = model.Hidden.LeftRightRatio.ModelValue, - udOutputDPIRatio = model.Hidden.UpDownRatio.ModelValue, - snap = model.Hidden.AngleSnappingDegrees.ModelValue, - maximumSpeed = model.Hidden.SpeedCap.ModelValue, - minimumSpeed = 0, - inputSpeedArgs = new SpeedArgs - { - combineMagnitudes = model.Acceleration.Anisotropy.CombineXYComponents.ModelValue, - lpNorm = model.Acceleration.Anisotropy.LPNorm.ModelValue, - outputSmoothHalflife = model.Hidden.OutputSmoothingHalfLife.ModelValue, - inputSmoothHalflife = model.Acceleration.Coalescion.InputSmoothingHalfLife.ModelValue, - scaleSmoothHalflife = model.Acceleration.Coalescion.ScaleSmoothingHalfLife.ModelValue, - } - }; - - } - } -} diff --git a/userspace-backend/Data/Device.cs b/userspace-backend/Data/Device.cs index 4144652f..126849e2 100644 --- a/userspace-backend/Data/Device.cs +++ b/userspace-backend/Data/Device.cs @@ -1,5 +1,4 @@ -using System; -using System.ComponentModel; +using System; using System.Text.Json.Serialization; namespace userspace_backend.Data @@ -22,17 +21,24 @@ public class Device public override bool Equals(object? obj) { return obj is Device device && - Name == device.Name && - HWID == device.HWID && + string.Equals(Name, device.Name, StringComparison.OrdinalIgnoreCase) && + string.Equals(HWID, device.HWID, StringComparison.OrdinalIgnoreCase) && DPI == device.DPI && PollingRate == device.PollingRate && Ignore == device.Ignore && - DeviceGroup == device.DeviceGroup; + string.Equals(DeviceGroup, device.DeviceGroup, StringComparison.OrdinalIgnoreCase); } public override int GetHashCode() { - return HashCode.Combine(Name, HWID, DPI, PollingRate, DeviceGroup); + HashCode hash = new HashCode(); + hash.Add(Name, StringComparer.OrdinalIgnoreCase); + hash.Add(HWID, StringComparer.OrdinalIgnoreCase); + hash.Add(DPI); + hash.Add(PollingRate); + hash.Add(Ignore); + hash.Add(DeviceGroup, StringComparer.OrdinalIgnoreCase); + return hash.ToHashCode(); } } } diff --git a/userspace-backend/Data/Mapping.cs b/userspace-backend/Data/Mapping.cs index 7f3647b0..3e26913f 100644 --- a/userspace-backend/Data/Mapping.cs +++ b/userspace-backend/Data/Mapping.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -18,16 +18,16 @@ public class Mapping public override bool Equals(object? obj) { - bool isEqual = obj is Mapping mapping - && string.Equals(Name, mapping.Name, StringComparison.InvariantCultureIgnoreCase) - && mapping.GroupsToProfiles.Equals(this.GroupsToProfiles); - - return isEqual; + return obj is Mapping mapping + && string.Equals(Name, mapping.Name, StringComparison.OrdinalIgnoreCase) + && (GroupsToProfiles?.Equals(mapping.GroupsToProfiles) ?? mapping.GroupsToProfiles is null); } public override int GetHashCode() { - return HashCode.Combine(Name.ToUpperInvariant(), GroupsToProfiles); + return HashCode.Combine( + Name is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(Name), + GroupsToProfiles?.GetHashCode() ?? 0); } public class GroupsToProfilesMapping : Dictionary @@ -36,21 +36,26 @@ public override bool Equals(object? obj) { return obj is GroupsToProfilesMapping mapping && Count == mapping.Count && - this.All(kvp => - mapping.TryGetValue(kvp.Key, out string mappingValue) - && string.Equals(mappingValue, kvp.Value, StringComparison.InvariantCultureIgnoreCase)); + this.All(kvp => + mapping.TryGetValue(kvp.Key, out string? mappingValue) + && string.Equals(mappingValue, kvp.Value, StringComparison.OrdinalIgnoreCase)); } public override int GetHashCode() { - HashCode hash = new HashCode(); + // XOR per-entry hashes for order-independence (matches Equals). + // Keys: dictionary's ordinal comparer; values: case-insensitive. + int hash = 0; foreach (var kvp in this) { - hash.Add(kvp.GetHashCode()); + int valueHash = kvp.Value is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value); + hash ^= HashCode.Combine(kvp.Key, valueHash); } - return hash.ToHashCode(); + return hash; } } } @@ -59,6 +64,8 @@ public class MappingEqualityComparer : IEqualityComparer { public bool Equals(Mapping? x, Mapping? y) { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; return x.Equals(y); } @@ -77,16 +84,29 @@ public class MappingSet public override bool Equals(object? obj) { - MappingSet test = obj as MappingSet; - return obj is MappingSet set - && set.Mappings.Length == this.Mappings.Length - && set.ActiveMappingIndex == this.ActiveMappingIndex - && !set.Mappings.Except(this.Mappings, Mapping.EqualityComparer).Any(); + if (obj is not MappingSet set) return false; + if (ActiveMappingIndex != set.ActiveMappingIndex) return false; + if (ReferenceEquals(Mappings, set.Mappings)) return true; + if (Mappings is null || set.Mappings is null) return false; + + return Mappings.Length == set.Mappings.Length + && !set.Mappings.Except(Mappings, Mapping.EqualityComparer).Any(); } public override int GetHashCode() { - return HashCode.Combine(Mappings, ActiveMappingIndex); + // XOR element hashes so the hash is order-independent, consistent + // with the set-based Equals; combine with the positional index. + int mappingsHash = 0; + if (Mappings is not null) + { + foreach (Mapping mapping in Mappings) + { + mappingsHash ^= mapping?.GetHashCode() ?? 0; + } + } + + return HashCode.Combine(mappingsHash, ActiveMappingIndex); } } } diff --git a/userspace-backend/Data/Profile.cs b/userspace-backend/Data/Profile.cs index d5e69629..599f378b 100644 --- a/userspace-backend/Data/Profile.cs +++ b/userspace-backend/Data/Profile.cs @@ -1,22 +1,21 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Text.Json.Serialization; using userspace_backend.Data.Profiles; namespace userspace_backend.Data { public class Profile { + [JsonRequired] public string Name { get; set; } public int OutputDPI { get; set; } public double YXRatio { get; set; } + [JsonRequired] public Acceleration Acceleration { get; set; } + [JsonRequired] public Hidden Hidden { get; set; } } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs index 62c2aef3..7617efdf 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/ClassicAccel.cs @@ -1,21 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class ClassicAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Classic; - public double Acceleration { get; set; } + public double Acceleration { get; set; } = FormulaDefaults.ClassicAcceleration; - public double Exponent { get; set; } + public double Exponent { get; set; } = FormulaDefaults.ClassicExponent; - public double Offset { get; set; } + public double Offset { get; set; } = FormulaDefaults.ClassicOffset; - public double Cap { get; set; } + public double Cap { get; set; } = FormulaDefaults.ClassicCap; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/FormulaDefaults.cs b/userspace-backend/Data/Profiles/Accel/Formula/FormulaDefaults.cs new file mode 100644 index 00000000..d432f74e --- /dev/null +++ b/userspace-backend/Data/Profiles/Accel/Formula/FormulaDefaults.cs @@ -0,0 +1,40 @@ +namespace userspace_backend.Data.Profiles.Accel.Formula +{ + // Single source of truth for formula defaults: used by BackEndComposer DI and the Data DTOs. + // UI defaults intentionally differ from native/Contracts; do not unify with Contracts. + public static class FormulaDefaults + { + // Synchronous + public const double SyncSpeed = 15; + public const double Motivity = 1.4; + public const double Gamma = 1; + public const double Smoothness = 0.5; + + // Linear + public const double LinearAcceleration = 0.01; + public const double LinearOffset = 0; + public const double LinearCap = 0; + + // Classic + public const double ClassicAcceleration = 0.01; + public const double ClassicExponent = 2; + public const double ClassicOffset = 0; + public const double ClassicCap = 0; + + // Power + public const double PowerScale = 1; + public const double PowerExponent = 0.05; + public const double PowerOutputOffset = 0; + public const double PowerCap = 0; + + // Jump + public const double JumpSmooth = 0.5; + public const double JumpInput = 15; + public const double JumpOutput = 1.5; + + // Natural + public const double NaturalDecayRate = 0.1; + public const double NaturalInputOffset = 0; + public const double NaturalLimit = 1.5; + } +} diff --git a/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs index b18bf4a1..b515e403 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/JumpAccel.cs @@ -1,20 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class JumpAccel : FormulaAccel - { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Jump; - public double Smooth { get; set; } + public double Smooth { get; set; } = FormulaDefaults.JumpSmooth; - public double Input { get; set; } + public double Input { get; set; } = FormulaDefaults.JumpInput; - public double Output { get; set; } + public double Output { get; set; } = FormulaDefaults.JumpOutput; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs index fbf8ccf0..c191e52b 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/LinearAccel.cs @@ -1,19 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class LinearAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Linear; - public double Acceleration { get; set; } + public double Acceleration { get; set; } = FormulaDefaults.LinearAcceleration; - public double Offset { get; set; } + public double Offset { get; set; } = FormulaDefaults.LinearOffset; - public double Cap { get; set; } + public double Cap { get; set; } = FormulaDefaults.LinearCap; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs index 81743c0a..790bfb1e 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/NaturalAccel.cs @@ -1,19 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class NaturalAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Natural; - public double DecayRate { get; set; } + public double DecayRate { get; set; } = FormulaDefaults.NaturalDecayRate; - public double InputOffset { get; set; } + public double InputOffset { get; set; } = FormulaDefaults.NaturalInputOffset; - public double Limit { get; set; } + public double Limit { get; set; } = FormulaDefaults.NaturalLimit; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs index bd615970..cf103735 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/PowerAccel.cs @@ -1,21 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class PowerAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Power; - public double Scale { get; set; } + public double Scale { get; set; } = FormulaDefaults.PowerScale; - public double Exponent { get; set; } + public double Exponent { get; set; } = FormulaDefaults.PowerExponent; - public double OutputOffset { get; set; } + public double OutputOffset { get; set; } = FormulaDefaults.PowerOutputOffset; - public double Cap { get; set; } + public double Cap { get; set; } = FormulaDefaults.PowerCap; } } diff --git a/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs b/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs index 822a79fe..a314b61b 100644 --- a/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/Formula/SynchronousAccel.cs @@ -1,21 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel.Formula { public class SynchronousAccel : FormulaAccel { public override AccelerationFormulaType FormulaType => AccelerationFormulaType.Synchronous; - public double SyncSpeed { get; set; } + public double SyncSpeed { get; set; } = FormulaDefaults.SyncSpeed; - public double Motivity { get; set; } + public double Motivity { get; set; } = FormulaDefaults.Motivity; - public double Gamma { get; set; } + public double Gamma { get; set; } = FormulaDefaults.Gamma; - public double Smoothness { get; set; } + public double Smoothness { get; set; } = FormulaDefaults.Smoothness; } } diff --git a/userspace-backend/Data/Profiles/Accel/FormulaAccel.cs b/userspace-backend/Data/Profiles/Accel/FormulaAccel.cs index f8eaf254..43865df2 100644 --- a/userspace-backend/Data/Profiles/Accel/FormulaAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/FormulaAccel.cs @@ -1,12 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel { - public class FormulaAccel : Acceleration + public abstract class FormulaAccel : Acceleration { public enum AccelerationFormulaType { @@ -18,9 +12,9 @@ public enum AccelerationFormulaType Jump = 5, } - public override AccelerationDefinitionType Type { get => AccelerationDefinitionType.Formula; } + public override AccelerationDefinitionType Type => AccelerationDefinitionType.Formula; - public virtual AccelerationFormulaType FormulaType { get; } + public abstract AccelerationFormulaType FormulaType { get; } public bool Gain { get; set; } } diff --git a/userspace-backend/Data/Profiles/Accel/LookupTableAccel.cs b/userspace-backend/Data/Profiles/Accel/LookupTableAccel.cs index dc4e4847..19f5280e 100644 --- a/userspace-backend/Data/Profiles/Accel/LookupTableAccel.cs +++ b/userspace-backend/Data/Profiles/Accel/LookupTableAccel.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel { public class LookupTableAccel : Acceleration @@ -14,10 +8,10 @@ public enum LookupTableType Sensitivity = 1, } - public override AccelerationDefinitionType Type { get => AccelerationDefinitionType.LookupTable; } + public override AccelerationDefinitionType Type => AccelerationDefinitionType.LookupTable; public LookupTableType ApplyAs { get; set; } - public double[] Data { get; set; } + public double[] Data { get; set; } = []; } } diff --git a/userspace-backend/Data/Profiles/Accel/NoAcceleration.cs b/userspace-backend/Data/Profiles/Accel/NoAcceleration.cs index 37eb57e2..d887f9ec 100644 --- a/userspace-backend/Data/Profiles/Accel/NoAcceleration.cs +++ b/userspace-backend/Data/Profiles/Accel/NoAcceleration.cs @@ -1,13 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles.Accel { public class NoAcceleration : Acceleration { - public override AccelerationDefinitionType Type { get => AccelerationDefinitionType.None; } + public override AccelerationDefinitionType Type => AccelerationDefinitionType.None; } } diff --git a/userspace-backend/Data/Profiles/Acceleration.cs b/userspace-backend/Data/Profiles/Acceleration.cs index f9656487..771afd00 100644 --- a/userspace-backend/Data/Profiles/Acceleration.cs +++ b/userspace-backend/Data/Profiles/Acceleration.cs @@ -1,18 +1,6 @@ -using System.Text.Json.Serialization; -using userspace_backend.Data.Profiles.Accel; -using userspace_backend.Data.Profiles.Accel.Formula; - namespace userspace_backend.Data.Profiles { - [JsonDerivedType(typeof(SynchronousAccel))] - [JsonDerivedType(typeof(LinearAccel))] - [JsonDerivedType(typeof(ClassicAccel))] - [JsonDerivedType(typeof(NaturalAccel))] - [JsonDerivedType(typeof(PowerAccel))] - [JsonDerivedType(typeof(JumpAccel))] - [JsonDerivedType(typeof(LookupTableAccel))] - [JsonDerivedType(typeof(NoAcceleration))] - public class Acceleration + public abstract class Acceleration { public enum AccelerationDefinitionType { @@ -21,15 +9,9 @@ public enum AccelerationDefinitionType LookupTable, } - public virtual AccelerationDefinitionType Type { get; init; } + public abstract AccelerationDefinitionType Type { get; } - public Anisotropy Anisotropy { get; set; } = new Anisotropy - { - Domain = new Vector2(), - Range = new Vector2(), - LPNorm = 2.0, - CombineXYComponents = false - }; + public Anisotropy Anisotropy { get; set; } = new Anisotropy(); public Coalescion Coalescion { get; set; } = new Coalescion(); } diff --git a/userspace-backend/Data/Profiles/Anisotropy.cs b/userspace-backend/Data/Profiles/Anisotropy.cs index 93c443ba..7dbc6ecf 100644 --- a/userspace-backend/Data/Profiles/Anisotropy.cs +++ b/userspace-backend/Data/Profiles/Anisotropy.cs @@ -1,18 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles { public class Anisotropy { - public Vector2 Domain { get; set; } = new Vector2(); + public Vector2 Domain { get; set; } = new Vector2 { X = 1, Y = 1 }; - public Vector2 Range { get; set; } = new Vector2(); + public Vector2 Range { get; set; } = new Vector2 { X = 1, Y = 1 }; - public double LPNorm { get; set; } + public double LPNorm { get; set; } = 2.0; public bool CombineXYComponents { get; set; } } diff --git a/userspace-backend/Data/Profiles/Coalescion.cs b/userspace-backend/Data/Profiles/Coalescion.cs index 79f7c0f2..b44d0157 100644 --- a/userspace-backend/Data/Profiles/Coalescion.cs +++ b/userspace-backend/Data/Profiles/Coalescion.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles { public class Coalescion diff --git a/userspace-backend/Data/Profiles/Hidden.cs b/userspace-backend/Data/Profiles/Hidden.cs index 5cb10a21..85617c79 100644 --- a/userspace-backend/Data/Profiles/Hidden.cs +++ b/userspace-backend/Data/Profiles/Hidden.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles { public class Hidden @@ -12,9 +6,9 @@ public class Hidden public double AngleSnappingDegrees { get; set; } - public double LeftRightRatio { get; set; } + public double LeftRightRatio { get; set; } = 1.0; - public double UpDownRatio { get; set; } + public double UpDownRatio { get; set; } = 1.0; public double SpeedCap { get; set; } diff --git a/userspace-backend/Data/Profiles/Vector2.cs b/userspace-backend/Data/Profiles/Vector2.cs index cca3dace..4d4eb93c 100644 --- a/userspace-backend/Data/Profiles/Vector2.cs +++ b/userspace-backend/Data/Profiles/Vector2.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace userspace_backend.Data.Profiles { public class Vector2 diff --git a/userspace-backend/Data/Settings.cs b/userspace-backend/Data/Settings.cs index a7b70a68..938c2209 100644 --- a/userspace-backend/Data/Settings.cs +++ b/userspace-backend/Data/Settings.cs @@ -1,72 +1,19 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; +using CommunityToolkit.Mvvm.ComponentModel; namespace userspace_backend.Data { - public class Settings : INotifyPropertyChanged + public partial class Settings : ObservableObject { + [ObservableProperty] private bool showToastNotifications = true; - private bool showConfirmModals = true; - private string theme = "System"; - private string language = "en-US"; - - public bool ShowToastNotifications - { - get => showToastNotifications; - set - { - if (showToastNotifications != value) - { - showToastNotifications = value; - OnPropertyChanged(); - } - } - } - - public string Theme - { - get => theme; - set - { - if (theme != value) - { - theme = value; - OnPropertyChanged(); - } - } - } - public bool ShowConfirmModals - { - get => showConfirmModals; - set - { - if (showConfirmModals != value) - { - showConfirmModals = value; - OnPropertyChanged(); - } - } - } - - public string Language - { - get => language; - set - { - if (language != value) - { - language = value; - OnPropertyChanged(); - } - } - } + [ObservableProperty] + private bool showConfirmModals = true; - public event PropertyChangedEventHandler? PropertyChanged; + [ObservableProperty] + private string theme = "System"; - protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } + [ObservableProperty] + private string language = "en-US"; } -} \ No newline at end of file +} diff --git a/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs b/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs index 32b738dc..296d32e6 100644 --- a/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs +++ b/userspace-backend/Display/Calculations/CurveCalculationHelpers.cs @@ -1,30 +1,25 @@ -using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace userspace_backend.Display.Calculations { + // TODO: Replace with curvature-adaptive sampling or adjacent. public static class CurveCalculationHelpers { + // Non-zero floor: CurvePreview divides output by MouseSpeed, and any log grid + // needs a positive minimum. Do not lower this to 0. public const double SlowestHandSpeed = 0.05; - public const double FastestHandSpeed = 200; - public const double CurvePointsResolution = 256; + public const double FastestHandSpeed = 200; // max charted hand speed + public const int CurvePointsResolution = 256; - public static ICollection CalculateCurvePointSpeeds() + public static IReadOnlyList CalculateCurvePointSpeeds() { - List curvePointSpeeds = new List(); + int count = CurvePointsResolution; + List curvePointSpeeds = new List(count); - double ratio = FastestHandSpeed / SlowestHandSpeed; - double sqrtRatio = Math.Sqrt(ratio); - double middle = sqrtRatio * SlowestHandSpeed; - double increment = 2.0 / (CurvePointsResolution - 1.0); - - for (double i = -1; i <= 1; i += increment) + for (int i = 0; i < count; i++) { - double speed = middle * Math.Pow(sqrtRatio, i); - curvePointSpeeds.Add(speed); + double t = (double)i / (count - 1); + curvePointSpeeds.Add(SlowestHandSpeed + t * (FastestHandSpeed - SlowestHandSpeed)); } return curvePointSpeeds; diff --git a/userspace-backend/Display/CurvePreview.cs b/userspace-backend/Display/CurvePreview.cs index 1f29c1ca..707d7f35 100644 --- a/userspace-backend/Display/CurvePreview.cs +++ b/userspace-backend/Display/CurvePreview.cs @@ -1,11 +1,10 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using userspace_backend.Display.Calculations; +using userspace_backend.Driver; +using RaProfile = RawAccel.Contracts.RawAccelProfile; namespace userspace_backend.Display { @@ -13,30 +12,33 @@ public interface ICurvePreview { ObservableCollection Points { get; } - void GeneratePoints(Profile profile); + void GeneratePoints(RaProfile profile); void SetPoints(IEnumerable points); } public class CurvePreview : ICurvePreview { - public CurvePreview() + private readonly IAccelEvaluator evaluator; + + public CurvePreview(IAccelEvaluator evaluator) { + this.evaluator = evaluator; Points = new ObservableCollection(); InitPoints(); } public ObservableCollection Points { get; } - public void GeneratePoints(Profile profile) + public void GeneratePoints(RaProfile profile) { - ManagedAccel accel = new ManagedAccel(profile).CreateStatelessCopy(); + using IAccelInstance instance = evaluator.CreateInstance(profile); foreach (CurvePoint point in Points) { - var output = accel.Accelerate(point.MouseSpeed, 0, 1, 1); - var outputSpeed = Math.Sqrt(Math.Pow(output.Item1, 2) + Math.Pow(output.Item2, 2)); - point.Output = outputSpeed / point.MouseSpeed; + var (ox, oy) = instance.Accelerate(point.MouseSpeed, 0, dpiFactor: 1, timeMs: 1); + var outputSpeed = Math.Sqrt(ox * ox + oy * oy); + point.Output = point.MouseSpeed > 0 ? outputSpeed / point.MouseSpeed : 0.0; } } @@ -49,9 +51,9 @@ public void SetPoints(IEnumerable points) } } - protected void InitPoints() + private void InitPoints() { - ICollection speeds = CurveCalculationHelpers.CalculateCurvePointSpeeds(); + IReadOnlyList speeds = CurveCalculationHelpers.CalculateCurvePointSpeeds(); foreach (double speed in speeds) { diff --git a/userspace-backend/Driver/IAccelEvaluator.cs b/userspace-backend/Driver/IAccelEvaluator.cs new file mode 100644 index 00000000..f676252a --- /dev/null +++ b/userspace-backend/Driver/IAccelEvaluator.cs @@ -0,0 +1,23 @@ +using System; +using RawAccel.Contracts; + +namespace userspace_backend.Driver +{ + // Separate from IRawAccelDriver: preview is pure math, doesn't touch the backend. + // + // Windows: wraps wrapper.ManagedAccel.CreateStatelessCopy over the same common/ math. + // Linux: P/Invokes a stripped libcommon.so (deferred); identity stub for now. + public interface IAccelEvaluator + { + IAccelInstance CreateInstance(RawAccelProfile profile); + } + + // IDisposable: the Linux ShimInstance holds an unmanaged ra_curve handle, + // and preview callers create one per refresh. + public interface IAccelInstance : IDisposable + { + // dpiFactor: device DPI / NORMALIZED_DPI (1000). timeMs: slice for this sample. + // Returns same units as input. + (double x, double y) Accelerate(double x, double y, double dpiFactor, double timeMs); + } +} diff --git a/userspace-backend/Driver/IRawAccelDriver.cs b/userspace-backend/Driver/IRawAccelDriver.cs new file mode 100644 index 00000000..66ee77ae --- /dev/null +++ b/userspace-backend/Driver/IRawAccelDriver.cs @@ -0,0 +1,25 @@ +using RawAccel.Contracts; + +namespace userspace_backend.Driver +{ + // Platform-agnostic driver surface. + // + // Error contract (Windows and Linux impls both): + // - Apply: returns false on failure, logs, never throws. + // - Read, Deactivate: throw on failure (expected to succeed once IsAvailable). + // - GetCurrentMouseSpeedSample: Zero on error, never throws -- callers + // can't distinguish idle from unavailable. + public interface IRawAccelDriver + { + // UI gates Apply on this. + bool IsAvailable { get; } + + bool Apply(RawAccelConfig config); + + RawAccelConfig Read(); + + void Deactivate(); + + MouseSpeedSample GetCurrentMouseSpeedSample(); + } +} diff --git a/userspace-backend/Driver/MouseSpeedSample.cs b/userspace-backend/Driver/MouseSpeedSample.cs new file mode 100644 index 00000000..14fd0edb --- /dev/null +++ b/userspace-backend/Driver/MouseSpeedSample.cs @@ -0,0 +1,11 @@ +namespace userspace_backend.Driver +{ + // Live input speed in counts/ms normalized to 1000 DPI (curve-math units). + // Combined is the agent-computed magnitude (not necessarily hypot(X, Y)). + public readonly record struct MouseSpeedSample(double X, double Y, double Combined) + { + public static readonly MouseSpeedSample Zero = new(0, 0, 0); + + public bool IsZero => X == 0 && Y == 0 && Combined == 0; + } +} diff --git a/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs b/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs new file mode 100644 index 00000000..36300d7b --- /dev/null +++ b/userspace-backend/Driver/Windows/ManagedAccelEvaluator.cs @@ -0,0 +1,44 @@ +using System; +using Newtonsoft.Json; +using RawAccel.Contracts; + +namespace userspace_backend.Driver.Windows +{ + // IAccelEvaluator over wrapper.ManagedAccel (same common/ math as the driver). + // POCO -> wrapper.Profile via JSON round-trip (JsonProperty names match on + // both sides), so this stays in lockstep with the apply path. + public sealed class ManagedAccelEvaluator : IAccelEvaluator + { + public IAccelInstance CreateInstance(RawAccelProfile profile) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + var json = JsonConvert.SerializeObject(profile); + var nativeProfile = JsonConvert.DeserializeObject(json) + ?? throw new InvalidOperationException( + "POCO -> wrapper.Profile deserialization returned null"); + // Seed is disposed; CreateStatelessCopy allocates a fresh native pair. + using var seed = new ManagedAccel(nativeProfile); + var accel = seed.CreateStatelessCopy(); + return new ManagedAccelInstance(accel); + } + + private sealed class ManagedAccelInstance : IAccelInstance + { + private readonly ManagedAccel accel; + + public ManagedAccelInstance(ManagedAccel accel) + { + this.accel = accel; + } + + public (double x, double y) Accelerate( + double x, double y, double dpiFactor, double timeMs) + { + var t = accel.Accelerate(x, y, dpiFactor, timeMs); + return (t.Item1, t.Item2); + } + + public void Dispose() => accel.Dispose(); + } + } +} diff --git a/userspace-backend/Driver/Windows/RawInputMouseListener.cs b/userspace-backend/Driver/Windows/RawInputMouseListener.cs new file mode 100644 index 00000000..248cb7ac --- /dev/null +++ b/userspace-backend/Driver/Windows/RawInputMouseListener.cs @@ -0,0 +1,470 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using RawAccel.Contracts; + +namespace userspace_backend.Driver.Windows +{ + // Captures live mouse speed via Win32 raw input on its own message-only + // window + thread (Avalonia exposes no WndProc to hook). Reports in chart + // units: counts/ms normalized to 1000 DPI. + // + // Raw input keys devices by HANDLE; config keys DPI by hardware-id. We map + // handle -> id via wrapper.dll's MultiHandleDevice; unmapped handles use the + // default factor. + internal sealed class RawInputMouseListener : IDisposable + { + // No movement for this long => Zero (line eases back to rest). + private const double FreshnessMs = 150.0; + + // Clamp inter-event interval so bursts/stalls don't spike or flatline. + // 0.1 ms = 10 kHz ceiling, above any real polling rate. + private const double MinIntervalMs = 0.1; + private const double MaxIntervalMs = 100.0; + + private const int LifecycleTimeoutMs = 2000; + + // Win32 constants. + private const uint WM_DESTROY = 0x0002; + private const uint WM_CLOSE = 0x0010; + private const uint WM_QUIT = 0x0012; + private const uint WM_INPUT = 0x00FF; + private const uint WM_INPUT_DEVICE_CHANGE = 0x00FE; + private const uint RID_INPUT = 0x10000003; + private const uint RIDEV_INPUTSINK = 0x00000100; + private const uint RIDEV_DEVNOTIFY = 0x00002000; + private const uint RIM_TYPEMOUSE = 0; + private const ushort MOUSE_MOVE_ABSOLUTE = 0x01; + private const uint RAWINPUT_ERROR = unchecked((uint)-1); + private static readonly IntPtr HWND_MESSAGE = new(-3); + + // Distinct class name per instance: a re-created listener can't collide + // with a not-yet-freed class. + private static int instanceCounter; + + private readonly ILogger logger; + private readonly string className; + private readonly object gate = new(); + private readonly object lifecycleGate = new(); + private readonly ManualResetEventSlim ready = new(false); + + private Thread? thread; + private uint nativeThreadId; + private IntPtr hwnd; + private WndProcDelegate? wndProc; // kept alive against GC for RegisterClass + private volatile bool running; + private volatile bool disposed; + + // Latest speed (under gate); lastEventTimestamp is the freshness + + // inter-event clock (Interlocked). + private double lastX, lastY, lastCombined; + private long lastEventTimestamp; + + // handle -> NormalizedDpi/dpi; defaultFactor for the rest. Volatile + + // build-once-publish keeps HandleRawInput lock-free on the hot path. + private volatile Dictionary handleFactors = new(); + private double defaultFactor = 1.0; + + // Applied config's DPI-by-id, used to rebuild handleFactors. + private Dictionary dpiById = new(StringComparer.OrdinalIgnoreCase); + private int defaultDpi = (int)RawAccelConstants.NormalizedDpi; + + public RawInputMouseListener(ILogger? logger = null) + { + this.logger = logger ?? NullLogger.Instance; + int id = Interlocked.Increment(ref instanceCounter); + className = $"RawAccelRawInputSink_{Environment.ProcessId}_{id}"; + } + + // Starts the capture thread. Idempotent. Blocks briefly until ready. + public void Start() + { + lock (lifecycleGate) + { + if (disposed || thread != null) return; + thread = new Thread(ThreadMain) + { + IsBackground = true, + Name = "RawAccelRawInput", + }; + thread.Start(); + } + ready.Wait(LifecycleTimeoutMs); + } + + // Feeds per-device DPI from the applied config. Safe before Start. + public void UpdateDevices(RawAccelConfig config) + { + int defDpi = config.defaultDeviceConfig?.dpi ?? (int)RawAccelConstants.NormalizedDpi; + if (defDpi <= 0) defDpi = (int)RawAccelConstants.NormalizedDpi; + + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (config.devices != null) + { + foreach (var dev in config.devices) + { + if (string.IsNullOrEmpty(dev.id)) continue; + byId[dev.id] = dev.config?.dpi ?? defDpi; + } + } + + lock (gate) + { + dpiById = byId; + defaultDpi = defDpi; + } + RebuildHandleMap(); + } + + // Current normalized input speed; Zero if idle/unavailable. + public MouseSpeedSample CurrentSample() + { + if (!running) return MouseSpeedSample.Zero; + + long last = Interlocked.Read(ref lastEventTimestamp); + if (last == 0) return MouseSpeedSample.Zero; + + double sinceMs = (Stopwatch.GetTimestamp() - last) * 1000.0 / Stopwatch.Frequency; + if (sinceMs > FreshnessMs) return MouseSpeedSample.Zero; + + lock (gate) + { + return new MouseSpeedSample(lastX, lastY, lastCombined); + } + } + + public void Dispose() + { + lock (lifecycleGate) + { + if (disposed) return; + disposed = true; + running = false; + } + + // Paired with ThreadMain's Volatile.Write outside any lock. + uint tid = Volatile.Read(ref nativeThreadId); + if (tid != 0) + { + // Wake the loop; the thread tears down its own window/class. + PostThreadMessageW(tid, WM_QUIT, IntPtr.Zero, IntPtr.Zero); + } + thread?.Join(LifecycleTimeoutMs); + ready.Dispose(); + } + + // ---------------------------------------------------------------------------- + // Capture thread + // ---------------------------------------------------------------------------- + + private void ThreadMain() + { + Volatile.Write(ref nativeThreadId, GetCurrentThreadId()); + + IntPtr hInstance = GetModuleHandleW(null); + wndProc = WindowProc; + + var wc = new WNDCLASS + { + lpfnWndProc = wndProc, + hInstance = hInstance, + lpszClassName = className, + }; + + bool classRegistered = false; + + try + { + if (RegisterClassW(ref wc) == 0) + { + logger.LogWarning("RegisterClass failed (err {Err}); speed line disabled", + Marshal.GetLastWin32Error()); + ready.Set(); + return; + } + classRegistered = true; + + hwnd = CreateWindowExW(0, className, string.Empty, 0, 0, 0, 0, 0, + HWND_MESSAGE, IntPtr.Zero, hInstance, IntPtr.Zero); + if (hwnd == IntPtr.Zero) + { + logger.LogWarning("CreateWindowEx failed (err {Err}); speed line disabled", + Marshal.GetLastWin32Error()); + ready.Set(); + return; + } + + var devices = new[] + { + new RAWINPUTDEVICE + { + UsagePage = 0x01, // generic desktop + Usage = 0x02, // mouse + Flags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, + hwndTarget = hwnd, + }, + }; + if (!RegisterRawInputDevices(devices, 1, (uint)Marshal.SizeOf())) + { + logger.LogWarning("RegisterRawInputDevices failed (err {Err}); speed line disabled", + Marshal.GetLastWin32Error()); + ready.Set(); + return; + } + + RebuildHandleMap(); + running = true; + ready.Set(); + + logger.LogDebug( + "raw input listener active: hwnd=0x{Hwnd:x}, mapped handles={Count}", + hwnd.ToInt64(), handleFactors.Count); + + while (GetMessageW(out MSG msg, IntPtr.Zero, 0, 0) > 0) + { + DispatchMessageW(ref msg); + } + } + catch (Exception ex) + { + logger.LogError(ex, "raw input listener thread failed"); + ready.Set(); + } + finally + { + running = false; + if (hwnd != IntPtr.Zero) + { + DestroyWindow(hwnd); + hwnd = IntPtr.Zero; + } + if (classRegistered) UnregisterClassW(className, hInstance); + } + } + + private IntPtr WindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) + { + switch (msg) + { + case WM_INPUT: + try { HandleRawInput(lParam); } + catch (Exception ex) { logger.LogTrace(ex, "WM_INPUT handling failed"); } + return DefWindowProcW(hWnd, msg, wParam, lParam); + + case WM_INPUT_DEVICE_CHANGE: + try { RebuildHandleMap(); } + catch (Exception ex) { logger.LogTrace(ex, "device map rebuild failed"); } + return IntPtr.Zero; + + case WM_CLOSE: + DestroyWindow(hWnd); + return IntPtr.Zero; + + case WM_DESTROY: + PostQuitMessage(0); + return IntPtr.Zero; + + default: + return DefWindowProcW(hWnd, msg, wParam, lParam); + } + } + + // Cached: Marshal reflection per WM_INPUT would burn the hot path. + private static readonly uint RawInputMouseSize = (uint)Marshal.SizeOf(); + private static readonly uint RawInputHeaderSize = + (uint)Marshal.OffsetOf(nameof(RAWINPUTMOUSE.MouseFlags)); + + private void HandleRawInput(IntPtr hRawInput) + { + uint size = RawInputMouseSize; + if (GetRawInputData(hRawInput, RID_INPUT, out RAWINPUTMOUSE data, ref size, RawInputHeaderSize) + == RAWINPUT_ERROR) + { + return; + } + + if (data.Type != RIM_TYPEMOUSE) return; + if ((data.MouseFlags & MOUSE_MOVE_ABSOLUTE) != 0) return; // only relative motion + if (data.LastX == 0 && data.LastY == 0) return; + + double factor = FactorForHandle(data.Device); + + long now = Stopwatch.GetTimestamp(); + long prev = Interlocked.Exchange(ref lastEventTimestamp, now); + double dtMs = prev == 0 + ? MaxIntervalMs + : (now - prev) * 1000.0 / Stopwatch.Frequency; + if (dtMs < MinIntervalMs) dtMs = MinIntervalMs; + else if (dtMs > MaxIntervalMs) dtMs = MaxIntervalMs; + + double dx = data.LastX; + double dy = data.LastY; + double speedX = Math.Abs(dx) * factor / dtMs; + double speedY = Math.Abs(dy) * factor / dtMs; + double speedCombined = Math.Sqrt(dx * dx + dy * dy) * factor / dtMs; + + lock (gate) + { + lastX = speedX; + lastY = speedY; + lastCombined = speedCombined; + } + } + + private static double FactorFor(int dpi) => + dpi > 0 ? RawAccelConstants.NormalizedDpi / dpi : 1.0; + + private double FactorForHandle(IntPtr handle) + { + var map = handleFactors; + return map.TryGetValue(handle, out double f) ? f : Volatile.Read(ref defaultFactor); + } + + // Rebuilds handle -> factor from the live device list + config. + private void RebuildHandleMap() + { + var map = new Dictionary(); + Dictionary byId; + int defDpi; + lock (gate) + { + byId = dpiById; + defDpi = defaultDpi; + } + double defFactor = FactorFor(defDpi); + + try + { + foreach (MultiHandleDevice device in MultiHandleDevice.GetList()) + { + int dpi = (device.id != null && byId.TryGetValue(device.id, out int d) && d > 0) + ? d + : defDpi; + double factor = FactorFor(dpi); + foreach (IntPtr handle in device.handles) + { + map[handle] = factor; + } + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "enumerating raw input devices failed"); + } + + Volatile.Write(ref defaultFactor, defFactor); + handleFactors = map; // volatile store publishes the new map + } + + // ---------------------------------------------------------------------------- + // P/Invoke + // ---------------------------------------------------------------------------- + + private delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WNDCLASS + { + public uint style; + public WndProcDelegate lpfnWndProc; + public int cbClsExtra; + public int cbWndExtra; + public IntPtr hInstance; + public IntPtr hIcon; + public IntPtr hCursor; + public IntPtr hbrBackground; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpszMenuName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MSG + { + public IntPtr hwnd; + public uint message; + public IntPtr wParam; + public IntPtr lParam; + public uint time; + public int ptX; + public int ptY; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTDEVICE + { + public ushort UsagePage; + public ushort Usage; + public uint Flags; + public IntPtr hwndTarget; + } + + // Flattened RAWINPUTHEADER + RAWMOUSE; Padding mirrors the native + // union's 4-byte alignment after MouseFlags. + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTMOUSE + { + // RAWINPUTHEADER + public uint Type; + public uint Size; + public IntPtr Device; + public IntPtr wParam; + // RAWMOUSE + public ushort MouseFlags; + public ushort Padding; + public ushort ButtonFlags; + public ushort ButtonData; + public uint RawButtons; + public int LastX; + public int LastY; + public uint ExtraInformation; + } + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern ushort RegisterClassW(ref WNDCLASS lpWndClass); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CreateWindowExW(uint dwExStyle, string lpClassName, + string lpWindowName, uint dwStyle, int x, int y, int nWidth, int nHeight, + IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); + + [DllImport("user32.dll")] + private static extern bool DestroyWindow(IntPtr hWnd); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr DefWindowProcW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetMessageW(out MSG lpMsg, IntPtr hWnd, + uint wMsgFilterMin, uint wMsgFilterMax); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr DispatchMessageW(ref MSG lpMsg); + + [DllImport("user32.dll")] + private static extern void PostQuitMessage(int nExitCode); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool PostThreadMessageW(uint idThread, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterRawInputDevices( + [In] RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetRawInputData(IntPtr hRawInput, uint uiCommand, + out RAWINPUTMOUSE pData, ref uint pcbSize, uint cbSizeHeader); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandleW(string? lpModuleName); + + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + } +} diff --git a/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs new file mode 100644 index 00000000..719aeecc --- /dev/null +++ b/userspace-backend/Driver/Windows/WindowsRawAccelDriver.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json; +using RawAccel.Contracts; + +namespace userspace_backend.Driver.Windows +{ + public sealed class WindowsRawAccelDriver : IRawAccelDriver, IDisposable + { + private readonly ILogger logger; + private readonly object listenerGate = new(); + + // Lazy: unused paths skip the window + thread. + // Volatile for EnsureListener's lock-free fast path. + private volatile RawInputMouseListener? listener; + + // Replayed into the listener for per-device DPI. + // Volatile: written by Apply, read by EnsureListener under a different lock. + private volatile RawAccelConfig? lastConfig; + + private volatile bool disposed; + + public WindowsRawAccelDriver(ILogger? logger = null) + { + this.logger = logger ?? NullLogger.Instance; + } + + public bool IsAvailable + { + get + { + try + { + VersionHelper.ValidOrThrow(); + return true; + } + catch (Exception ex) + { + logger.LogDebug(ex, "driver version probe failed"); + return false; + } + } + } + + public bool Apply(RawAccelConfig config) + { + try + { + var json = JsonConvert.SerializeObject(config); + + var (native, errors) = DriverConfig.Convert(json); + if (!string.IsNullOrEmpty(errors)) + { + logger.LogError("driver rejected settings: {Errors}", errors); + return false; + } + native.Activate(); + lastConfig = config; + } + catch (Exception ex) + { + logger.LogError(ex, "driver apply failed"); + return false; + } + + // Driver is already active; don't fail Apply for a listener hiccup. + try { listener?.UpdateDevices(config); } + catch (Exception ex) { logger.LogDebug(ex, "listener device update failed after apply"); } + + return true; + } + + public RawAccelConfig Read() + { + var native = DriverConfig.GetActive(); + var json = native.ToJSON(); + return JsonConvert.DeserializeObject(json) + ?? throw new InvalidOperationException( + "wrapper.DriverConfig -> POCO deserialization returned null"); + } + + public void Deactivate() + { + DriverConfig.Deactivate(); + } + + public MouseSpeedSample GetCurrentMouseSpeedSample() + { + try + { + return EnsureListener().CurrentSample(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "mouse speed sample failed"); + return MouseSpeedSample.Zero; + } + } + + private RawInputMouseListener EnsureListener() + { + var existing = listener; + if (existing != null) return existing; + + lock (listenerGate) + { + if (disposed) + throw new ObjectDisposedException(nameof(WindowsRawAccelDriver)); + if (listener == null) + { + var created = new RawInputMouseListener(logger); + created.Start(); + if (lastConfig != null) created.UpdateDevices(lastConfig); + listener = created; + } + return listener; + } + } + + public void Dispose() + { + RawInputMouseListener? toDispose; + lock (listenerGate) + { + if (disposed) return; + disposed = true; + toDispose = listener; + listener = null; + } + // Outside the lock so thread-join can't block EnsureListener. + toDispose?.Dispose(); + } + } +} diff --git a/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs b/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs new file mode 100644 index 00000000..40d4d82d --- /dev/null +++ b/userspace-backend/Driver/Windows/WindowsSystemDevicesRetriever.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; +using userspace_backend.Model; + +namespace userspace_backend.Driver.Windows +{ + // Windows device enumeration via wrapper.dll's MultiHandleDevice. + public sealed class WindowsSystemDevicesRetriever : ISystemDevicesRetriever + { + public IList GetSystemDevices() + { + IList rawDevices = MultiHandleDevice.GetList(); + return rawDevices.Select(d => new WindowsSystemDevice(d)).ToList(); + } + } + + public sealed class WindowsSystemDevice : ISystemDevice + { + public WindowsSystemDevice(MultiHandleDevice multiHandleDevice) + { + Name = multiHandleDevice.name ?? string.Empty; + HWID = multiHandleDevice.id ?? string.Empty; + } + + public string Name { get; } + + public string HWID { get; } + } +} diff --git a/userspace-backend/DriverConfigActivator.cs b/userspace-backend/DriverConfigActivator.cs deleted file mode 100644 index 03d78489..00000000 --- a/userspace-backend/DriverConfigActivator.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace userspace_backend -{ - public interface IDriverConfigActivator - { - void Write(DriverConfig config); - } - - public sealed class DriverConfigActivator : IDriverConfigActivator - { - public void Write(DriverConfig config) => config.Activate(); - } -} diff --git a/userspace-backend/IO/DevicesReaderWriter.cs b/userspace-backend/IO/DevicesReaderWriter.cs index 964f7e8c..6db902aa 100644 --- a/userspace-backend/IO/DevicesReaderWriter.cs +++ b/userspace-backend/IO/DevicesReaderWriter.cs @@ -8,7 +8,7 @@ namespace userspace_backend.IO { public class DevicesReaderWriter : ReaderWriterBase> { - public static JsonSerializerOptions JsonOptions = new JsonSerializerOptions { WriteIndented = true }; + public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { WriteIndented = true }; protected override string FileType => "Devices"; @@ -17,7 +17,7 @@ public override string Serialize(IEnumerable devices) return JsonSerializer.Serialize(devices, JsonOptions); } - public override IEnumerable Deserialize(string toRead) + public override IEnumerable? Deserialize(string toRead) { return JsonSerializer.Deserialize>(toRead, JsonOptions); } diff --git a/userspace-backend/IO/MappingsReaderWriter.cs b/userspace-backend/IO/MappingsReaderWriter.cs index 5c4d5018..c7574392 100644 --- a/userspace-backend/IO/MappingsReaderWriter.cs +++ b/userspace-backend/IO/MappingsReaderWriter.cs @@ -11,7 +11,7 @@ namespace userspace_backend.IO { public class MappingsReaderWriter : ReaderWriterBase { - public static JsonSerializerOptions JsonOptions = new JsonSerializerOptions + public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { WriteIndented = true, }; @@ -23,7 +23,7 @@ public override string Serialize(MappingSet toWrite) return JsonSerializer.Serialize(toWrite, JsonOptions); } - public override MappingSet Deserialize(string toRead) + public override MappingSet? Deserialize(string toRead) { return JsonSerializer.Deserialize(toRead, JsonOptions); } diff --git a/userspace-backend/IO/ProfileReaderWriter.cs b/userspace-backend/IO/ProfileReaderWriter.cs index 175c7067..39444f06 100644 --- a/userspace-backend/IO/ProfileReaderWriter.cs +++ b/userspace-backend/IO/ProfileReaderWriter.cs @@ -1,24 +1,26 @@ using System.Text.Json; using System.Text.Json.Serialization; using DATA = userspace_backend.Data; +using userspace_backend.IO.Serialization; namespace userspace_backend.IO { public class ProfileReaderWriter : ReaderWriterBase { - public static JsonSerializerOptions JsonOptions = new JsonSerializerOptions + public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter(), + new AccelerationJsonConverter(), } }; protected override string FileType => "Profile"; - public override DATA.Profile Deserialize(string toRead) + public override DATA.Profile? Deserialize(string toRead) { return JsonSerializer.Deserialize(toRead, JsonOptions); } diff --git a/userspace-backend/IO/ReaderWriterBase.cs b/userspace-backend/IO/ReaderWriterBase.cs index 8735fc34..3655b48f 100644 --- a/userspace-backend/IO/ReaderWriterBase.cs +++ b/userspace-backend/IO/ReaderWriterBase.cs @@ -1,9 +1,5 @@ -using System; -using System.Collections.Generic; +using System; using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace userspace_backend.IO { @@ -20,18 +16,19 @@ public void Write(string path, T toWrite) var parent = Directory.GetParent(path)?.FullName; - if (!Directory.Exists(parent)) + if (parent != null && !Directory.Exists(parent)) { Directory.CreateDirectory(parent); } - var devicesText = Serialize(toWrite); + var serialized = Serialize(toWrite); using (StreamWriter outputFile = new StreamWriter(path)) { - outputFile.Write(devicesText); + outputFile.Write(serialized); } } + public T Read(string path) { if (!File.Exists(path)) @@ -39,25 +36,30 @@ public T Read(string path) throw new FileNotFoundException(path); } - T readIn = default; - - try + string fileText; + using (StreamReader fileToRead = new StreamReader(path)) { - using (StreamReader fileToRead = new StreamReader(path)) - { - var fileText = fileToRead.ReadToEnd(); + fileText = fileToRead.ReadToEnd(); + } - if (string.IsNullOrWhiteSpace(fileText)) - { - throw new Exception($"{FileType} file is empty."); - } + if (string.IsNullOrWhiteSpace(fileText)) + { + throw new Exception($"{FileType} file is empty."); + } - readIn = Deserialize(fileText); - } + T? readIn; + try + { + readIn = Deserialize(fileText); } catch (Exception ex) { - throw new Exception($"Error parsing devices file at path {path}", ex); + throw new Exception($"Error parsing {FileType} file at path {path}", ex); + } + + if (readIn is null) + { + throw new Exception($"{FileType} file deserialized to null at path {path}."); } return readIn; @@ -65,6 +67,6 @@ public T Read(string path) public abstract string Serialize(T toWrite); - public abstract T Deserialize(string toRead); + public abstract T? Deserialize(string toRead); } } diff --git a/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs b/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs index 7a86db15..416eed31 100644 --- a/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs +++ b/userspace-backend/IO/Serialization/AccelerationJsonConverter.cs @@ -1,10 +1,7 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using System.Threading.Tasks; using userspace_backend.Data.Profiles; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; @@ -76,8 +73,8 @@ private static AccelerationDefinitionType DetermineDefinitionType(string typeStr { if (!Enum.TryParse(typeStringFromJson, ignoreCase: true, out AccelerationDefinitionType result)) { - throw new JsonException($"Acceleration base type [\"{typeStringFromJson}\"] not valid." + - $"Valid values: [{string.Join(", ", Enum.GetNames(typeof(AccelerationDefinitionType)))}"); + throw new JsonException($"Acceleration base type [\"{typeStringFromJson}\"] not valid. " + + $"Valid values: [{string.Join(", ", Enum.GetNames(typeof(AccelerationDefinitionType)))}]"); } return result; @@ -99,7 +96,7 @@ private static Acceleration CreateAccelerationOfType(AccelerationDefinitionType private static FormulaAccel CreateFormulaAccel(string[] defnSplit, ref Utf8JsonReader readerFromStart) { - if (defnSplit.Length < 1) + if (defnSplit.Length < 2) { throw new JsonException("Type \"Formula\" must be followed by a forward slash and formula type. Example: \"Type\": \"Formula/Classic\""); } @@ -115,10 +112,18 @@ private static FormulaAccel CreateFormulaAccel(string[] defnSplit, ref Utf8JsonR switch (formulaType) { + case AccelerationFormulaType.Synchronous: + return JsonSerializer.Deserialize(ref readerFromStart); case AccelerationFormulaType.Linear: return JsonSerializer.Deserialize(ref readerFromStart); case AccelerationFormulaType.Classic: return JsonSerializer.Deserialize(ref readerFromStart); + case AccelerationFormulaType.Power: + return JsonSerializer.Deserialize(ref readerFromStart); + case AccelerationFormulaType.Natural: + return JsonSerializer.Deserialize(ref readerFromStart); + case AccelerationFormulaType.Jump: + return JsonSerializer.Deserialize(ref readerFromStart); default: throw new JsonException($"Unknown formula type {formulaTypeString}"); } @@ -128,8 +133,8 @@ private static AccelerationFormulaType DetermineFormulaType(string formulaTypeFr { if (!Enum.TryParse(formulaTypeFromJson, ignoreCase: true, out AccelerationFormulaType result)) { - throw new JsonException($"Acceleration formula type [\"{formulaTypeFromJson}\"] not valid." + - $"Valid values: [{string.Join(", ", Enum.GetNames(typeof(AccelerationFormulaType)))}"); + throw new JsonException($"Acceleration formula type [\"{formulaTypeFromJson}\"] not valid. " + + $"Valid values: [{string.Join(", ", Enum.GetNames(typeof(AccelerationFormulaType)))}]"); } return result; @@ -142,6 +147,52 @@ private static LookupTableAccel CreateLookupTableAccel(ref Utf8JsonReader reader public override void Write(Utf8JsonWriter writer, Acceleration value, JsonSerializerOptions options) { + // Serialize the runtime type's fields, then overwrite "Type" with + // the discriminator Read expects (e.g. "Formula/Classic", "LookupTable", + // "None"). The fresh options instance excludes this converter to + // avoid recursing into ourselves. + JsonNode? node = JsonSerializer.SerializeToNode( + value, value.GetType(), WriteOptionsWithoutSelf(options)); + + if (node is JsonObject obj) + { + obj["Type"] = GetDiscriminator(value); + // FormulaType is folded into Type ("Formula/Classic"); don't emit twice. + obj.Remove("FormulaType"); + } + node?.WriteTo(writer); + } + + private static string GetDiscriminator(Acceleration v) => v switch + { + NoAcceleration => "None", + LookupTableAccel => "LookupTable", + FormulaAccel f => $"Formula/{f.FormulaType}", + _ => v.Type.ToString(), + }; + + // Cache derived options keyed by source identity. A reader/writer reuses + // one JsonSerializerOptions for its lifetime, so this is a near-perfect hit; + // a different source just re-derives. The first-call race wastes at most + // one copy -- no locking needed. + private JsonSerializerOptions? cachedSource; + private JsonSerializerOptions? cachedWriteOptions; + + private JsonSerializerOptions WriteOptionsWithoutSelf(JsonSerializerOptions src) + { + if (!ReferenceEquals(src, cachedSource)) + { + var copy = new JsonSerializerOptions(src); + for (int i = copy.Converters.Count - 1; i >= 0; --i) + { + if (copy.Converters[i] is AccelerationJsonConverter) + copy.Converters.RemoveAt(i); + } + cachedSource = src; + cachedWriteOptions = copy; + } + + return cachedWriteOptions!; } } } diff --git a/userspace-backend/IO/SettingsReaderWriter.cs b/userspace-backend/IO/SettingsReaderWriter.cs index 4e8cdcf9..58971a6f 100644 --- a/userspace-backend/IO/SettingsReaderWriter.cs +++ b/userspace-backend/IO/SettingsReaderWriter.cs @@ -6,10 +6,9 @@ namespace userspace_backend.IO { public class SettingsReaderWriter : ReaderWriterBase { - public static JsonSerializerOptions JsonOptions = new JsonSerializerOptions - { + public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; protected override string FileType => "Settings"; @@ -21,14 +20,9 @@ public override string Serialize(Settings settings) public override Settings Deserialize(string toRead) { - try - { - return JsonSerializer.Deserialize(toRead, JsonOptions) ?? new Settings(); - } - catch (JsonException) - { - return new Settings(); - } + // Literal "null" -> defaults; malformed JSON throws for the caller + // (BackEndLoader.LoadSettings) to handle, matching sibling readers. + return JsonSerializer.Deserialize(toRead, JsonOptions) ?? new Settings(); } } } \ No newline at end of file diff --git a/userspace-backend/Logging/EditableSettingLog.cs b/userspace-backend/Logging/EditableSettingLog.cs deleted file mode 100644 index 22debcdf..00000000 --- a/userspace-backend/Logging/EditableSettingLog.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace userspace_backend.Logging -{ - public static class EditableSettingLog - { - public static ILogger Logger { get; private set; } = NullLogger.Instance; - - public static void Configure(ILoggerFactory factory) - { - Logger = factory.CreateLogger("userspace_backend.EditableSetting"); - } - } -} diff --git a/userspace-backend/Logging/FileLoggerProvider.cs b/userspace-backend/Logging/FileLoggerProvider.cs index aa0dcdbf..0739bfa9 100644 --- a/userspace-backend/Logging/FileLoggerProvider.cs +++ b/userspace-backend/Logging/FileLoggerProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Diagnostics; using System.IO; using System.Text; using Microsoft.Extensions.Logging; @@ -30,7 +31,7 @@ public ILogger CreateLogger(string categoryName) internal void Append(string categoryName, LogLevel level, string message, Exception? exception) { var sb = new StringBuilder(); - sb.Append('[').Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append("] "); + sb.Append('[').Append(DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fffZ")).Append("] "); sb.Append('[').Append(level).Append("] "); sb.Append('[').Append(categoryName).Append("] "); sb.Append(message); @@ -47,9 +48,11 @@ internal void Append(string categoryName, LogLevel level, string message, Except { File.AppendAllText(filePath, sb.ToString(), Encoding.UTF8); } - catch + catch (Exception ex) { - // Never crash the app because of a log write failure. + // Never crash the app because of a log write failure, but make the + // failure observable instead of silently losing log output. + Debug.WriteLine($"FileLoggerProvider failed to write to '{filePath}': {ex}"); } } } diff --git a/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs index 17b194f1..dd985872 100644 --- a/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/AccelDefinitionModel.cs @@ -1,11 +1,12 @@ -using userspace_backend.Data.Profiles; +using userspace_backend.Data.Profiles; using userspace_backend.Model.EditableSettings; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; namespace userspace_backend.Model.AccelDefinitions { public interface IAccelDefinitionModel : IEditableSettingsCollectionV2 { - AccelArgs MapToDriver(); + RaAccelArgs MapToDriver(); } public interface IAccelDefinitionModelSpecific : IAccelDefinitionModel, IEditableSettingsCollectionSpecific where T : Acceleration diff --git a/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs b/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs index 36676d7d..80bf33eb 100644 --- a/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs +++ b/userspace-backend/Model/AccelDefinitions/AccelerationModel.cs @@ -1,9 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using System; using userspace_backend.Data.Profiles; using userspace_backend.Model.EditableSettings; using userspace_backend.Model.ProfileComponents; using static userspace_backend.Data.Profiles.Acceleration; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; namespace userspace_backend.Model.AccelDefinitions { @@ -15,7 +16,7 @@ public interface IAccelerationModel : IEditableSettingsSelector, IAccelerationModel @@ -61,7 +62,7 @@ public override Acceleration MapToData() return acceleration; } - public AccelArgs MapToDriver() => ((IAccelDefinitionModel)Selected)?.MapToDriver() ?? new AccelArgs(); + public RaAccelArgs MapToDriver() => ((IAccelDefinitionModel)Selected)?.MapToDriver() ?? new RaAccelArgs(); protected override bool TryMapEditableSettingsFromData(Acceleration data) { @@ -77,8 +78,8 @@ protected override bool TryMapEditableSettingsCollectionsFromData(Acceleration d else result &= Anisotropy.TryMapFromData(new Anisotropy { - Domain = new Vector2(), - Range = new Vector2(), + Domain = new Vector2 { X = 1, Y = 1 }, + Range = new Vector2 { X = 1, Y = 1 }, LPNorm = 2.0, CombineXYComponents = false }); diff --git a/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs index b1f0fb70..27d65585 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/ClassicAccelerationDefinitionModel.cs @@ -1,7 +1,11 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; +using RaCapMode = RawAccel.Contracts.CapMode; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -11,20 +15,20 @@ public interface IClassicAccelerationDefinitionModel : IAccelDefinitionModelSpec } public class ClassicAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, IClassicAccelerationDefinitionModel { public const string AccelerationDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Acceleration)}"; public const string ExponentDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Exponent)}"; public const string OffsetDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Offset)}"; - public const string CapDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(CapDIKey)}"; + public const string CapDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Cap)}"; public ClassicAccelerationDefinitionModel( [FromKeyedServices(AccelerationDIKey)]IEditableSettingSpecific acceleration, [FromKeyedServices(ExponentDIKey)]IEditableSettingSpecific exponent, [FromKeyedServices(OffsetDIKey)]IEditableSettingSpecific offset, [FromKeyedServices(CapDIKey)]IEditableSettingSpecific cap) - : base([acceleration, exponent, offset, cap], []) + : base([acceleration, exponent, offset, cap]) { Acceleration = acceleration; Exponent = exponent; @@ -40,16 +44,16 @@ public ClassicAccelerationDefinitionModel( public IEditableSettingSpecific Cap { get; set; } - public AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.classic, + mode = RaAccelMode.classic, acceleration = Acceleration.ModelValue, exponentClassic = Exponent.ModelValue, inputOffset = Offset.ModelValue, - cap = new Vec2 { x = 0, y = Cap.ModelValue }, - capMode = CapMode.output + cap = new Vec2D { x = 0, y = Cap.ModelValue }, + capMode = RaCapMode.output }; } @@ -71,10 +75,5 @@ protected override bool TryMapEditableSettingsFromData(ClassicAccel data) & Offset.TryUpdateModelDirectly(data.Offset) & Cap.TryUpdateModelDirectly(data.Cap); } - - protected override bool TryMapEditableSettingsCollectionsFromData(ClassicAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs new file mode 100644 index 00000000..95ce625d --- /dev/null +++ b/userspace-backend/Model/AccelDefinitions/Formula/FormulaAccelerationDefinitionModel.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using userspace_backend.Data.Profiles.Accel; +using userspace_backend.Model.EditableSettings; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; + +namespace userspace_backend.Model.AccelDefinitions.Formula +{ + /// + /// Shared base for per-formula definition models (Classic, Jump, Linear, + /// Natural, Power, Synchronous). Each formula is a flat set of leaf double + /// settings, so collection mapping is a no-op. Subclasses supply + /// , MapToData, and per-field MapFromData. + /// + public abstract class FormulaAccelerationDefinitionModel + : EditableSettingsSelectable + where TData : FormulaAccel + { + protected FormulaAccelerationDefinitionModel(IEnumerable curveParameters) + : base(curveParameters, []) + { + } + + public abstract RaAccelArgs MapToDriver(); + + // No formula has nested collections -- params are all leaf settings. + protected sealed override bool TryMapEditableSettingsCollectionsFromData(TData data) => true; + } +} diff --git a/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs index a38c6131..9db8fa5c 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/JumpAccelerationDefinitionModel.cs @@ -1,7 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -10,18 +13,18 @@ public interface IJumpAccelerationDefinitionModel : IAccelDefinitionModelSpecifi } public class JumpAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, IJumpAccelerationDefinitionModel { - public const string SmoothDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Smooth)}"; - public const string InputDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Input)}"; - public const string OutputDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Output)}"; + public const string SmoothDIKey = $"{nameof(JumpAccelerationDefinitionModel)}.{nameof(Smooth)}"; + public const string InputDIKey = $"{nameof(JumpAccelerationDefinitionModel)}.{nameof(Input)}"; + public const string OutputDIKey = $"{nameof(JumpAccelerationDefinitionModel)}.{nameof(Output)}"; public JumpAccelerationDefinitionModel( [FromKeyedServices(SmoothDIKey)]IEditableSettingSpecific smooth, [FromKeyedServices(InputDIKey)]IEditableSettingSpecific input, [FromKeyedServices(OutputDIKey)]IEditableSettingSpecific output) - : base([smooth, input, output], []) + : base([smooth, input, output]) { Smooth = smooth; Input = input; @@ -34,13 +37,13 @@ public JumpAccelerationDefinitionModel( public IEditableSettingSpecific Output { get; set; } - public AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.jump, + mode = RaAccelMode.jump, smooth = Smooth.ModelValue, - cap = new Vec2 { x = Input.ModelValue, y = Output.ModelValue }, + cap = new Vec2D { x = Input.ModelValue, y = Output.ModelValue }, }; } @@ -60,10 +63,5 @@ protected override bool TryMapEditableSettingsFromData(JumpAccel data) & Input.TryUpdateModelDirectly(data.Input) & Output.TryUpdateModelDirectly(data.Output); } - - protected override bool TryMapEditableSettingsCollectionsFromData(JumpAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs index 086a8bba..a4764bba 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/LinearAccelerationDefinitionModel.cs @@ -1,7 +1,11 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; +using RaCapMode = RawAccel.Contracts.CapMode; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -10,18 +14,18 @@ public interface ILinearAccelerationDefinitionModel : IAccelDefinitionModelSpeci } public class LinearAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, ILinearAccelerationDefinitionModel { public const string AccelerationDIKey = $"{nameof(LinearAccelerationDefinitionModel)}.{nameof(Acceleration)}"; public const string OffsetDIKey = $"{nameof(LinearAccelerationDefinitionModel)}.{nameof(Offset)}"; - public const string CapDIKey = $"{nameof(LinearAccelerationDefinitionModel)}.{nameof(CapDIKey)}"; + public const string CapDIKey = $"{nameof(LinearAccelerationDefinitionModel)}.{nameof(Cap)}"; public LinearAccelerationDefinitionModel( [FromKeyedServices(AccelerationDIKey)]IEditableSettingSpecific acceleration, [FromKeyedServices(OffsetDIKey)]IEditableSettingSpecific offset, [FromKeyedServices(CapDIKey)]IEditableSettingSpecific cap) - : base([acceleration, offset, cap], []) + : base([acceleration, offset, cap]) { Acceleration = acceleration; Offset = offset; @@ -34,16 +38,16 @@ public LinearAccelerationDefinitionModel( public IEditableSettingSpecific Cap { get; set; } - public AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.classic, + mode = RaAccelMode.classic, acceleration = Acceleration.ModelValue, exponentClassic = 2, inputOffset = Offset.ModelValue, - cap = new Vec2 { x = 0, y = Cap.ModelValue }, - capMode = CapMode.output, + cap = new Vec2D { x = 0, y = Cap.ModelValue }, + capMode = RaCapMode.output, }; } @@ -63,10 +67,5 @@ protected override bool TryMapEditableSettingsFromData(LinearAccel data) & Offset.TryUpdateModelDirectly(data.Offset) & Cap.TryUpdateModelDirectly(data.Cap); } - - protected override bool TryMapEditableSettingsCollectionsFromData(LinearAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs index a4abde4c..4526d83b 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/NaturalAccelerationDefinitionModel.cs @@ -1,7 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -10,7 +12,7 @@ public interface INaturalAccelerationDefinitionModel : IAccelDefinitionModelSpec } public class NaturalAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, INaturalAccelerationDefinitionModel { public const string DecayRateDIKey = $"{nameof(NaturalAccelerationDefinitionModel)}.{nameof(DecayRate)}"; @@ -21,7 +23,7 @@ public NaturalAccelerationDefinitionModel( [FromKeyedServices(DecayRateDIKey)]IEditableSettingSpecific decayRate, [FromKeyedServices(InputOffsetDIKey)]IEditableSettingSpecific inputOffset, [FromKeyedServices(LimitDIKey)]IEditableSettingSpecific limit) - : base([decayRate, inputOffset, limit], []) + : base([decayRate, inputOffset, limit]) { DecayRate = decayRate; InputOffset = inputOffset; @@ -34,11 +36,11 @@ public NaturalAccelerationDefinitionModel( public IEditableSettingSpecific Limit { get; set; } - public AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.natural, + mode = RaAccelMode.natural, decayRate = DecayRate.ModelValue, inputOffset = InputOffset.ModelValue, limit = Limit.ModelValue, @@ -61,10 +63,5 @@ protected override bool TryMapEditableSettingsFromData(NaturalAccel data) & InputOffset.TryUpdateModelDirectly(data.InputOffset) & Limit.TryUpdateModelDirectly(data.Limit); } - - protected override bool TryMapEditableSettingsCollectionsFromData(NaturalAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs index 25c4df61..dbd5bf15 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/PowerAccelerationDefinitionModel.cs @@ -1,8 +1,12 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; +using RaCapMode = RawAccel.Contracts.CapMode; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -11,20 +15,20 @@ public interface IPowerAccelerationDefinitionModel : IAccelDefinitionModelSpecif } public class PowerAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, IPowerAccelerationDefinitionModel { - public const string ScaleDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Scale)}"; - public const string ExponentDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(Exponent)}"; - public const string OutputOffsetDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(OutputOffset)}"; - public const string CapDIKey = $"{nameof(ClassicAccelerationDefinitionModel)}.{nameof(CapDIKey)}"; + public const string ScaleDIKey = $"{nameof(PowerAccelerationDefinitionModel)}.{nameof(Scale)}"; + public const string ExponentDIKey = $"{nameof(PowerAccelerationDefinitionModel)}.{nameof(Exponent)}"; + public const string OutputOffsetDIKey = $"{nameof(PowerAccelerationDefinitionModel)}.{nameof(OutputOffset)}"; + public const string CapDIKey = $"{nameof(PowerAccelerationDefinitionModel)}.{nameof(Cap)}"; public PowerAccelerationDefinitionModel( [FromKeyedServices(ScaleDIKey)]IEditableSettingSpecific scale, [FromKeyedServices(ExponentDIKey)]IEditableSettingSpecific exponent, [FromKeyedServices(OutputOffsetDIKey)]IEditableSettingSpecific outputOffset, [FromKeyedServices(CapDIKey)]IEditableSettingSpecific cap) - : base([scale, exponent, outputOffset, cap], []) + : base([scale, exponent, outputOffset, cap]) { Scale = scale; Exponent = exponent; @@ -40,16 +44,16 @@ public PowerAccelerationDefinitionModel( public IEditableSettingSpecific Cap { get; set; } - public AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.power, + mode = RaAccelMode.power, scale = Scale.ModelValue, exponentPower = Exponent.ModelValue, outputOffset = OutputOffset.ModelValue, - cap = new Vec2 { x = 0, y = Cap.ModelValue }, - capMode = CapMode.output, + cap = new Vec2D { x = 0, y = Cap.ModelValue }, + capMode = RaCapMode.output, }; } @@ -71,10 +75,5 @@ protected override bool TryMapEditableSettingsFromData(PowerAccel data) & OutputOffset.TryUpdateModelDirectly(data.OutputOffset) & Cap.TryUpdateModelDirectly(data.Cap); } - - protected override bool TryMapEditableSettingsCollectionsFromData(PowerAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs index f8caf3b8..2fb2a6db 100644 --- a/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/Formula/SynchronousAccelerationDefinitionModel.cs @@ -1,7 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Data.Profiles.Accel.Formula; using userspace_backend.Model.EditableSettings; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions.Formula { @@ -17,7 +19,7 @@ public interface ISynchronousAccelerationDefinitionModel : IAccelDefinitionModel } public class SynchronousAccelerationDefinitionModel - : EditableSettingsSelectable, + : FormulaAccelerationDefinitionModel, ISynchronousAccelerationDefinitionModel { public const string SyncSpeedDIKey = $"{nameof(SynchronousAccelerationDefinitionModel)}.{nameof(SyncSpeed)}"; @@ -30,7 +32,7 @@ public SynchronousAccelerationDefinitionModel( [FromKeyedServices(MotivityDIKey)]IEditableSettingSpecific motivity, [FromKeyedServices(GammaDIKey)]IEditableSettingSpecific gamma, [FromKeyedServices(SmoothnessDIKey)]IEditableSettingSpecific smoothness) - : base([syncSpeed, motivity, gamma, smoothness], []) + : base([syncSpeed, motivity, gamma, smoothness]) { SyncSpeed = syncSpeed; Motivity = motivity; @@ -46,11 +48,11 @@ public SynchronousAccelerationDefinitionModel( public IEditableSettingSpecific Smoothness { get; set; } - public AccelArgs MapToDriver() + public override RaAccelArgs MapToDriver() { - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.synchronous, + mode = RaAccelMode.synchronous, syncSpeed = SyncSpeed.ModelValue, motivity = Motivity.ModelValue, gamma = Gamma.ModelValue, @@ -76,10 +78,5 @@ protected override bool TryMapEditableSettingsFromData(SynchronousAccel data) & Gamma.TryUpdateModelDirectly(data.Gamma) & Smoothness.TryUpdateModelDirectly(data.Smoothness); } - - protected override bool TryMapEditableSettingsCollectionsFromData(SynchronousAccel data) - { - return true; - } } } diff --git a/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs b/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs index 22477d9f..42be959b 100644 --- a/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs +++ b/userspace-backend/Model/AccelDefinitions/FormulaAccelModel.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; using System.ComponentModel; @@ -8,6 +8,7 @@ using userspace_backend.Model.AccelDefinitions.Formula; using userspace_backend.Model.EditableSettings; using static userspace_backend.Data.Profiles.Accel.FormulaAccel; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; namespace userspace_backend.Model.AccelDefinitions { @@ -40,7 +41,7 @@ public FormulaAccelModel( public IEditableSettingSpecific Gain { get; set; } - public AccelArgs MapToDriver() => ((IAccelDefinitionModel)Selected)?.MapToDriver() ?? new AccelArgs(); + public RaAccelArgs MapToDriver() => ((IAccelDefinitionModel)Selected)?.MapToDriver() ?? new RaAccelArgs(); protected override bool TryMapEditableSettingsCollectionsFromData(FormulaAccel data) { @@ -49,7 +50,8 @@ protected override bool TryMapEditableSettingsCollectionsFromData(FormulaAccel d protected override bool TryMapEditableSettingsFromData(FormulaAccel data) { - return Gain.TryUpdateModelDirectly(data.Gain); + return FormulaType.TryUpdateModelDirectly(data.FormulaType) + & Gain.TryUpdateModelDirectly(data.Gain); } } } diff --git a/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs index a414a2fe..c2f98713 100644 --- a/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/LookupTableDefinitionModel.cs @@ -1,10 +1,12 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using System; using System.Linq; using userspace_backend.Data.Profiles; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Model.EditableSettings; using static userspace_backend.Data.Profiles.Accel.LookupTableAccel; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions { @@ -34,15 +36,19 @@ public LookupTableDefinitionModel( public IEditableSettingSpecific Data { get; set; } - public AccelArgs MapToDriver() + public RaAccelArgs MapToDriver() { // data in driver profile must be predefined length for marshalling purposes - var accelArgsData = new float[AccelArgs.MaxLutPoints*2]; - Data.ModelValue.Data.Select(Convert.ToSingle).ToArray().CopyTo(accelArgsData, 0); + double[] lutData = Data.ModelValue.Data; + var accelArgsData = new float[RaAccelArgs.MaxLutPoints*2]; + for (int i = 0; i < lutData.Length; i++) + { + accelArgsData[i] = (float)lutData[i]; + } - return new AccelArgs + return new RaAccelArgs { - mode = AccelMode.lut, + mode = RaAccelMode.lut, data = accelArgsData, length = Data.ModelValue.Data.Length, }; @@ -80,20 +86,13 @@ public LookupTableData(double[]? data = null) public int CompareTo(object? obj) { - if (obj == null) - { - return -1; - } - - double[]? compareTo = obj as double[]; - - if (compareTo == null) + if (obj is not LookupTableData other) { return -1; } // We are using CompareTo as a stand-in for equality - return Data.SequenceEqual(compareTo) ? 0 : -1; + return Data.SequenceEqual(other.Data) ? 0 : -1; } } } diff --git a/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs b/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs index 5155103e..e6149f25 100644 --- a/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs +++ b/userspace-backend/Model/AccelDefinitions/NoAccelDefinitionModel.cs @@ -1,6 +1,8 @@ -using userspace_backend.Data.Profiles; +using userspace_backend.Data.Profiles; using userspace_backend.Data.Profiles.Accel; using userspace_backend.Model.EditableSettings; +using RaAccelArgs = RawAccel.Contracts.RawAccelAccelArgs; +using RaAccelMode = RawAccel.Contracts.AccelMode; namespace userspace_backend.Model.AccelDefinitions { @@ -18,11 +20,11 @@ public NoAccelDefinitionModel() public NoAcceleration NoAcceleration { get; protected set; } - public AccelArgs MapToDriver() + public RaAccelArgs MapToDriver() { - return new AccelArgs() + return new RaAccelArgs() { - mode = AccelMode.noaccel, + mode = RaAccelMode.noaccel, }; } diff --git a/userspace-backend/Model/DeviceGroups.cs b/userspace-backend/Model/DeviceGroups.cs index aa908b5c..470e5a67 100644 --- a/userspace-backend/Model/DeviceGroups.cs +++ b/userspace-backend/Model/DeviceGroups.cs @@ -105,8 +105,7 @@ protected override IEnumerable EnumerateEditableS protected override void InitEditableSettingsAndCollections(IEnumerable dataObject) { - // This initialization does not set up all device group models. - // That is done in backend construction in order to point the devices to their groups. + // Backend construction sets up the rest, pointing devices at their groups. DeviceGroupModels = new ObservableCollection() { DefaultDeviceGroup }; } } diff --git a/userspace-backend/Model/DevicesModel.cs b/userspace-backend/Model/DevicesModel.cs index f3db5cdd..22c39483 100644 --- a/userspace-backend/Model/DevicesModel.cs +++ b/userspace-backend/Model/DevicesModel.cs @@ -16,13 +16,15 @@ public class DevicesModel : EditableSettingsList, IDevices { public DevicesModel( IServiceProvider serviceProvider, - ISystemDevicesProvider systemDevicesProvider) + ISystemDevicesProvider systemDevicesProvider, + DeviceGroups deviceGroups) : base(serviceProvider, [], []) { SystemDevices = systemDevicesProvider; + DeviceGroups = deviceGroups; } - public DeviceGroups DeviceGroups { get; set; } + public DeviceGroups DeviceGroups { get; } public ISystemDevicesProvider SystemDevices { get; protected set; } diff --git a/userspace-backend/Model/EditableSettings/EditableSetting.cs b/userspace-backend/Model/EditableSettings/EditableSetting.cs index dfc4b4f1..fcc60a5d 100644 --- a/userspace-backend/Model/EditableSettings/EditableSetting.cs +++ b/userspace-backend/Model/EditableSettings/EditableSetting.cs @@ -1,11 +1,11 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using System; -using userspace_backend.Logging; namespace userspace_backend.Model.EditableSettings { - public partial class EditableSetting : ObservableObject, IEditableSettingSpecific where T : IComparable + public partial class EditableSettingV2 : ObservableObject, IEditableSettingSpecific where T : IComparable { /// /// This value can be bound in UI for direct editing @@ -21,164 +21,9 @@ public partial class EditableSetting : ObservableObject, IEditableSettingSpec public T CurrentValidatedValue => ModelValue; - public EditableSetting( - string displayName, - T initialValue, - IUserInputParser parser, - IModelValueValidator validator, - bool autoUpdateFromInterface = false, - string localizationKey = null) - { - DisplayName = displayName; - LocalizationKey = localizationKey; - LastWrittenValue = initialValue; - Parser = parser; - Validator = validator; - UpdateModelValueFromLastKnown(); - UpdateInterfaceValue(); - AutoUpdateFromInterface = autoUpdateFromInterface; - } - - /// - /// Display name for this setting in UI - /// - /// TODO: Make private and only use DisplayText for UI - public string DisplayName { get; } - - /// - /// Optional localization key for this setting. If provided, DisplayText will use localized string instead of DisplayName - /// - public string LocalizationKey { get; set; } - - /// - /// Gets the display text for this setting. Returns LocalizationKey if provided, otherwise DisplayName. - /// UI layer should handle actual localization of the key. - /// - public string DisplayText => - !string.IsNullOrEmpty(LocalizationKey) - ? LocalizationKey - : DisplayName ?? string.Empty; - - public string EditedValueForDiplay => InterfaceValue; + public const string LoggerCategoryName = "userspace_backend.EditableSetting"; - public T LastWrittenValue { get; protected set; } - - /// - /// Interface can set this for cases when new value arrives all at once (such as menu selection) - /// instead of cases where new value arrives in parts (typing) - /// - public bool AutoUpdateFromInterface { get; set; } - - private IUserInputParser Parser { get; } - - //TODO: change settings collections init so that this can be made private for non-static validators - public IModelValueValidator Validator { get; set; } - - public bool HasChanged() => ModelValue.CompareTo(LastWrittenValue) == 0; - - public bool TryUpdateFromInterface() - { - if (string.IsNullOrEmpty(InterfaceValue)) - { - UpdateInterfaceValue(); - return false; - } - - if (!Parser.TryParse(InterfaceValue.Trim(), out T parsedValue)) - { - UpdateInterfaceValue(); - return false; - } - - if (parsedValue.CompareTo(ModelValue) == 0) - { - return true; - } - - if (Validator == null) - { - throw new InvalidOperationException( - $"Validator is null for EditableSetting '{DisplayName}'. " + - $"InterfaceValue: '{InterfaceValue}', " + - $"ParsedValue: '{parsedValue}', " + - $"ModelValue: '{ModelValue}', " + - $"Parser: {Parser?.GetType().Name ?? "null"}"); - } - - if (!Validator.Validate(parsedValue)) - { - UpdateInterfaceValue(); - return false; - } - - UpdatedModeValue(parsedValue); - return true; - } - - protected void UpdateInterfaceValue() - { - InterfaceValue = ModelValue?.ToString(); - } - - protected void UpdateModelValueFromLastKnown() - { - UpdatedModeValue(LastWrittenValue); - } - - protected void UpdatedModeValue(T value) - { - ModelValue = value; - } - - partial void OnInterfaceValueChanged(string value) - { - if (AutoUpdateFromInterface) - { - TryUpdateFromInterface(); - } - } - - public bool TryUpdateModelDirectly(T data) - { - if (data.CompareTo(ModelValue) == 0) - { - return true; - } - - if (!Validator.Validate(data)) - { - UpdateInterfaceValue(); - EditableSettingLog.Logger.LogDebug( - "Setting '{Name}' ({Type}) [v1] rejected direct update: value {Value} failed validation", - DisplayName, typeof(T).Name, data); - return false; - } - - T previous = ModelValue; - UpdatedModeValue(data); - UpdateInterfaceValue(); - EditableSettingLog.Logger.LogDebug( - "Setting '{Name}' ({Type}) [v1] changed: {Old} -> {New}", - DisplayName, typeof(T).Name, previous, data); - return true; - } - } - - public partial class EditableSettingV2 : ObservableObject, IEditableSettingSpecific where T : IComparable - { - /// - /// This value can be bound in UI for direct editing - /// - [ObservableProperty] - public string interfaceValue; - - /// - /// This value can be bound in UI for logic based on validated input - /// - [ObservableProperty] - public T modelValue; - - public T CurrentValidatedValue => ModelValue; + private readonly ILogger logger; public EditableSettingV2( string displayName, @@ -186,7 +31,8 @@ public EditableSettingV2( IUserInputParser parser, IModelValueValidator validator, bool autoUpdateFromInterface = false, - string localizationKey = null) + string localizationKey = null, + ILogger? logger = null) { DisplayName = displayName; LocalizationKey = localizationKey; @@ -196,6 +42,7 @@ public EditableSettingV2( UpdateModelValueFromLastKnown(); SetInterfaceToModel(); AutoUpdateFromInterface = autoUpdateFromInterface; + this.logger = logger ?? NullLogger.Instance; } /// @@ -210,7 +57,7 @@ public EditableSettingV2( ? LocalizationKey : DisplayName ?? string.Empty; - public string EditedValueForDiplay => InterfaceValue; + public string EditedValueForDisplay => InterfaceValue; public T LastWrittenValue { get; protected set; } @@ -227,7 +74,7 @@ public EditableSettingV2( private bool AllowAutoUpdateFromInterface { get; set; } = true; - public bool HasChanged() => ModelValue.CompareTo(LastWrittenValue) == 0; + public bool HasChanged() => ModelValue.CompareTo(LastWrittenValue) != 0; public bool TryUpdateFromInterface() { @@ -247,7 +94,7 @@ protected bool TryUpdateFromInterfaceImpl(out bool editedInterfaceNeedsReset) if (string.IsNullOrEmpty(InterfaceValue)) { - EditableSettingLog.Logger.LogDebug( + logger.LogDebug( "Setting '{Name}' ({Type}) commit skipped: InterfaceValue is empty", DisplayName, typeof(T).Name); return false; @@ -255,7 +102,7 @@ protected bool TryUpdateFromInterfaceImpl(out bool editedInterfaceNeedsReset) if (!Parser.TryParse(InterfaceValue.Trim(), out T parsedValue)) { - EditableSettingLog.Logger.LogDebug( + logger.LogDebug( "Setting '{Name}' ({Type}) commit rejected: parser could not parse '{Value}'", DisplayName, typeof(T).Name, InterfaceValue); return false; @@ -309,7 +156,7 @@ private bool TryUpdateModelDirectlyImpl(T data, out bool editedInterfaceNeedsRes if (!Validator.Validate(data)) { editedInterfaceNeedsReset = true; - EditableSettingLog.Logger.LogDebug( + logger.LogDebug( "Setting '{Name}' ({Type}) rejected direct update: value {Value} failed validation", DisplayName, typeof(T).Name, data); return false; @@ -318,7 +165,7 @@ private bool TryUpdateModelDirectlyImpl(T data, out bool editedInterfaceNeedsRes T previous = ModelValue; UpdateModeValue(data); SetInterfaceToModel(); - EditableSettingLog.Logger.LogDebug( + logger.LogDebug( "Setting '{Name}' ({Type}) changed: {Old} -> {New}", DisplayName, typeof(T).Name, previous, data); return true; diff --git a/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs b/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs index 64c15d1f..6be5bd7b 100644 --- a/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs +++ b/userspace-backend/Model/EditableSettings/EditableSettingsCollection.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using System; using System.Collections.Generic; using System.ComponentModel; @@ -6,36 +6,86 @@ namespace userspace_backend.Model.EditableSettings { - public abstract class EditableSettingsCollection : ObservableObject, IEditableSettingsCollectionV2 + /// + /// Internal node of the settings tree. + /// + /// + /// Settings form a tree: the root model is the root node, collections are internal + /// nodes (this interface), and individual settings are leaves. Collections may hold + /// other collections and/or settings. + /// + public interface IEditableSettingsCollectionV2 { - public EditableSettingsCollection(T dataObject) - { - InitEditableSettingsAndCollections(dataObject); - GatherEditableSettings(); - GatherEditableSettingsCollections(); - } + public bool HasChanged { get; } + + public EventHandler AnySettingChanged { get; set; } + } + + public interface IEditableSettingsCollectionSpecific : IEditableSettingsCollectionV2 + { + T MapToData(); + + bool TryMapFromData(T data); + } + public interface INamedEditableSettingsCollectionSpecific : IEditableSettingsCollectionSpecific + { + public IEditableSettingSpecific Name { get; } + } + + /// + /// Shared base for settings-collection internal nodes. Holds change tracking + /// and event plumbing common to + /// and . Subclasses populate + /// and + /// and wire the change handlers. + /// + public abstract class EditableSettingsCollectionBase : ObservableObject, IEditableSettingsCollectionV2 + { public EventHandler AnySettingChanged { get; set; } - public IEnumerable AllContainedEditableSettings { get; set; } + public IEnumerable AllContainedEditableSettings { get; protected set; } - public IEnumerable AllContainedEditableSettingsCollections { get; set; } + public IEnumerable AllContainedEditableSettingsCollections { get; protected set; } public bool HasChanged { get; protected set; } public void EvaluateWhetherHasChanged() { - if (AllContainedEditableSettings.Any(s => s.HasChanged()) || - AllContainedEditableSettingsCollections.Any(c => c.HasChanged)) - { - HasChanged = true; - } - else + HasChanged = AllContainedEditableSettings.Any(s => s.HasChanged()) + || AllContainedEditableSettingsCollections.Any(c => c.HasChanged); + } + + protected void EditableSettingChangedEventHandler(object? sender, PropertyChangedEventArgs e) + { + if (string.Equals(e.PropertyName, nameof(IEditableSettingSpecific.ModelValue))) { - HasChanged = false; + OnAnySettingChanged(); } } + protected void EditableSettingsCollectionChangedEventHandler(object? sender, EventArgs e) + { + OnAnySettingChanged(); + } + + protected void OnAnySettingChanged() + { + AnySettingChanged?.Invoke(this, new EventArgs()); + } + + public abstract T MapToData(); + } + + public abstract class EditableSettingsCollection : EditableSettingsCollectionBase + { + public EditableSettingsCollection(T dataObject) + { + InitEditableSettingsAndCollections(dataObject); + GatherEditableSettings(); + GatherEditableSettingsCollections(); + } + public void GatherEditableSettings() { AllContainedEditableSettings = EnumerateEditableSettings(); @@ -61,74 +111,23 @@ public void GatherEditableSettingsCollections() } } - protected void EditableSettingChangedEventHandler(object? sender, PropertyChangedEventArgs e) - { - if (string.Equals(e.PropertyName, nameof(IEditableSettingSpecific.ModelValue))) - { - OnAnySettingChanged(); - } - } - - protected void EditableSettingsCollectionChangedEventHandler(object? sender, EventArgs e) - { - OnAnySettingChanged(); - } - - protected void OnAnySettingChanged() - { - AnySettingChanged?.Invoke(this, new EventArgs()); - } - protected abstract void InitEditableSettingsAndCollections(T dataObject); protected abstract IEnumerable EnumerateEditableSettings(); protected abstract IEnumerable EnumerateEditableSettingsCollections(); - - public abstract T MapToData(); - } - - /// - /// Collection of editable settings and other collections. - /// Internal node of settings tree. - /// - /// - /// The settings model for this backend is a composed object containing other composed objects and settings. - /// In this way, the objects form a tree. The root node is the base model class, the internal nodes are objects containing other objects - /// and settings, and the settings themselves are the leaf nodes. - /// This interface is then an internal node of the tree. It can contain other settings collections (internal nodes) and also contain - /// settings themselves. - /// - public interface IEditableSettingsCollectionV2 - { - public bool HasChanged { get; } - - public EventHandler AnySettingChanged { get; set; } - } - - public interface IEditableSettingsCollectionSpecific : IEditableSettingsCollectionV2 - { - T MapToData(); - - bool TryMapFromData(T data); - } - - public interface INamedEditableSettingsCollectionSpecific : IEditableSettingsCollectionSpecific - { - public IEditableSettingSpecific Name { get; } } /// /// Base class for settings collections. /// /// - /// Each unique set of collection logic should be generalized into a class that is either this class or a child of this class, - /// but not the actual class in the model. - /// This class, and any child class that is a parent to actual settings collections in the model, requires unit tests. - /// The actual settings collections in the model do not need to each be tested beyond composition. + /// Unique collection logic belongs in this class or a child of it, not in + /// concrete model collections. This class and any such parent requires unit + /// tests; concrete model collections only need composition tests. /// /// - public abstract class EditableSettingsCollectionV2 : ObservableObject, IEditableSettingsCollectionSpecific + public abstract class EditableSettingsCollectionV2 : EditableSettingsCollectionBase, IEditableSettingsCollectionSpecific { public EditableSettingsCollectionV2( IEnumerable editableSettings, @@ -154,14 +153,6 @@ public EditableSettingsCollectionV2( } } - public EventHandler AnySettingChanged { get; set; } - - public IEnumerable AllContainedEditableSettings { get; set; } - - public IEnumerable AllContainedEditableSettingsCollections { get; set; } - - public bool HasChanged { get; protected set; } - public bool TryMapFromData(T data) { bool result = true; @@ -172,39 +163,6 @@ public bool TryMapFromData(T data) return result; } - public void EvaluateWhetherHasChanged() - { - if (AllContainedEditableSettings.Any(s => s.HasChanged()) || - AllContainedEditableSettingsCollections.Any(c => c.HasChanged)) - { - HasChanged = true; - } - else - { - HasChanged = false; - } - } - - protected void EditableSettingChangedEventHandler(object? sender, PropertyChangedEventArgs e) - { - if (string.Equals(e.PropertyName, nameof(IEditableSettingSpecific.ModelValue))) - { - OnAnySettingChanged(); - } - } - - protected void EditableSettingsCollectionChangedEventHandler(object? sender, EventArgs e) - { - OnAnySettingChanged(); - } - - protected void OnAnySettingChanged() - { - AnySettingChanged?.Invoke(this, new EventArgs()); - } - - public abstract T MapToData(); - protected abstract bool TryMapEditableSettingsFromData(T data); protected abstract bool TryMapEditableSettingsCollectionsFromData(T data); diff --git a/userspace-backend/Model/EditableSettings/EditableSettingsList.cs b/userspace-backend/Model/EditableSettings/EditableSettingsList.cs index 7afdc0ef..db9a3546 100644 --- a/userspace-backend/Model/EditableSettings/EditableSettingsList.cs +++ b/userspace-backend/Model/EditableSettings/EditableSettingsList.cs @@ -68,6 +68,7 @@ public bool TryInsert(int index, T element) if (!ContainsElementWithName(name)) { + element.AnySettingChanged += EditableSettingsCollectionChangedEventHandler; ElementsInternal.Insert(index, element); return true; } @@ -111,7 +112,14 @@ public bool TryGetElement(string name, out T? element) public bool TryRemoveElement(T element) { - return ElementsInternal.Remove(element); + bool removed = ElementsInternal.Remove(element); + + if (removed) + { + element.AnySettingChanged -= EditableSettingsCollectionChangedEventHandler; + } + + return removed; } protected bool ContainsElementWithName(string name) => TryGetElement(name, out T? _); @@ -120,6 +128,7 @@ public bool TryRemoveElement(T element) protected void AddElement(T element) { + element.AnySettingChanged += EditableSettingsCollectionChangedEventHandler; ElementsInternal.Add(element); } @@ -141,20 +150,32 @@ protected override bool TryMapEditableSettingsCollectionsFromData(IEnumerable { bool result = true; + var incomingNames = new HashSet(StringComparer.InvariantCultureIgnoreCase); + foreach (U dataElement in data) { string elementName = GetNameFromData(dataElement); + incomingNames.Add(elementName); if (!TryGetElement(elementName, out T? element)) { element = GenerateDefaultElement(elementName); - + AddElement(element); } result &= element!.TryMapFromData(dataElement); } + // The incoming data is the source of truth: drop elements it no longer contains. + // OS-detected devices are merged separately (ImportSystemDevices), not through here. + foreach (T stale in ElementsInternal + .Where(e => !incomingNames.Contains(GetNameFromElement(e))) + .ToList()) + { + TryRemoveElement(stale); + } + return result; } } diff --git a/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs b/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs index 07ef4d30..e7dba657 100644 --- a/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs +++ b/userspace-backend/Model/EditableSettings/EditableSettingsSelector.cs @@ -46,7 +46,8 @@ protected EditableSettingsSelectable( public bool TryMapFromData(U data) { T dataCasted = data as T; - return dataCasted == null ? false : TryMapFromData(dataCasted); + // base. avoids re-binding to the U overload (T converts to U). + return dataCasted == null ? false : base.TryMapFromData(dataCasted); } U IEditableSettingsCollectionSpecific.MapToData() @@ -116,7 +117,8 @@ protected EditableSettingsSelectableSelector( public bool TryMapFromData(V data) { U dataCasted = data as U; - return dataCasted == null ? false : TryMapFromData(dataCasted); + // base. avoids re-binding to the V overload (U converts to V). + return dataCasted == null ? false : base.TryMapFromData(dataCasted); } V IEditableSettingsCollectionSpecific.MapToData() diff --git a/userspace-backend/Model/EditableSettings/IEditableSetting.cs b/userspace-backend/Model/EditableSettings/IEditableSetting.cs index 562f79ab..73771680 100644 --- a/userspace-backend/Model/EditableSettings/IEditableSetting.cs +++ b/userspace-backend/Model/EditableSettings/IEditableSetting.cs @@ -11,7 +11,7 @@ public interface IEditableSetting : INotifyPropertyChanged string DisplayText { get; } - string EditedValueForDiplay { get; } + string EditedValueForDisplay { get; } string InterfaceValue { get; set; } diff --git a/userspace-backend/Model/EditableSettings/ModelValueValidator.cs b/userspace-backend/Model/EditableSettings/ModelValueValidator.cs index 22ffcb0e..242669d4 100644 --- a/userspace-backend/Model/EditableSettings/ModelValueValidator.cs +++ b/userspace-backend/Model/EditableSettings/ModelValueValidator.cs @@ -1,10 +1,56 @@ -namespace userspace_backend.Model.EditableSettings +using System; + +namespace userspace_backend.Model.EditableSettings { public interface IModelValueValidator { bool Validate(T value); } + /// + /// Rejects values outside [min, max]. Either bound may be omitted (open) and + /// either may be inclusive or exclusive. Rejection returns false; caller keeps + /// the last good value. + /// + public class RangeValidator : IModelValueValidator where T : struct, IComparable + { + private readonly T? min; + private readonly T? max; + private readonly bool minInclusive; + private readonly bool maxInclusive; + + public RangeValidator(T? min = null, T? max = null, bool minInclusive = true, bool maxInclusive = true) + { + this.min = min; + this.max = max; + this.minInclusive = minInclusive; + this.maxInclusive = maxInclusive; + } + + public bool Validate(T value) + { + if (min.HasValue) + { + int cmp = value.CompareTo(min.Value); + if (minInclusive ? cmp < 0 : cmp <= 0) + { + return false; + } + } + + if (max.HasValue) + { + int cmp = value.CompareTo(max.Value); + if (maxInclusive ? cmp > 0 : cmp >= 0) + { + return false; + } + } + + return true; + } + } + public class DefaultModelValueValidator : IModelValueValidator { public const string AllChangeInvalidDIKey = nameof(AllChangeInvalidDIKey); diff --git a/userspace-backend/Model/EditableSettings/UserInputParser.cs b/userspace-backend/Model/EditableSettings/UserInputParser.cs index a3f6aa11..2d33181b 100644 --- a/userspace-backend/Model/EditableSettings/UserInputParser.cs +++ b/userspace-backend/Model/EditableSettings/UserInputParser.cs @@ -42,7 +42,9 @@ public class DoubleParser : IUserInputParser { public bool TryParse(string input, out double parsedValue) { - if (double.TryParse(input, out parsedValue)) + if (double.TryParse(input, out parsedValue) && + !double.IsNaN(parsedValue) && + !double.IsInfinity(parsedValue)) { return true; } @@ -66,31 +68,12 @@ public bool TryParse(string input, out bool parsedValue) } } - public class AccelerationDefinitionTypeParser : IUserInputParser + public class EnumParser : IUserInputParser where T : struct, Enum { - public bool TryParse(string input, out Acceleration.AccelerationDefinitionType parsedValue) + public bool TryParse(string input, out T parsedValue) { - if (Enum.TryParse(input, ignoreCase: true, out parsedValue)) - { - return true; - } - - parsedValue = default; - return false; - } - } - - public class LookupTableTypeParser : IUserInputParser - { - public bool TryParse(string input, out LookupTableAccel.LookupTableType parsedValue) - { - if (Enum.TryParse(input, ignoreCase: true, out parsedValue)) - { - return true; - } - - parsedValue = default; - return false; + // Enum.TryParse sets parsedValue to default on failure. + return Enum.TryParse(input, ignoreCase: true, out parsedValue); } } @@ -131,18 +114,4 @@ IEnumerable ToDoubles(string[] splitInput) return false; } } - - public class AccelerationFormulaTypeParser : IUserInputParser - { - public bool TryParse(string input, out FormulaAccel.AccelerationFormulaType parsedValue) - { - if (Enum.TryParse(input, ignoreCase: true, out parsedValue)) - { - return true; - } - - parsedValue = default; - return false; - } - } } diff --git a/userspace-backend/Model/MappingModel.cs b/userspace-backend/Model/MappingModel.cs index f8a85ebb..3f5ba819 100644 --- a/userspace-backend/Model/MappingModel.cs +++ b/userspace-backend/Model/MappingModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; using userspace_backend.Model.EditableSettings; using userspace_backend.Data; using System.Collections.Specialized; @@ -42,6 +43,9 @@ public MappingModel( { dg.DeviceGroupModels.CollectionChanged += OnIndividualMappingsChanged; } + + // When a referenced profile is deleted, reassign affected entries to the default profile. + ((INotifyCollectionChanged)Profiles.Profiles).CollectionChanged += OnProfilesChanged; } private bool setActive; @@ -132,6 +136,43 @@ protected void OnIndividualMappingsChanged(object? sender, NotifyCollectionChang FindDeviceGroupsStillUnmapped(); } + protected void OnProfilesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems == null) + { + return; + } + + foreach (IProfileModel removed in e.OldItems.OfType()) + { + ReassignGroupsFromRemovedProfile(removed); + } + } + + private void ReassignGroupsFromRemovedProfile(IProfileModel removedProfile) + { + IProfileModel? fallback = Profiles.DefaultProfile; + + // If the default profile itself was the one removed, fall back to any remaining profile. + if (fallback == null || ReferenceEquals(fallback, removedProfile)) + { + fallback = Profiles.Profiles.FirstOrDefault(p => !ReferenceEquals(p, removedProfile)); + } + + if (fallback == null) + { + return; + } + + foreach (MappingGroup group in IndividualMappings) + { + if (ReferenceEquals(group.Profile, removedProfile)) + { + group.Profile = fallback; + } + } + } + protected override bool TryMapEditableSettingsFromData(Mapping data) { return Name.TryUpdateModelDirectly(data.Name); @@ -143,11 +184,17 @@ protected override bool TryMapEditableSettingsCollectionsFromData(Mapping data) } } - public class MappingGroup + public class MappingGroup : ObservableObject { public string DeviceGroup { get; set; } - public IProfileModel Profile { get; set; } + private IProfileModel profile; + + public IProfileModel Profile + { + get => profile; + set => SetProperty(ref profile, value); + } // This is here for easy binding public IProfilesModel Profiles { get; set; } diff --git a/userspace-backend/Model/MappingsModel.cs b/userspace-backend/Model/MappingsModel.cs index 75a79825..0cc56e75 100644 --- a/userspace-backend/Model/MappingsModel.cs +++ b/userspace-backend/Model/MappingsModel.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -184,7 +185,9 @@ public bool TryAddMapping(DATA.Mapping? mappingToAdd = null) displayName: "Name", initialValue: mappingToAdd.Name, parser: ServiceProvider.GetRequiredService>(), - validator: NameValidator); + validator: NameValidator, + logger: ServiceProvider.GetService() + ?.CreateLogger(EditableSettingV2.LoggerCategoryName)); // Construct MappingModel with DI pattern MappingModel mapping = new MappingModel(nameSetting, NameValidator, DeviceGroups, Profiles, mappingToAdd); diff --git a/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs b/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs index 9476adad..07230c0e 100644 --- a/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs +++ b/userspace-backend/Model/ProfileComponents/AnisotropyModel.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using userspace_backend.Data.Profiles; using userspace_backend.Model.EditableSettings; +using Vec2D = RawAccel.Contracts.Vec2; namespace userspace_backend.Model.ProfileComponents { @@ -17,6 +18,10 @@ public interface IAnisotropyModel : IEditableSettingsCollectionSpecific LPNorm { get; } IEditableSettingSpecific CombineXYComponents { get; } + + Vec2D MapDomainToDriver(); + + Vec2D MapRangeToDriver(); } public class AnisotropyModel : EditableSettingsCollectionV2, IAnisotropyModel @@ -57,6 +62,10 @@ public AnisotropyModel( public IEditableSettingSpecific CombineXYComponents { get; set; } + public Vec2D MapDomainToDriver() => new Vec2D { x = DomainX.ModelValue, y = DomainY.ModelValue }; + + public Vec2D MapRangeToDriver() => new Vec2D { x = RangeX.ModelValue, y = RangeY.ModelValue }; + public override Anisotropy MapToData() { return new Anisotropy() @@ -64,6 +73,7 @@ public override Anisotropy MapToData() Domain = new Vector2() { X = DomainX.ModelValue, Y = DomainY.ModelValue }, Range = new Vector2() { X = RangeX.ModelValue, Y = RangeY.ModelValue }, LPNorm = LPNorm.ModelValue, + CombineXYComponents = CombineXYComponents.ModelValue, }; } @@ -77,10 +87,20 @@ protected override bool TryMapEditableSettingsFromData(Anisotropy data) { if (data == null) return false; - return DomainX.TryUpdateModelDirectly(data.Domain.X) - & DomainY.TryUpdateModelDirectly(data.Domain.Y) - & RangeX.TryUpdateModelDirectly(data.Range.X) - & RangeY.TryUpdateModelDirectly(data.Range.Y) + // Identity ((1,1),(1,1)) is the only meaningful default; (0,0) was + // written by an older buggy fallback and produces a flat curve. + // Substitute identity for that shape on load. + Vector2 domain = (data.Domain == null || (data.Domain.X == 0 && data.Domain.Y == 0)) + ? new Vector2 { X = 1, Y = 1 } + : data.Domain; + Vector2 range = (data.Range == null || (data.Range.X == 0 && data.Range.Y == 0)) + ? new Vector2 { X = 1, Y = 1 } + : data.Range; + + return DomainX.TryUpdateModelDirectly(domain.X) + & DomainY.TryUpdateModelDirectly(domain.Y) + & RangeX.TryUpdateModelDirectly(range.X) + & RangeY.TryUpdateModelDirectly(range.Y) & LPNorm.TryUpdateModelDirectly(data.LPNorm) & CombineXYComponents.TryUpdateModelDirectly(data.CombineXYComponents); } diff --git a/userspace-backend/Model/ProfileModel.cs b/userspace-backend/Model/ProfileModel.cs index 329ad060..c80b3c3f 100644 --- a/userspace-backend/Model/ProfileModel.cs +++ b/userspace-backend/Model/ProfileModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; @@ -6,12 +6,13 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using userspace_backend.Common; using userspace_backend.Display; using userspace_backend.Model.AccelDefinitions; using userspace_backend.Model.EditableSettings; using userspace_backend.Model.ProfileComponents; using DATA = userspace_backend.Data; +using RaProfile = RawAccel.Contracts.RawAccelProfile; +using RaSpeedArgs = RawAccel.Contracts.RawAccelSpeedArgs; namespace userspace_backend.Model { @@ -36,7 +37,7 @@ public interface IProfileModel : IEditableSettingsCollectionSpecific, IProfileModel @@ -88,7 +89,7 @@ public ProfileModel( public IHiddenModel Hidden { get; set; } - public Profile CurrentValidatedDriverProfile { get; protected set; } + public RaProfile CurrentValidatedDriverProfile { get; protected set; } public ICurvePreview XCurvePreview { get; protected set; } @@ -97,8 +98,6 @@ public ProfileModel( [Obsolete("Use XCurvePreview instead")] public ICurvePreview CurvePreview => XCurvePreview; - protected IModelValueValidator NameValidator { get; } - public override DATA.Profile MapToData() { return new DATA.Profile() @@ -111,6 +110,43 @@ public override DATA.Profile MapToData() }; } + public RaProfile MapToDriver() + { + return new RaProfile() + { + name = Name.ModelValue, + outputDPI = OutputDPI.ModelValue, + yxOutputDPIRatio = YXRatio.ModelValue, + + // Both axes use the same UI curve, but argsX and argsY MUST be distinct + // instances -- the native wrapper mutates each axis's data array in place. + // Don't collapse to a shared variable. Pinned by + // BackEndApplyTests.Apply_SingleCurve_PopulatesBothAxes. + argsX = Acceleration.MapToDriver(), + argsY = Acceleration.MapToDriver(), + + domainXY = Acceleration.Anisotropy.MapDomainToDriver(), + rangeXY = Acceleration.Anisotropy.MapRangeToDriver(), + rotation = Hidden.RotationDegrees.ModelValue, + lrOutputDPIRatio = Hidden.LeftRightRatio.ModelValue, + udOutputDPIRatio = Hidden.UpDownRatio.ModelValue, + snap = Hidden.AngleSnappingDegrees.ModelValue, + maximumSpeed = Hidden.SpeedCap.ModelValue, + + // Driver supports a speed floor (common/rawaccel-base.hpp); UI doesn't + // expose one, keep pinned at 0. + minimumSpeed = 0, + inputSpeedArgs = new RaSpeedArgs + { + combineMagnitudes = Acceleration.Anisotropy.CombineXYComponents.ModelValue, + lpNorm = Acceleration.Anisotropy.LPNorm.ModelValue, + outputSmoothHalflife = Hidden.OutputSmoothingHalfLife.ModelValue, + inputSmoothHalflife = Acceleration.Coalescion.InputSmoothingHalfLife.ModelValue, + scaleSmoothHalflife = Acceleration.Coalescion.ScaleSmoothingHalfLife.ModelValue, + } + }; + } + protected void AnyNonPreviewPropertyChangedEventHandler(object? send, PropertyChangedEventArgs e) { if (string.Equals(e.PropertyName, nameof(IEditableSettingSpecific.ModelValue))) @@ -132,13 +168,13 @@ protected void AnyCurvePreviewPropertyChangedEventHandler(object? send, Property protected void AnyCurveSettingCollectionChangedEventHandler(object? sender, EventArgs e) { logger.LogDebug("Curve-setting collection changed: {Sender}", sender?.GetType().Name); - // All settings collections currently require curve preview to be re-generated + // All settings collections currently force a preview regen. RecalculateDriverDataAndCurvePreview(); } protected void RecalculateDriverData() { - CurrentValidatedDriverProfile = DriverHelpers.MapProfileModelToDriver(this); + CurrentValidatedDriverProfile = MapToDriver(); logger.LogDebug( "RecalculateDriverData for profile {Name}: outputDPI={OutputDPI} argsX.mode={Mode} argsX.accel={Accel}", Name?.ModelValue ?? "", diff --git a/userspace-backend/Model/ProfilesModel.cs b/userspace-backend/Model/ProfilesModel.cs index cde732cd..1dc5cf3e 100644 --- a/userspace-backend/Model/ProfilesModel.cs +++ b/userspace-backend/Model/ProfilesModel.cs @@ -6,23 +6,6 @@ using userspace_backend.Model.EditableSettings; using DATA = userspace_backend.Data; -/** - * TODO: Fix circular dependency and initialization order issues with ProfileNameValidator - * - * - ProfilesModel needs ProfileNameValidator to create ProfileModel instances - * - ProfileNameValidator needs ProfilesModel to check for duplicate names - * - * - Base constructor calls InitEditableSettingsAndCollections() BEFORE derived constructor can set NameValidator property, causing validator to be null - * - * - SOLUTION (Implement after DI PR from _m00se): - * - * Create IProfileNameChecker interface for duplicate name validation - * Have ProfilesModel implement IProfileNameChecker - * Inject IProfileNameChecker into ProfileNameValidator constructor - * Register ProfileNameValidator in DI container - * Inject ProfileNameValidator into ProfilesModel constructor - */ - namespace userspace_backend.Model { public interface IProfilesModel : IEditableSettingsList @@ -40,7 +23,7 @@ public interface IProfilesModel : IEditableSettingsList, IProfilesModel { - // Default profile is created during BackEnd.Load() if it doesn't exist + // Default profile is created by BackEnd.Load() if absent. public ProfilesModel(IServiceProvider serviceProvider) : base(serviceProvider, [], []) @@ -49,7 +32,8 @@ public ProfilesModel(IServiceProvider serviceProvider) public ReadOnlyObservableCollection Profiles => Elements; - public IProfileModel? DefaultProfile => Elements.FirstOrDefault(p => p.Name.ModelValue == "default"); + public IProfileModel? DefaultProfile => + Elements.FirstOrDefault(p => string.Equals(p.Name.ModelValue, "default", StringComparison.InvariantCultureIgnoreCase)); public bool TryGetProfile(string name, out IProfileModel? profile) => TryGetElement(name, out profile); @@ -89,4 +73,16 @@ protected override string GetNameFromData(DATA.Profile data) return data.Name; } } + + public class ProfileNameValidator(IProfilesModel profiles) : IModelValueValidator + { + protected IProfilesModel Profiles { get; } = profiles; + + public bool Validate(string value) + { + return !string.IsNullOrEmpty(value) + && value.Length <= MaxNameLengthValidator.MaxNameLength + && !Profiles.TryGetProfile(value, out _); + } + } } diff --git a/userspace-backend/Model/SystemDevices.cs b/userspace-backend/Model/SystemDevices.cs index 28fc8fd3..7f36ee82 100644 --- a/userspace-backend/Model/SystemDevices.cs +++ b/userspace-backend/Model/SystemDevices.cs @@ -50,7 +50,8 @@ public void RefreshSystemDevices() } /// - /// Retrieves list of devices from operating system + /// Per-OS device enumeration. Windows: RawInput via wrapper.dll. Linux: + /// stub (TODO: query the agent or read /dev/input). /// public interface ISystemDevicesRetriever { @@ -58,20 +59,7 @@ public interface ISystemDevicesRetriever } /// - /// Application implementation of - /// - public sealed class SystemDevicesRetriever : ISystemDevicesRetriever - { - public IList GetSystemDevices() - { - IList rawDevices = MultiHandleDevice.GetList(); - return rawDevices.Select(d => new SystemDevice(d) as ISystemDevice).ToList(); - } - } - - /// - /// Interface to represent devices as they come from windows. - /// The actual class from windows is non-trivial to construct and test. + /// OS-supplied device. Backing impls live next to their per-platform retrievers. /// public interface ISystemDevice { @@ -79,21 +67,4 @@ public interface ISystemDevice public string HWID { get; } } - - /// - /// Data class to wrap - /// - public class SystemDevice : ISystemDevice - { - public SystemDevice(MultiHandleDevice multiHandleDevice) - { - RawDevice = multiHandleDevice; - } - - public string Name { get => RawDevice.name; } - - public string HWID { get => RawDevice.id; } - - private MultiHandleDevice RawDevice { get; } - } } diff --git a/userspace-backend/userspace-backend.csproj b/userspace-backend/userspace-backend.csproj index 87f76344..4c350e18 100644 --- a/userspace-backend/userspace-backend.csproj +++ b/userspace-backend/userspace-backend.csproj @@ -18,7 +18,19 @@ + + + + + + + + +