diff --git a/src/SwitchifyPc.App/App.xaml.cs b/src/SwitchifyPc.App/App.xaml.cs index a965825..ac239e6 100644 --- a/src/SwitchifyPc.App/App.xaml.cs +++ b/src/SwitchifyPc.App/App.xaml.cs @@ -282,11 +282,13 @@ private async Task StartBluetoothAsync() modifierOverlay = new WindowsModifierKeyOverlayNotifier(nativeInput); commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay, modifierOverlay: modifierOverlay); mouseRepeatController = new MouseRepeatController(commandExecutor, mouseRepeatSettingsStore); + PointerSpeedController pointerSpeedController = new(pointerSettingsStore, inputAdapter.SetPointerMovementSettings); ControlSession controlSession = new( new CommandAuthValidator(pairingStore), commandExecutor, new WindowsPointerProfileProvider(nativeInput, pointerSettingsStore, mouseRepeatSettingsStore), - mouseRepeatController); + mouseRepeatController, + pointerSpeedController); pairingApprovalManager ??= new PairingApprovalManager(pairingStore); RemoteControlSession remoteSession = new( diff --git a/src/SwitchifyPc.App/SettingsWindow.xaml b/src/SwitchifyPc.App/SettingsWindow.xaml index 1599a7b..3b5fd4f 100644 --- a/src/SwitchifyPc.App/SettingsWindow.xaml +++ b/src/SwitchifyPc.App/SettingsWindow.xaml @@ -370,7 +370,7 @@ @@ -381,9 +381,9 @@ - + - + + SaveIfReady(() => controller!.SetPointerScalePercent(5)); + private void PointerScale25_Checked(object sender, RoutedEventArgs e) => SaveIfReady(() => controller!.SetPointerScalePercent(25)); private void PointerScale50_Checked(object sender, RoutedEventArgs e) => SaveIfReady(() => controller!.SetPointerScalePercent(50)); diff --git a/src/SwitchifyPc.Core/Control/ControlSession.cs b/src/SwitchifyPc.Core/Control/ControlSession.cs index a3f8c9f..c4772d9 100644 --- a/src/SwitchifyPc.Core/Control/ControlSession.cs +++ b/src/SwitchifyPc.Core/Control/ControlSession.cs @@ -44,17 +44,20 @@ public sealed class ControlSession private readonly DesktopCommandExecutor commandExecutor; private readonly IPointerProfileProvider pointerProfileProvider; private readonly MouseRepeatController? mouseRepeatController; + private readonly PointerSpeedController? pointerSpeedController; public ControlSession( CommandAuthValidator authValidator, DesktopCommandExecutor commandExecutor, IPointerProfileProvider pointerProfileProvider, - MouseRepeatController? mouseRepeatController = null) + MouseRepeatController? mouseRepeatController = null, + PointerSpeedController? pointerSpeedController = null) { this.authValidator = authValidator; this.commandExecutor = commandExecutor; this.pointerProfileProvider = pointerProfileProvider; this.mouseRepeatController = mouseRepeatController; + this.pointerSpeedController = pointerSpeedController; } public async Task ProcessMessageAsync(string rawMessage, CancellationToken cancellationToken = default) @@ -124,9 +127,21 @@ public async Task ProcessMessageAsync(string rawMessage, C await StopRepeatAsync(auth.DeviceId ?? "").ConfigureAwait(false); result = CommandExecutionResult.Success; } + else if (type == "pointer.speed.set") + { + if (pointerSpeedController is null) + { + result = CommandExecutionResult.Failure("unsupported_command", "Pointer speed settings are not available."); + } + else + { + pointerSpeedController.SetScalePercent(request.GetProperty("payload").GetProperty("scalePercent").GetDouble()); + result = CommandExecutionResult.Success; + } + } else { - if (type is not ("connection.ping" or "pointer.profile")) + if (type is not ("connection.ping" or "pointer.profile" or "pointer.speed.set")) { await StopRepeatAsync(auth.DeviceId ?? "").ConfigureAwait(false); } @@ -273,6 +288,17 @@ private static JsonObject PointerProfileResponse(string id, PointerMovementProfi ["scrollIntervalMs"] = profile.Capabilities.MouseRepeat.ScrollIntervalMs, ["minIntervalMs"] = profile.Capabilities.MouseRepeat.MinIntervalMs, ["maxIntervalMs"] = profile.Capabilities.MouseRepeat.MaxIntervalMs + }, + ["pointerSpeed"] = new JsonObject + { + ["supported"] = profile.Capabilities.PointerSpeed.Supported, + ["setSupported"] = profile.Capabilities.PointerSpeed.SetSupported, + ["scalePercent"] = profile.Capabilities.PointerSpeed.ScalePercent, + ["minScalePercent"] = profile.Capabilities.PointerSpeed.MinScalePercent, + ["maxScalePercent"] = profile.Capabilities.PointerSpeed.MaxScalePercent, + ["stepPercent"] = profile.Capabilities.PointerSpeed.StepPercent, + ["baseMoveDelta"] = profile.Capabilities.PointerSpeed.BaseMoveDelta, + ["effectiveMoveDelta"] = profile.Capabilities.PointerSpeed.EffectiveMoveDelta } } }, diff --git a/src/SwitchifyPc.Core/Input/PointerProfile.cs b/src/SwitchifyPc.Core/Input/PointerProfile.cs index 38df13c..413687e 100644 --- a/src/SwitchifyPc.Core/Input/PointerProfile.cs +++ b/src/SwitchifyPc.Core/Input/PointerProfile.cs @@ -16,7 +16,21 @@ public sealed record MouseRepeatCapabilities( int ScrollIntervalMs, int MinIntervalMs, int MaxIntervalMs); -public sealed record PointerCapabilities(bool NoAckMouseMove, IReadOnlyList NoAckCommands, IReadOnlyList SupportedCommands, MouseRepeatCapabilities MouseRepeat); +public sealed record PointerSpeedCapabilities( + bool Supported, + bool SetSupported, + double ScalePercent, + double MinScalePercent, + double MaxScalePercent, + double StepPercent, + int BaseMoveDelta, + int EffectiveMoveDelta); +public sealed record PointerCapabilities( + bool NoAckMouseMove, + IReadOnlyList NoAckCommands, + IReadOnlyList SupportedCommands, + MouseRepeatCapabilities MouseRepeat, + PointerSpeedCapabilities PointerSpeed); public sealed record PointerMovementProfile( string DisplayId, double ScaleFactor, @@ -70,7 +84,8 @@ public static PointerMovementProfile Create(PointerProfileInput input) MoveIntervalMs: MouseRepeatSettingsModel.Default.MoveIntervalMs, ScrollIntervalMs: MouseRepeatSettingsModel.Default.ScrollIntervalMs, MinIntervalMs: MouseRepeatSettingsModel.MinIntervalMs, - MaxIntervalMs: MouseRepeatSettingsModel.MaxIntervalMs))); + MaxIntervalMs: MouseRepeatSettingsModel.MaxIntervalMs), + PointerSpeed: PointerSpeedFor(PointerMovementSettingsModel.Default))); } private static Bounds NormalizeBounds(Bounds bounds) @@ -97,6 +112,23 @@ private static int ToLogicalDelta(double nativePixels, double scaleFactor, int m return Clamp((int)Math.Round(nativePixels / scaleFactor, MidpointRounding.AwayFromZero), 1, maxDelta); } + public static PointerSpeedCapabilities PointerSpeedFor(PointerMovementSettings settings) + { + double scalePercent = PointerMovementSettingsModel.ScalePercentFor(settings); + return new PointerSpeedCapabilities( + Supported: true, + SetSupported: true, + ScalePercent: scalePercent, + MinScalePercent: PointerMovementSettingsModel.PointerMovementScaleMin, + MaxScalePercent: PointerMovementSettingsModel.PointerMovementScaleMax, + StepPercent: PointerMovementSettingsModel.PointerMovementScaleStep, + BaseMoveDelta: PointerMovementSettingsModel.BaseMoveDelta, + EffectiveMoveDelta: Clamp( + (int)Math.Round(PointerMovementSettingsModel.BaseMoveDelta * (scalePercent / 100), MidpointRounding.AwayFromZero), + 1, + ProtocolConstants.MaxPointerDelta)); + } + private static int Clamp(int value, int min, int max) { return Math.Min(max, Math.Max(min, value)); diff --git a/src/SwitchifyPc.Core/Input/PointerSpeedController.cs b/src/SwitchifyPc.Core/Input/PointerSpeedController.cs new file mode 100644 index 0000000..d001897 --- /dev/null +++ b/src/SwitchifyPc.Core/Input/PointerSpeedController.cs @@ -0,0 +1,29 @@ +using SwitchifyPc.Core.Settings; + +namespace SwitchifyPc.Core.Input; + +public sealed class PointerSpeedController +{ + private readonly IPointerMovementSettingsStore settingsStore; + private readonly Action applyLiveSettings; + + public PointerSpeedController( + IPointerMovementSettingsStore settingsStore, + Action applyLiveSettings) + { + this.settingsStore = settingsStore; + this.applyLiveSettings = applyLiveSettings; + } + + public PointerMovementSettings Current() + { + return settingsStore.Load(); + } + + public PointerMovementSettings SetScalePercent(double scalePercent) + { + PointerMovementSettings saved = settingsStore.Save(new PointerMovementSettings(scalePercent)); + applyLiveSettings(saved); + return saved; + } +} diff --git a/src/SwitchifyPc.Core/Settings/PointerMovementSettings.cs b/src/SwitchifyPc.Core/Settings/PointerMovementSettings.cs index 532aeb9..c36e479 100644 --- a/src/SwitchifyPc.Core/Settings/PointerMovementSettings.cs +++ b/src/SwitchifyPc.Core/Settings/PointerMovementSettings.cs @@ -15,9 +15,10 @@ public sealed record PointerMovementSettings(double ScalePercent); public static class PointerMovementSettingsModel { - public const double PointerMovementScaleMin = 25; - public const double PointerMovementScaleMax = 200; + public const double PointerMovementScaleMin = 5; + public const double PointerMovementScaleMax = 225; public const double PointerMovementScaleStep = 5; + public const int BaseMoveDelta = 128; private const double DisplayPercentageMin = 1; private const double DisplayPercentageMax = 50; diff --git a/src/SwitchifyPc.Core/Ui/SettingsViewModel.cs b/src/SwitchifyPc.Core/Ui/SettingsViewModel.cs index 227a493..4211526 100644 --- a/src/SwitchifyPc.Core/Ui/SettingsViewModel.cs +++ b/src/SwitchifyPc.Core/Ui/SettingsViewModel.cs @@ -81,6 +81,8 @@ public string StartWithSystemMessage public double PointerScalePercent => PointerMovementSettingsModel.ScalePercentFor(pointerMovementSettings); + public bool IsPointerScale5 => Math.Abs(PointerScalePercent - 5) < 0.1; + public bool IsPointerScale25 => Math.Abs(PointerScalePercent - 25) < 0.1; public bool IsPointerScale50 => Math.Abs(PointerScalePercent - 50) < 0.1; @@ -202,6 +204,7 @@ public void SetPointerMovementSettings(PointerMovementSettings settings) pointerMovementSettings = PointerMovementSettingsModel.Normalize(settings); OnPropertyChanged(nameof(PointerMovementSettings)); OnPropertyChanged(nameof(PointerScalePercent)); + OnPropertyChanged(nameof(IsPointerScale5)); OnPropertyChanged(nameof(IsPointerScale25)); OnPropertyChanged(nameof(IsPointerScale50)); OnPropertyChanged(nameof(IsPointerScale75)); diff --git a/src/SwitchifyPc.Protocol/ProtocolConstants.cs b/src/SwitchifyPc.Protocol/ProtocolConstants.cs index 59ddb2a..f459164 100644 --- a/src/SwitchifyPc.Protocol/ProtocolConstants.cs +++ b/src/SwitchifyPc.Protocol/ProtocolConstants.cs @@ -36,6 +36,7 @@ public static class ProtocolConstants "media.control", "window.control", "pointer.profile", + "pointer.speed.set", "connection.ping", "connection.disconnecting" }; diff --git a/src/SwitchifyPc.Protocol/ProtocolValidator.cs b/src/SwitchifyPc.Protocol/ProtocolValidator.cs index 09918cb..8746d45 100644 --- a/src/SwitchifyPc.Protocol/ProtocolValidator.cs +++ b/src/SwitchifyPc.Protocol/ProtocolValidator.cs @@ -144,6 +144,10 @@ private static ProtocolValidationResult ValidateCommandPayload(string type, Json : Invalid("invalid_payload", "Mouse button is invalid."), "mouse.rightClick" or "connection.ping" or "connection.disconnecting" or "pointer.profile" => ObjectPropertyCount(payload) == 0 ? Valid(payload) : Invalid("invalid_payload", "Payload must be empty."), + "pointer.speed.set" => + TryGetPositiveFiniteNumber(payload, "scalePercent", out _) && ObjectPropertyCount(payload) == 1 + ? Valid(payload) + : Invalid("invalid_payload", "Pointer speed is invalid."), "keyboard.key" => TryGetString(payload, "key", out string? key) && ProtocolConstants.KeyboardKeys.Contains(key) ? Valid(payload) @@ -352,6 +356,11 @@ private static ProtocolValidationResult ValidatePointerProfilePayload(JsonElemen { return Invalid("invalid_payload", "Mouse repeat capability is invalid."); } + + if (capabilities.TryGetProperty("pointerSpeed", out JsonElement pointerSpeed) && !ValidatePointerSpeedCapability(pointerSpeed)) + { + return Invalid("invalid_payload", "Pointer speed capability is invalid."); + } } return Valid(payload); @@ -406,6 +415,29 @@ private static bool ValidateOptionalInterval(JsonElement value, string propertyN intervalMs <= maxIntervalMs; } + private static bool ValidatePointerSpeedCapability(JsonElement pointerSpeed) + { + if (!IsObject(pointerSpeed) || + !TryGetBoolean(pointerSpeed, "supported", out _) || + !TryGetBoolean(pointerSpeed, "setSupported", out _) || + !TryGetPositiveFiniteNumber(pointerSpeed, "scalePercent", out double scalePercent) || + !TryGetPositiveFiniteNumber(pointerSpeed, "minScalePercent", out double minScalePercent) || + !TryGetPositiveFiniteNumber(pointerSpeed, "maxScalePercent", out double maxScalePercent) || + !TryGetPositiveFiniteNumber(pointerSpeed, "stepPercent", out _) || + !TryGetInteger(pointerSpeed, "baseMoveDelta", out int baseMoveDelta) || + baseMoveDelta <= 0 || + !TryGetInteger(pointerSpeed, "effectiveMoveDelta", out int effectiveMoveDelta) || + effectiveMoveDelta <= 0) + { + return false; + } + + return minScalePercent <= scalePercent && + scalePercent <= maxScalePercent && + baseMoveDelta <= ProtocolConstants.MaxPointerDelta && + effectiveMoveDelta <= ProtocolConstants.MaxPointerDelta; + } + private static bool ValidateCommandArrayCapability(JsonElement capabilities, string propertyName, IReadOnlySet allowed) { if (!capabilities.TryGetProperty(propertyName, out JsonElement commands)) return true; diff --git a/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs b/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs index bfc4473..bdd32d6 100644 --- a/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs +++ b/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs @@ -4,6 +4,7 @@ using SwitchifyPc.Core.Control; using SwitchifyPc.Core.Input; using SwitchifyPc.Core.Pairing; +using SwitchifyPc.Core.Settings; using SwitchifyPc.Protocol; namespace SwitchifyPc.Tests; @@ -132,7 +133,8 @@ private static PointerCapabilities TestPointerCapabilities() true, ProtocolConstants.NoAckControlCommandTypes.ToArray(), ProtocolConstants.CommandTypes.ToArray(), - new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000)); + new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000), + PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default)); } private static string SignedCommand(string type, object payload, string id = "request-1", string? responseMode = null) diff --git a/src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs b/src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs index 8c2b010..3ffa398 100644 --- a/src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs +++ b/src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs @@ -4,6 +4,7 @@ using SwitchifyPc.Core.Control; using SwitchifyPc.Core.Input; using SwitchifyPc.Core.Pairing; +using SwitchifyPc.Core.Settings; using SwitchifyPc.Protocol; namespace SwitchifyPc.Tests; @@ -203,7 +204,8 @@ private static PointerCapabilities TestPointerCapabilities() true, ProtocolConstants.NoAckControlCommandTypes.ToArray(), ProtocolConstants.CommandTypes.ToArray(), - new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000)); + new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000), + PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default)); } private static JsonDocument Reassemble(IReadOnlyList frames) diff --git a/src/SwitchifyPc.Tests/ControlSessionTests.cs b/src/SwitchifyPc.Tests/ControlSessionTests.cs index e0fcc66..f1a62b0 100644 --- a/src/SwitchifyPc.Tests/ControlSessionTests.cs +++ b/src/SwitchifyPc.Tests/ControlSessionTests.cs @@ -111,6 +111,40 @@ public async Task ReturnsPointerProfileResponse() Assert.True(ProtocolValidator.ValidateProtocolResponse(response.RootElement).Ok); } + [Fact] + public async Task AuthenticatedPointerSpeedSetSavesAndAppliesLiveSettings() + { + FakePointerSettings pointerSettings = new(PointerMovementSettingsModel.Default); + List applied = []; + ControlSession session = CreateSession( + new FakeInputAdapter(), + pointerSettings: pointerSettings, + applyPointerSettings: applied.Add); + + ControlSessionResult result = await session.ProcessMessageAsync(SignedCommand("pointer.speed.set", new { scalePercent = 227 })); + + Assert.True(result.HasResponse); + Assert.Equal(new PointerMovementSettings(225), pointerSettings.Saved); + Assert.Equal([new PointerMovementSettings(225)], applied); + } + + [Fact] + public async Task UnauthenticatedPointerSpeedSetDoesNotSaveSettings() + { + FakePointerSettings pointerSettings = new(PointerMovementSettingsModel.Default); + List applied = []; + ControlSession session = CreateSession( + new FakeInputAdapter(), + pointerSettings: pointerSettings, + applyPointerSettings: applied.Add); + + ControlSessionResult result = await session.ProcessMessageAsync(SignedCommand("pointer.speed.set", new { scalePercent = 125 }, authOverride: "bad-proof")); + + AssertError(result, "request-1", "invalid_auth"); + Assert.Null(pointerSettings.Saved); + Assert.Empty(applied); + } + [Fact] public async Task AuthenticatedRepeatStartExecutesInitialCommandAndStopAcknowledges() { @@ -174,7 +208,9 @@ private static ControlSession CreateSession( FakeInputAdapter adapter, ICursorOverlayNotifier? cursorOverlay = null, double? lastSeenAt = null, - IMouseRepeatSettingsStore? mouseRepeatSettings = null) + IMouseRepeatSettingsStore? mouseRepeatSettings = null, + FakePointerSettings? pointerSettings = null, + Action? applyPointerSettings = null) { MemoryPairingStore store = new(new PairingState( DesktopId: "desktop-1", @@ -195,12 +231,16 @@ private static ControlSession CreateSession( MouseRepeatController? repeatController = mouseRepeatSettings is null ? null : new MouseRepeatController(executor, mouseRepeatSettings, (_, cancellationToken) => Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken)); + PointerSpeedController? pointerSpeedController = pointerSettings is null + ? null + : new PointerSpeedController(pointerSettings, applyPointerSettings ?? (_ => { })); return new ControlSession( new CommandAuthValidator(store, () => Now), executor, new FixedPointerProfileProvider(profile), - repeatController); + repeatController, + pointerSpeedController); } private static PointerCapabilities TestPointerCapabilities() @@ -209,7 +249,8 @@ private static PointerCapabilities TestPointerCapabilities() true, ProtocolConstants.NoAckControlCommandTypes.ToArray(), ProtocolConstants.CommandTypes.ToArray(), - new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000)); + new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000), + PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default)); } private sealed class FakeMouseRepeatSettings(MouseRepeatSettings settings) : IMouseRepeatSettingsStore @@ -223,6 +264,20 @@ public MouseRepeatSettings Save(MouseRepeatSettings next) } } + private sealed class FakePointerSettings(PointerMovementSettings settings) : IPointerMovementSettingsStore + { + public PointerMovementSettings? Saved { get; private set; } + + public PointerMovementSettings Load() => settings; + + public PointerMovementSettings Save(PointerMovementSettings next) + { + settings = PointerMovementSettingsModel.Normalize(next); + Saved = settings; + return settings; + } + } + private static string SignedCommand( string type, object payload, diff --git a/src/SwitchifyPc.Tests/PointerMovementSettingsTests.cs b/src/SwitchifyPc.Tests/PointerMovementSettingsTests.cs index 8edcd6a..ad39051 100644 --- a/src/SwitchifyPc.Tests/PointerMovementSettingsTests.cs +++ b/src/SwitchifyPc.Tests/PointerMovementSettingsTests.cs @@ -20,8 +20,9 @@ public void NormalizesScalePercent() { Assert.Equal(PointerMovementSettingsModel.Default, PointerMovementSettingsModel.Normalize((JsonNode?)null)); Assert.Equal(new PointerMovementSettings(125), PointerMovementSettingsModel.Normalize(JsonNode.Parse("""{"scalePercent":125}"""))); - Assert.Equal(new PointerMovementSettings(25), PointerMovementSettingsModel.Normalize(JsonNode.Parse("""{"scalePercent":10}"""))); - Assert.Equal(new PointerMovementSettings(200), PointerMovementSettingsModel.Normalize(JsonNode.Parse("""{"scalePercent":1000}"""))); + Assert.Equal(new PointerMovementSettings(5), PointerMovementSettingsModel.Normalize(JsonNode.Parse("""{"scalePercent":1}"""))); + Assert.Equal(new PointerMovementSettings(10), PointerMovementSettingsModel.Normalize(JsonNode.Parse("""{"scalePercent":10}"""))); + Assert.Equal(new PointerMovementSettings(225), PointerMovementSettingsModel.Normalize(JsonNode.Parse("""{"scalePercent":1000}"""))); Assert.Equal(new PointerMovementSettings(125), PointerMovementSettingsModel.Normalize(JsonNode.Parse("""{"scalePercent":123}"""))); Assert.Equal(PointerMovementSettingsModel.Default, PointerMovementSettingsModel.Normalize(JsonNode.Parse("""{"scalePercent":"125"}"""))); } diff --git a/src/SwitchifyPc.Tests/PointerProfileTests.cs b/src/SwitchifyPc.Tests/PointerProfileTests.cs index f6d0f92..e7c04c1 100644 --- a/src/SwitchifyPc.Tests/PointerProfileTests.cs +++ b/src/SwitchifyPc.Tests/PointerProfileTests.cs @@ -25,6 +25,14 @@ public void CreatesStableBaselineDeltasForScaledDisplay() Assert.Equal(250, profile.Capabilities.MouseRepeat.IntervalMs); Assert.Equal(250, profile.Capabilities.MouseRepeat.MoveIntervalMs); Assert.Equal(250, profile.Capabilities.MouseRepeat.ScrollIntervalMs); + Assert.True(profile.Capabilities.PointerSpeed.Supported); + Assert.True(profile.Capabilities.PointerSpeed.SetSupported); + Assert.Equal(100, profile.Capabilities.PointerSpeed.ScalePercent); + Assert.Equal(5, profile.Capabilities.PointerSpeed.MinScalePercent); + Assert.Equal(225, profile.Capabilities.PointerSpeed.MaxScalePercent); + Assert.Equal(5, profile.Capabilities.PointerSpeed.StepPercent); + Assert.Equal(128, profile.Capabilities.PointerSpeed.BaseMoveDelta); + Assert.Equal(128, profile.Capabilities.PointerSpeed.EffectiveMoveDelta); } [Fact] diff --git a/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs b/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs index d5dea90..0ce0127 100644 --- a/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs +++ b/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs @@ -62,6 +62,7 @@ public void IncludesCurrentCommandTypes() "media.control", "window.control", "pointer.profile", + "pointer.speed.set", "connection.ping", "connection.disconnecting" ]; @@ -80,6 +81,7 @@ public void RepeatCommandsRequireAckResponses() { Assert.DoesNotContain("mouse.repeat.start", ProtocolConstants.NoAckControlCommandTypes); Assert.DoesNotContain("mouse.repeat.stop", ProtocolConstants.NoAckControlCommandTypes); + Assert.DoesNotContain("pointer.speed.set", ProtocolConstants.NoAckControlCommandTypes); } [Fact] diff --git a/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs b/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs index 944db29..a99c364 100644 --- a/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs +++ b/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs @@ -42,6 +42,7 @@ public void AcceptsCurrentCommandPayloads() new { type = "media.control", payload = new { action = "playPause" } }, new { type = "window.control", payload = new { action = "switchNext" } }, new { type = "pointer.profile", payload = new { } }, + new { type = "pointer.speed.set", payload = new { scalePercent = 125 } }, new { type = "connection.ping", payload = new { } }, new { type = "connection.disconnecting", payload = new { } } ]; @@ -89,6 +90,7 @@ public void AcceptsNoResponseModeOnlyForUserControlCommands() ("connection.ping", new { }), ("connection.disconnecting", new { }), ("pointer.profile", new { }), + ("pointer.speed.set", new { scalePercent = 125 }), ("keyboard.textStream.open", new { streamId = "stream-1" }), ("keyboard.textStream.chunk", new { streamId = "stream-1", seq = 0, text = "Hello" }), ("keyboard.textStream.close", new { streamId = "stream-1", expectedCount = 0 }) @@ -157,6 +159,9 @@ public void RejectsUnsafePayloads() BaseCommand("keyboard.textStream.key", new { streamId = "stream-1", seq = 0, key = "F13" }), BaseCommand("keyboard.textStream.close", new { streamId = "stream-1", expectedCount = -1 }), BaseCommand("pointer.profile", new { includeDisplays = true }), + BaseCommand("pointer.speed.set", new { }), + BaseCommand("pointer.speed.set", new { scalePercent = "125" }), + BaseCommand("pointer.speed.set", new { scalePercent = -1 }), BaseCommand("connection.disconnecting", new { reason = "leaving" }) ]; @@ -211,6 +216,17 @@ public void ValidatesPointerProfileResponses() scrollIntervalMs = 500, minIntervalMs = 100, maxIntervalMs = 2000 + }, + pointerSpeed = new + { + supported = true, + setSupported = true, + scalePercent = 100, + minScalePercent = 5, + maxScalePercent = 225, + stepPercent = 5, + baseMoveDelta = 128, + effectiveMoveDelta = 128 } } }, @@ -220,6 +236,78 @@ public void ValidatesPointerProfileResponses() Assert.True(result.Ok, result.Message); } + [Fact] + public void RejectsInvalidPointerSpeedCapabilities() + { + ProtocolValidationResult result = ProtocolValidator.ValidateProtocolResponse(Json(new + { + version = ProtocolConstants.ProtocolVersion, + id = "profile-1", + type = "pointer.profile", + ok = true, + payload = new + { + displayId = "0:0:1280:720:1.5", + scaleFactor = 1.5, + bounds = new { x = 0, y = 0, width = 1280, height = 720 }, + maxDelta = ProtocolConstants.MaxPointerDelta, + recommendedDeltas = new { small = 50, medium = 130, large = 252 }, + capabilities = new + { + pointerSpeed = new + { + supported = true, + setSupported = true, + scalePercent = 300, + minScalePercent = 5, + maxScalePercent = 225, + stepPercent = 5, + baseMoveDelta = 128, + effectiveMoveDelta = 128 + } + } + }, + error = (object?)null + })); + + Assert.False(result.Ok); + Assert.Equal("invalid_payload", result.Error); + + ProtocolValidationResult malformedSetSupportedResult = ProtocolValidator.ValidateProtocolResponse(Json(new + { + version = ProtocolConstants.ProtocolVersion, + id = "profile-1", + type = "pointer.profile", + ok = true, + payload = new + { + displayId = "0:0:1280:720:1.5", + scaleFactor = 1.5, + bounds = new { x = 0, y = 0, width = 1280, height = 720 }, + maxDelta = ProtocolConstants.MaxPointerDelta, + recommendedDeltas = new { small = 50, medium = 130, large = 252 }, + capabilities = new + { + pointerSpeed = new + { + supported = true, + setSupported = "yes", + scalePercent = 100, + minScalePercent = 5, + maxScalePercent = 225, + stepPercent = 5, + baseMoveDelta = 128, + effectiveMoveDelta = 128 + } + } + }, + error = (object?)null + })); + + Assert.False(malformedSetSupportedResult.Ok); + Assert.Equal("invalid_payload", malformedSetSupportedResult.Error); + } + [Fact] public void RejectsInvalidMouseRepeatIntervalCapabilities() { diff --git a/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs b/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs index a3f0239..780897e 100644 --- a/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs +++ b/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs @@ -3,6 +3,7 @@ using SwitchifyPc.Core.Control; using SwitchifyPc.Core.Input; using SwitchifyPc.Core.Pairing; +using SwitchifyPc.Core.Settings; using SwitchifyPc.Protocol; namespace SwitchifyPc.Tests; @@ -255,7 +256,8 @@ private static PointerCapabilities TestPointerCapabilities() true, ProtocolConstants.NoAckControlCommandTypes.ToArray(), ProtocolConstants.CommandTypes.ToArray(), - new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000)); + new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000), + PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default)); } private static string PairingRequest( diff --git a/src/SwitchifyPc.Tests/SettingsControllerTests.cs b/src/SwitchifyPc.Tests/SettingsControllerTests.cs index 9597d57..3efa24b 100644 --- a/src/SwitchifyPc.Tests/SettingsControllerTests.cs +++ b/src/SwitchifyPc.Tests/SettingsControllerTests.cs @@ -76,16 +76,31 @@ public void SetPointerScaleSavesNormalizedValueAndUpdatesDerivedPercentages() FakePointerSettings pointer = new(PointerMovementSettingsModel.Default); SettingsController controller = CreateController(viewModel, pointer: pointer); - PointerMovementSettings saved = controller.SetPointerScalePercent(212); + PointerMovementSettings saved = controller.SetPointerScalePercent(227); - Assert.Equal(200, saved.ScalePercent); - Assert.Equal(new PointerMovementSettings(200), pointer.Saved); - Assert.Equal(200, viewModel.PointerScalePercent); - Assert.Equal("9%", viewModel.PointerSmall); - Assert.Equal("24%", viewModel.PointerMedium); + Assert.Equal(225, saved.ScalePercent); + Assert.Equal(new PointerMovementSettings(225), pointer.Saved); + Assert.Equal(225, viewModel.PointerScalePercent); + Assert.Equal("10%", viewModel.PointerSmall); + Assert.Equal("27%", viewModel.PointerMedium); Assert.Equal("50%", viewModel.PointerLarge); } + [Fact] + public void SetPointerScaleSavesMinimumValue() + { + SettingsViewModel viewModel = new(); + FakePointerSettings pointer = new(PointerMovementSettingsModel.Default); + SettingsController controller = CreateController(viewModel, pointer: pointer); + + PointerMovementSettings saved = controller.SetPointerScalePercent(1); + + Assert.Equal(5, saved.ScalePercent); + Assert.Equal(new PointerMovementSettings(5), pointer.Saved); + Assert.Equal(5, viewModel.PointerScalePercent); + Assert.True(viewModel.IsPointerScale5); + } + [Fact] public async Task SetMouseRepeatSettingsSavesNormalizedValuesAndUpdatesViewModel() { diff --git a/src/SwitchifyPc.Tests/SettingsViewModelTests.cs b/src/SwitchifyPc.Tests/SettingsViewModelTests.cs index 344b330..903687b 100644 --- a/src/SwitchifyPc.Tests/SettingsViewModelTests.cs +++ b/src/SwitchifyPc.Tests/SettingsViewModelTests.cs @@ -113,6 +113,7 @@ public void MapsPointerMovementScaleAndDerivedPercentages() viewModel.SetPointerMovementSettings(new PointerMovementSettings(50)); + Assert.False(viewModel.IsPointerScale5); Assert.False(viewModel.IsPointerScale25); Assert.True(viewModel.IsPointerScale50); Assert.False(viewModel.IsPointerScale75); @@ -319,6 +320,7 @@ public void RaisesChangedProperties() }); Assert.Contains(nameof(SettingsViewModel.PointerScalePercent), changed); + Assert.Contains(nameof(SettingsViewModel.IsPointerScale5), changed); Assert.Contains(nameof(SettingsViewModel.IsPointerScale50), changed); Assert.Contains(nameof(SettingsViewModel.PointerSmall), changed); Assert.Contains(nameof(SettingsViewModel.PointerMedium), changed); diff --git a/src/SwitchifyPc.Tests/SettingsWindowTests.cs b/src/SwitchifyPc.Tests/SettingsWindowTests.cs index 49a7b07..fcf177a 100644 --- a/src/SwitchifyPc.Tests/SettingsWindowTests.cs +++ b/src/SwitchifyPc.Tests/SettingsWindowTests.cs @@ -72,6 +72,54 @@ public void SettingsWindowShowsSeparateMouseRepeatIntervals() }); } + [Fact] + public void SettingsWindowShowsFivePercentPointerScale() + { + RunOnSta(() => + { + WpfTestApplication.ApplyTheme(AppTheme.Light); + SettingsWindow window = new(new SettingsViewModel()); + try + { + window.Show(); + window.UpdateLayout(); + + Assert.Contains("5%", RadioButtonContent(window)); + } + finally + { + window.Close(); + } + }); + } + + [Fact] + public void SettingsWindowUsesPointerSpeedCopy() + { + RunOnSta(() => + { + WpfTestApplication.ApplyTheme(AppTheme.Light); + SettingsWindow window = new(new SettingsViewModel()); + try + { + window.Show(); + window.UpdateLayout(); + + IReadOnlyList text = TextBlocks(window); + Assert.Contains("Tune pointer speed and the visual cursor marker used while controlling this PC.", text); + Assert.Contains("Pointer speed", text); + Assert.Contains("Choose how quickly Android pointer movement moves on this display.", text); + Assert.DoesNotContain("Tune movement distance and the visual cursor marker used while controlling this PC.", text); + Assert.DoesNotContain("Movement distance", text); + Assert.DoesNotContain("Choose how far each Android pointer step moves on this display.", text); + } + finally + { + window.Close(); + } + }); + } + [Fact] public void SettingsWindowUsesCustomChrome() { @@ -179,6 +227,19 @@ node is WpfButton button && return result; } + private static IReadOnlyList RadioButtonContent(DependencyObject root) + { + List content = []; + Collect(root, node => + { + if (node is System.Windows.Controls.RadioButton { Content: string text }) + { + content.Add(text); + } + }); + return content; + } + private static void Collect(DependencyObject node, Action visit) { visit(node); diff --git a/src/SwitchifyPc.Windows/Input/WindowsPointerProfileProvider.cs b/src/SwitchifyPc.Windows/Input/WindowsPointerProfileProvider.cs index 3f3c665..eaf1be8 100644 --- a/src/SwitchifyPc.Windows/Input/WindowsPointerProfileProvider.cs +++ b/src/SwitchifyPc.Windows/Input/WindowsPointerProfileProvider.cs @@ -49,7 +49,8 @@ public PointerMovementProfile GetPointerProfile() MoveIntervalMs: mouseRepeatSettings.MoveIntervalMs, ScrollIntervalMs: mouseRepeatSettings.ScrollIntervalMs, MinIntervalMs: MouseRepeatSettingsModel.MinIntervalMs, - MaxIntervalMs: MouseRepeatSettingsModel.MaxIntervalMs))); + MaxIntervalMs: MouseRepeatSettingsModel.MaxIntervalMs), + PointerSpeed: PointerProfile.PointerSpeedFor(settings))); } private static int DeltaFor(double shortEdge, PointerMovementSettings settings, PointerMovementSizeKey size)