From 0a960e41dde4baa1adcac263d770ad2b3fc531a8 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 14 Jul 2026 12:40:35 +0100 Subject: [PATCH] Add directional monitor navigation - Add authenticated directional display movement and capability reporting - Select the nearest monitor center with structured safety errors - Cover protocol and Windows behavior with tests --- .../Control/ControlSession.cs | 5 ++ .../Input/DesktopCommandExecutor.cs | 15 +++- .../Input/DesktopInputAdapter.cs | 2 + src/SwitchifyPc.Core/Input/PointerProfile.cs | 10 ++- src/SwitchifyPc.Protocol/ProtocolConstants.cs | 9 +++ src/SwitchifyPc.Protocol/ProtocolValidator.cs | 20 ++++++ .../BluetoothControlFrameProcessorTests.cs | 3 +- .../BluetoothRemoteFrameProcessorTests.cs | 3 +- src/SwitchifyPc.Tests/ControlSessionTests.cs | 30 +++++++- .../DesktopCommandExecutorTests.cs | 54 ++++++++++++++ src/SwitchifyPc.Tests/PointerProfileTests.cs | 3 + .../ProtocolConstantsTests.cs | 1 + .../ProtocolValidatorTests.cs | 37 ++++++++++ .../RemoteControlSessionTests.cs | 3 +- .../WindowsDesktopInputAdapterTests.cs | 33 +++++++++ .../WindowsDisplayNavigationTests.cs | 70 +++++++++++++++++++ .../Input/WindowsDesktopInputAdapter.cs | 33 +++++++++ .../Input/WindowsDisplayNavigation.cs | 57 +++++++++++++++ .../Input/WindowsPointerProfileProvider.cs | 5 +- 19 files changed, 384 insertions(+), 9 deletions(-) create mode 100644 src/SwitchifyPc.Tests/WindowsDisplayNavigationTests.cs create mode 100644 src/SwitchifyPc.Windows/Input/WindowsDisplayNavigation.cs diff --git a/src/SwitchifyPc.Core/Control/ControlSession.cs b/src/SwitchifyPc.Core/Control/ControlSession.cs index 48664a9..a23b265 100644 --- a/src/SwitchifyPc.Core/Control/ControlSession.cs +++ b/src/SwitchifyPc.Core/Control/ControlSession.cs @@ -306,6 +306,11 @@ private static JsonObject PointerProfileResponse(string id, PointerMovementProfi ["stepPercent"] = profile.Capabilities.PointerSpeed.StepPercent, ["baseMoveDelta"] = profile.Capabilities.PointerSpeed.BaseMoveDelta, ["effectiveMoveDelta"] = profile.Capabilities.PointerSpeed.EffectiveMoveDelta + }, + ["displayNavigation"] = new JsonObject + { + ["supported"] = profile.Capabilities.DisplayNavigation.Supported, + ["displayCount"] = profile.Capabilities.DisplayNavigation.DisplayCount } } }, diff --git a/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs b/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs index d7db7cb..69275a9 100644 --- a/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs +++ b/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs @@ -58,6 +58,7 @@ public async Task ExecuteAsync(JsonElement command, Canc "mouse.doubleClick" => await DoubleClickMouseAsync(payload.GetProperty("button").GetString() ?? "", cancellationToken), "mouse.rightClick" => await RightClickMouseAsync(cancellationToken), "mouse.scroll" => await ScrollMouseAsync(payload, cancellationToken), + "pointer.display.move" => await MovePointerToDisplayAsync(payload.GetProperty("direction").GetString() ?? "", cancellationToken), "keyboard.key" => await PressKeyAsync(payload.GetProperty("key").GetString() ?? "", cancellationToken), "keyboard.modifierDown" => await SetModifierAsync(payload.GetProperty("key").GetString() ?? "", down: true, cancellationToken), "keyboard.modifierUp" => await SetModifierAsync(payload.GetProperty("key").GetString() ?? "", down: false, cancellationToken), @@ -183,6 +184,18 @@ private async Task ScrollMouseAsync(JsonElement payload, return CommandExecutionResult.Success; } + private async Task MovePointerToDisplayAsync(string direction, CancellationToken cancellationToken) + { + if (activeDragButton is not null) + { + return CommandExecutionResult.Failure("drag_active", "End the active drag before moving to another monitor."); + } + + await adapter.MovePointerToDisplayAsync(direction, cancellationToken); + cursorOverlay?.Show(new CursorOverlayEvent(CursorOverlayEventKind.Move)); + return CommandExecutionResult.Success; + } + private async Task PressKeyAsync(string key, CancellationToken cancellationToken) { await adapter.PressKeyAsync(key, cancellationToken); @@ -378,7 +391,7 @@ private static void AssertBoundedNumber(double value, int maxAbsValue, string la private static bool IsMouseCommand(string type) { - return type is "mouse.move" or "mouse.click" or "mouse.doubleClick" or "mouse.rightClick" or "mouse.scroll" or "mouse.dragStart" or "mouse.dragEnd"; + return type is "mouse.move" or "mouse.click" or "mouse.doubleClick" or "mouse.rightClick" or "mouse.scroll" or "mouse.dragStart" or "mouse.dragEnd" or "pointer.display.move"; } private async Task ReleaseHeldMouseButtonAsync(CancellationToken cancellationToken) diff --git a/src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs b/src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs index 7cd867f..1cd90fb 100644 --- a/src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs +++ b/src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs @@ -7,6 +7,8 @@ public interface IDesktopInputAdapter Task ClickMouseAsync(string button, CancellationToken cancellationToken = default); Task DoubleClickMouseAsync(string button, CancellationToken cancellationToken = default); Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellationToken = default); + Task MovePointerToDisplayAsync(string direction, CancellationToken cancellationToken = default) => + Task.FromException(new DesktopInputException("unsupported_command", "Display navigation is not available.")); Task PressKeyAsync(string key, CancellationToken cancellationToken = default); Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default); Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default); diff --git a/src/SwitchifyPc.Core/Input/PointerProfile.cs b/src/SwitchifyPc.Core/Input/PointerProfile.cs index 5a90af4..695fe88 100644 --- a/src/SwitchifyPc.Core/Input/PointerProfile.cs +++ b/src/SwitchifyPc.Core/Input/PointerProfile.cs @@ -28,12 +28,14 @@ public sealed record PointerSpeedCapabilities( double StepPercent, int BaseMoveDelta, int EffectiveMoveDelta); +public sealed record DisplayNavigationCapabilities(bool Supported, int DisplayCount); public sealed record PointerCapabilities( bool NoAckMouseMove, IReadOnlyList NoAckCommands, IReadOnlyList SupportedCommands, MouseRepeatCapabilities MouseRepeat, - PointerSpeedCapabilities PointerSpeed); + PointerSpeedCapabilities PointerSpeed, + DisplayNavigationCapabilities DisplayNavigation); public sealed record PointerMovementProfile( string DisplayId, double ScaleFactor, @@ -78,7 +80,8 @@ public static PointerMovementProfile Create(PointerProfileInput input) "keyboard.textStream.chunk", "keyboard.textStream.close", "connection.ping", - "pointer.profile" + "pointer.profile", + "pointer.display.move" ], MouseRepeat: new MouseRepeatCapabilities( Supported: true, @@ -91,7 +94,8 @@ public static PointerMovementProfile Create(PointerProfileInput input) AccelerationDurationMs: MouseRepeatSettingsModel.Default.AccelerationDurationMs, AccelerationDurationOptionsMs: MouseRepeatSettingsModel.AccelerationDurationOptionsMs, AccelerationInitialScalePercent: MouseRepeatSettingsModel.AccelerationInitialScalePercent), - PointerSpeed: PointerSpeedFor(PointerMovementSettingsModel.Default))); + PointerSpeed: PointerSpeedFor(PointerMovementSettingsModel.Default), + DisplayNavigation: new DisplayNavigationCapabilities(Supported: true, DisplayCount: 1))); } private static Bounds NormalizeBounds(Bounds bounds) diff --git a/src/SwitchifyPc.Protocol/ProtocolConstants.cs b/src/SwitchifyPc.Protocol/ProtocolConstants.cs index cc794d1..0c526f7 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.display.move", "pointer.speed.set", "connection.ping", "connection.disconnecting" @@ -138,6 +139,14 @@ public static class ProtocolConstants "maximizeFocused" }; + public static readonly IReadOnlySet DisplayNavigationDirections = new HashSet(StringComparer.Ordinal) + { + "left", + "right", + "up", + "down" + }; + public static readonly IReadOnlySet CommandResponseModes = new HashSet(StringComparer.Ordinal) { "ack", diff --git a/src/SwitchifyPc.Protocol/ProtocolValidator.cs b/src/SwitchifyPc.Protocol/ProtocolValidator.cs index b4f7b96..e1364c8 100644 --- a/src/SwitchifyPc.Protocol/ProtocolValidator.cs +++ b/src/SwitchifyPc.Protocol/ProtocolValidator.cs @@ -148,6 +148,12 @@ private static ProtocolValidationResult ValidateCommandPayload(string type, Json TryGetPositiveFiniteNumber(payload, "scalePercent", out _) && ObjectPropertyCount(payload) == 1 ? Valid(payload) : Invalid("invalid_payload", "Pointer speed is invalid."), + "pointer.display.move" => + TryGetString(payload, "direction", out string? direction) && + ProtocolConstants.DisplayNavigationDirections.Contains(direction) && + ObjectPropertyCount(payload) == 1 + ? Valid(payload) + : Invalid("invalid_payload", "Display navigation direction is invalid."), "keyboard.key" => TryGetString(payload, "key", out string? key) && ProtocolConstants.KeyboardKeys.Contains(key) ? Valid(payload) @@ -361,11 +367,25 @@ private static ProtocolValidationResult ValidatePointerProfilePayload(JsonElemen { return Invalid("invalid_payload", "Pointer speed capability is invalid."); } + + if (capabilities.TryGetProperty("displayNavigation", out JsonElement displayNavigation) && !ValidateDisplayNavigationCapability(displayNavigation)) + { + return Invalid("invalid_payload", "Display navigation capability is invalid."); + } } return Valid(payload); } + private static bool ValidateDisplayNavigationCapability(JsonElement displayNavigation) + { + return IsObject(displayNavigation) && + ObjectPropertyCount(displayNavigation) == 2 && + TryGetBoolean(displayNavigation, "supported", out _) && + TryGetInteger(displayNavigation, "displayCount", out int displayCount) && + displayCount is >= 1 and <= 64; + } + private static ProtocolValidationResult ValidateMouseRepeatStartPayload(JsonElement payload) { if (ObjectPropertyCount(payload) != 1 || diff --git a/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs b/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs index bdd32d6..52aff6e 100644 --- a/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs +++ b/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs @@ -134,7 +134,8 @@ private static PointerCapabilities TestPointerCapabilities() ProtocolConstants.NoAckControlCommandTypes.ToArray(), ProtocolConstants.CommandTypes.ToArray(), new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000), - PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default)); + PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default), + new DisplayNavigationCapabilities(true, 2)); } 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 3ffa398..108ec48 100644 --- a/src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs +++ b/src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs @@ -205,7 +205,8 @@ private static PointerCapabilities TestPointerCapabilities() ProtocolConstants.NoAckControlCommandTypes.ToArray(), ProtocolConstants.CommandTypes.ToArray(), new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000), - PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default)); + PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default), + new DisplayNavigationCapabilities(true, 2)); } private static JsonDocument Reassemble(IReadOnlyList frames) diff --git a/src/SwitchifyPc.Tests/ControlSessionTests.cs b/src/SwitchifyPc.Tests/ControlSessionTests.cs index 6a28564..92282e2 100644 --- a/src/SwitchifyPc.Tests/ControlSessionTests.cs +++ b/src/SwitchifyPc.Tests/ControlSessionTests.cs @@ -112,9 +112,28 @@ public async Task ReturnsPointerProfileResponse() Assert.Equal(1000, mouseRepeat.GetProperty("accelerationDurationMs").GetInt32()); Assert.Equal([0, 500, 1000, 2000], mouseRepeat.GetProperty("accelerationDurationOptionsMs").EnumerateArray().Select(value => value.GetInt32())); Assert.Equal(25, mouseRepeat.GetProperty("accelerationInitialScalePercent").GetInt32()); + JsonElement displayNavigation = payload.GetProperty("capabilities").GetProperty("displayNavigation"); + Assert.True(displayNavigation.GetProperty("supported").GetBoolean()); + Assert.Equal(2, displayNavigation.GetProperty("displayCount").GetInt32()); Assert.True(ProtocolValidator.ValidateProtocolResponse(response.RootElement).Ok); } + [Fact] + public async Task ExecutesDisplayNavigationOnlyAfterAuthentication() + { + FakeInputAdapter adapter = new(); + ControlSession session = CreateSession(adapter); + + ControlSessionResult rejected = await session.ProcessMessageAsync( + SignedCommand("pointer.display.move", new { direction = "right" }, authOverride: "bad-proof")); + ControlSessionResult accepted = await session.ProcessMessageAsync( + SignedCommand("pointer.display.move", new { direction = "right" })); + + AssertError(rejected, "request-1", "invalid_auth"); + Assert.True(accepted.HasResponse); + Assert.Equal(["right"], adapter.DisplayMoves); + } + [Fact] public async Task AuthenticatedPointerSpeedSetSavesAndAppliesLiveSettings() { @@ -254,7 +273,8 @@ private static PointerCapabilities TestPointerCapabilities() ProtocolConstants.NoAckControlCommandTypes.ToArray(), ProtocolConstants.CommandTypes.ToArray(), new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000), - PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default)); + PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default), + new DisplayNavigationCapabilities(true, 2)); } private sealed class FakeMouseRepeatSettings(MouseRepeatSettings settings) : IMouseRepeatSettingsStore @@ -337,6 +357,14 @@ private static void AssertError(ControlSessionResult result, string? id, string private sealed class FakeInputAdapter : IDesktopInputAdapter { + public List DisplayMoves { get; } = []; + + public Task MovePointerToDisplayAsync(string direction, CancellationToken cancellationToken = default) + { + DisplayMoves.Add(direction); + return Task.CompletedTask; + } + public List Calls { get; } = []; public bool ThrowOnPressKey { get; init; } diff --git a/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs b/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs index 0e03ec0..c6cdaff 100644 --- a/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs +++ b/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs @@ -423,6 +423,48 @@ public async Task LeavesServerOwnedCommandsUnsupported() Assert.Equal(2, overlay.HideCount); } + [Fact] + public async Task MovesPointerToDisplayAndShowsCursorOverlay() + { + FakeInputAdapter adapter = new(); + FakeCursorOverlay overlay = new(); + DesktopCommandExecutor executor = new(adapter, overlay); + + CommandExecutionResult result = await executor.ExecuteAsync(Command("pointer.display.move", new { direction = "right" })); + + Assert.True(result.Ok); + Assert.Contains("movePointerToDisplay:right", adapter.Calls); + Assert.Equal(CursorOverlayEventKind.Move, Assert.Single(overlay.Events).Kind); + Assert.Equal(1, overlay.ActiveCount); + } + + [Fact] + public async Task RejectsDisplayNavigationDuringActiveDrag() + { + FakeInputAdapter adapter = new(); + DesktopCommandExecutor executor = new(adapter); + await executor.ExecuteAsync(Command("mouse.dragStart", new { button = "left" })); + + CommandExecutionResult result = await executor.ExecuteAsync(Command("pointer.display.move", new { direction = "right" })); + + Assert.False(result.Ok); + Assert.Equal("drag_active", result.Code); + Assert.DoesNotContain("movePointerToDisplay:right", adapter.Calls); + } + + [Fact] + public async Task ConvertsDisplayNavigationAdapterFailureToStructuredResult() + { + FakeInputAdapter adapter = new() { ThrowOnMoveToDisplay = true }; + DesktopCommandExecutor executor = new(adapter); + + CommandExecutionResult result = await executor.ExecuteAsync(Command("pointer.display.move", new { direction = "up" })); + + Assert.False(result.Ok); + Assert.Equal("adapter_failure", result.Code); + Assert.Equal("Monitor move failed.", result.Message); + } + [Fact] public void EndControlSessionHidesCursorOverlaySession() { @@ -460,6 +502,7 @@ private sealed class FakeInputAdapter : IDesktopInputAdapter { public List Calls { get; } = []; public bool ThrowOnSetKeyDown { get; set; } + public bool ThrowOnMoveToDisplay { get; set; } public Task MoveMouseByAsync(double dx, double dy, CancellationToken cancellationToken = default) { @@ -491,6 +534,17 @@ public Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellatio return Task.CompletedTask; } + public Task MovePointerToDisplayAsync(string direction, CancellationToken cancellationToken = default) + { + if (ThrowOnMoveToDisplay) + { + throw new DesktopInputException("adapter_failure", "Monitor move failed."); + } + + Calls.Add($"movePointerToDisplay:{direction}"); + return Task.CompletedTask; + } + public Task PressKeyAsync(string key, CancellationToken cancellationToken = default) { Calls.Add($"pressKey:{key}"); diff --git a/src/SwitchifyPc.Tests/PointerProfileTests.cs b/src/SwitchifyPc.Tests/PointerProfileTests.cs index 6e86a9a..217b89b 100644 --- a/src/SwitchifyPc.Tests/PointerProfileTests.cs +++ b/src/SwitchifyPc.Tests/PointerProfileTests.cs @@ -37,6 +37,9 @@ public void CreatesStableBaselineDeltasForScaledDisplay() Assert.Equal(5, profile.Capabilities.PointerSpeed.StepPercent); Assert.Equal(128, profile.Capabilities.PointerSpeed.BaseMoveDelta); Assert.Equal(128, profile.Capabilities.PointerSpeed.EffectiveMoveDelta); + Assert.True(profile.Capabilities.DisplayNavigation.Supported); + Assert.Equal(1, profile.Capabilities.DisplayNavigation.DisplayCount); + Assert.Contains("pointer.display.move", profile.Capabilities.SupportedCommands); } [Fact] diff --git a/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs b/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs index a594c4c..993f379 100644 --- a/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs +++ b/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs @@ -61,6 +61,7 @@ public void IncludesCurrentCommandTypes() "keyboard.textStream.close", "media.control", "window.control", + "pointer.display.move", "pointer.profile", "pointer.speed.set", "connection.ping", diff --git a/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs b/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs index b0b95ca..43e2862 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.display.move", payload = new { direction = "left" } }, new { type = "pointer.speed.set", payload = new { scalePercent = 125 } }, new { type = "connection.ping", payload = new { } }, new { type = "connection.disconnecting", payload = new { } } @@ -159,6 +160,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.display.move", new { }), + BaseCommand("pointer.display.move", new { direction = "forward" }), + BaseCommand("pointer.display.move", new { direction = "left", extra = true }), BaseCommand("pointer.speed.set", new { }), BaseCommand("pointer.speed.set", new { scalePercent = "125" }), BaseCommand("pointer.speed.set", new { scalePercent = -1 }), @@ -230,6 +234,11 @@ public void ValidatesPointerProfileResponses() stepPercent = 5, baseMoveDelta = 128, effectiveMoveDelta = 128 + }, + displayNavigation = new + { + supported = true, + displayCount = 3 } } }, @@ -411,6 +420,34 @@ public void RejectsMalformedPointerProfileResponses() Assert.Equal("invalid_payload", result.Error); } + [Fact] + public void RejectsMalformedDisplayNavigationCapability() + { + ProtocolValidationResult result = ProtocolValidator.ValidateProtocolResponse(Json(new + { + version = ProtocolConstants.ProtocolVersion, + id = "profile-1", + type = "pointer.profile", + ok = true, + payload = new + { + displayId = "display-1", + scaleFactor = 1, + bounds = new { x = 0, y = 0, width = 1920, height = 1080 }, + maxDelta = ProtocolConstants.MaxPointerDelta, + recommendedDeltas = new { small = 50, medium = 130, large = 252 }, + capabilities = new + { + displayNavigation = new { supported = true, displayCount = 0 } + } + }, + error = (object?)null + })); + + Assert.False(result.Ok); + Assert.Equal("invalid_payload", result.Error); + } + private static object BaseCommand(string type, object payload, string? responseMode = null) { Dictionary command = new(StringComparer.Ordinal) diff --git a/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs b/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs index 780897e..2d5e3bb 100644 --- a/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs +++ b/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs @@ -257,7 +257,8 @@ private static PointerCapabilities TestPointerCapabilities() ProtocolConstants.NoAckControlCommandTypes.ToArray(), ProtocolConstants.CommandTypes.ToArray(), new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000), - PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default)); + PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default), + new DisplayNavigationCapabilities(true, 2)); } private static string PairingRequest( diff --git a/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs b/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs index c63964a..610e74c 100644 --- a/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs +++ b/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs @@ -85,6 +85,36 @@ public async Task ScrollsSmallNonZeroDeltaByAtLeastOneDetent() Assert.Equal(new PointerDelta(1, 0), native.Scrolled); } + [Fact] + public async Task MovesPointerToCenterOfSelectedDisplay() + { + PointerDisplay source = new(new PointerDisplayBounds(0, 0, 1920, 1080), 1); + PointerDisplay target = new(new PointerDisplayBounds(1920, -360, 2560, 1440), 1); + FakeNativeInput native = new() + { + CursorPosition = new PointerPosition(100, 100), + Display = source, + Displays = [source, target] + }; + WindowsDesktopInputAdapter adapter = new(native); + + await adapter.MovePointerToDisplayAsync("right"); + + Assert.Equal(new PointerPosition(3200, 360), native.MovedTo); + } + + [Fact] + public async Task RejectsUnavailableDisplayDirection() + { + FakeNativeInput native = new(); + WindowsDesktopInputAdapter adapter = new(native); + + DesktopInputException error = await Assert.ThrowsAsync(() => adapter.MovePointerToDisplayAsync("left")); + + Assert.Equal("no_display_in_direction", error.Code); + Assert.Equal("No monitor to the left.", error.Message); + } + [Fact] public async Task PressesMetaAsWindowsKey() { @@ -243,6 +273,7 @@ private sealed class FakeNativeInput : IWindowsNativeInput { public PointerPosition CursorPosition { get; init; } = new(0, 0); public PointerDisplay Display { get; init; } = new(new PointerDisplayBounds(0, 0, 1080, 1080), 1); + public IReadOnlyList? Displays { get; init; } public PointerPosition? MovedTo { get; private set; } public PointerDelta? MovedBy { get; private set; } public PointerDelta? Scrolled { get; private set; } @@ -262,6 +293,8 @@ public PointerDisplay GetDisplayForPosition(PointerPosition position) return Display; } + public IReadOnlyList GetDisplays() => Displays ?? [Display]; + public void MoveCursorTo(PointerPosition position) { MovedTo = position; diff --git a/src/SwitchifyPc.Tests/WindowsDisplayNavigationTests.cs b/src/SwitchifyPc.Tests/WindowsDisplayNavigationTests.cs new file mode 100644 index 0000000..b581efb --- /dev/null +++ b/src/SwitchifyPc.Tests/WindowsDisplayNavigationTests.cs @@ -0,0 +1,70 @@ +using SwitchifyPc.Windows.Input; + +namespace SwitchifyPc.Tests; + +public sealed class WindowsDisplayNavigationTests +{ + private static readonly PointerDisplay Source = Display(0, 0, 1920, 1080); + + [Theory] + [InlineData("left", -1280, 0)] + [InlineData("right", 1920, 0)] + [InlineData("up", 0, -900)] + [InlineData("down", 0, 1080)] + public void FindsDisplayInRequestedDirection(string direction, double expectedX, double expectedY) + { + PointerDisplay destination = direction switch + { + "left" => Display(-1280, 0, 1280, 1024), + "right" => Display(1920, 0, 2560, 1440), + "up" => Display(0, -900, 1600, 900), + _ => Display(0, 1080, 1366, 768) + }; + + PointerDisplay? target = WindowsDisplayNavigation.FindTarget(Source, [Source, destination], direction); + + Assert.NotNull(target); + Assert.Equal(expectedX, target.Bounds.X); + Assert.Equal(expectedY, target.Bounds.Y); + } + + [Fact] + public void SelectsNearestCenterInRequestedHalfPlane() + { + PointerDisplay nearDiagonal = Display(1920, 1080, 1280, 720); + PointerDisplay farRight = Display(4000, 0, 1920, 1080); + + PointerDisplay? target = WindowsDisplayNavigation.FindTarget(Source, [Source, farRight, nearDiagonal], "right"); + + Assert.Equal(nearDiagonal, target); + } + + [Fact] + public void UsesBoundsAsDeterministicTieBreaker() + { + PointerDisplay upper = Display(1920, -1080, 1920, 1080); + PointerDisplay lower = Display(1920, 1080, 1920, 1080); + + PointerDisplay? target = WindowsDisplayNavigation.FindTarget(Source, [Source, lower, upper], "right"); + + Assert.Equal(upper, target); + } + + [Fact] + public void ReturnsNullWhenNoDisplayExistsInDirection() + { + Assert.Null(WindowsDisplayNavigation.FindTarget(Source, [Source, Display(-1920, 0, 1920, 1080)], "right")); + Assert.Null(WindowsDisplayNavigation.FindTarget(Source, [Source], "left")); + } + + [Fact] + public void CalculatesCenterForNegativeOriginAndMixedResolution() + { + PointerDisplay display = Display(-2560, -1440, 2560, 1440); + + Assert.Equal(new PointerPosition(-1280, -720), WindowsDisplayNavigation.Center(display)); + } + + private static PointerDisplay Display(double x, double y, double width, double height) => + new(new PointerDisplayBounds(x, y, width, height), 1); +} diff --git a/src/SwitchifyPc.Windows/Input/WindowsDesktopInputAdapter.cs b/src/SwitchifyPc.Windows/Input/WindowsDesktopInputAdapter.cs index 5dba7eb..35a331b 100644 --- a/src/SwitchifyPc.Windows/Input/WindowsDesktopInputAdapter.cs +++ b/src/SwitchifyPc.Windows/Input/WindowsDesktopInputAdapter.cs @@ -8,6 +8,7 @@ public interface IWindowsNativeInput { PointerPosition GetCursorPosition(); PointerDisplay GetDisplayForPosition(PointerPosition position); + IReadOnlyList GetDisplays() => [GetDisplayForPosition(GetCursorPosition())]; void MoveCursorTo(PointerPosition position); void MoveCursorBy(PointerDelta delta); void SetMouseButtonDown(string button, bool down); @@ -79,6 +80,29 @@ public Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellatio return Task.CompletedTask; } + public Task MovePointerToDisplayAsync(string direction, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + PointerPosition current = nativeInput.GetCursorPosition(); + PointerDisplay source = nativeInput.GetDisplayForPosition(current); + PointerDisplay? target = WindowsDisplayNavigation.FindTarget(source, nativeInput.GetDisplays(), direction); + if (target is null) + { + string location = direction switch + { + "up" => "above", + "down" => "below", + _ => $"to the {direction}" + }; + throw new DesktopInputException("no_display_in_direction", $"No monitor {location}."); + } + + nativeInput.MoveCursorTo(WindowsDisplayNavigation.Center(target)); + cachedDisplay = null; + cachedDisplayAtMs = double.NegativeInfinity; + return Task.CompletedTask; + } + public async Task PressKeyAsync(string key, CancellationToken cancellationToken = default) { ushort virtualKey = WindowsInputMapper.KeyboardVirtualKey(key); @@ -215,6 +239,15 @@ public PointerDisplay GetDisplayForPosition(PointerPosition position) ScaleFactor: 1); } + public IReadOnlyList GetDisplays() + { + return System.Windows.Forms.Screen.AllScreens + .Select(screen => new PointerDisplay( + new PointerDisplayBounds(screen.Bounds.X, screen.Bounds.Y, screen.Bounds.Width, screen.Bounds.Height), + ScaleFactor: 1)) + .ToArray(); + } + public void MoveCursorTo(PointerPosition position) { if (!SetCursorPos((int)Math.Round(position.X), (int)Math.Round(position.Y))) diff --git a/src/SwitchifyPc.Windows/Input/WindowsDisplayNavigation.cs b/src/SwitchifyPc.Windows/Input/WindowsDisplayNavigation.cs new file mode 100644 index 0000000..9b375a8 --- /dev/null +++ b/src/SwitchifyPc.Windows/Input/WindowsDisplayNavigation.cs @@ -0,0 +1,57 @@ +namespace SwitchifyPc.Windows.Input; + +public static class WindowsDisplayNavigation +{ + public static PointerDisplay? FindTarget( + PointerDisplay source, + IReadOnlyList displays, + string direction) + { + PointerPosition sourceCenter = Center(source); + + return displays + .Where(display => !HasSameBounds(display, source)) + .Select(display => new Candidate(display, Center(display))) + .Where(candidate => IsInDirection(sourceCenter, candidate.Center, direction)) + .OrderBy(candidate => DistanceSquared(sourceCenter, candidate.Center)) + .ThenBy(candidate => candidate.Display.Bounds.X) + .ThenBy(candidate => candidate.Display.Bounds.Y) + .ThenBy(candidate => candidate.Display.Bounds.Width) + .ThenBy(candidate => candidate.Display.Bounds.Height) + .Select(candidate => candidate.Display) + .FirstOrDefault(); + } + + public static PointerPosition Center(PointerDisplay display) + { + return new PointerPosition( + display.Bounds.X + (display.Bounds.Width / 2), + display.Bounds.Y + (display.Bounds.Height / 2)); + } + + private static bool IsInDirection(PointerPosition source, PointerPosition candidate, string direction) + { + return direction switch + { + "left" => candidate.X < source.X, + "right" => candidate.X > source.X, + "up" => candidate.Y < source.Y, + "down" => candidate.Y > source.Y, + _ => false + }; + } + + private static bool HasSameBounds(PointerDisplay first, PointerDisplay second) + { + return first.Bounds == second.Bounds; + } + + private static double DistanceSquared(PointerPosition source, PointerPosition candidate) + { + double dx = candidate.X - source.X; + double dy = candidate.Y - source.Y; + return (dx * dx) + (dy * dy); + } + + private sealed record Candidate(PointerDisplay Display, PointerPosition Center); +} diff --git a/src/SwitchifyPc.Windows/Input/WindowsPointerProfileProvider.cs b/src/SwitchifyPc.Windows/Input/WindowsPointerProfileProvider.cs index 5fb1f1a..48edfd5 100644 --- a/src/SwitchifyPc.Windows/Input/WindowsPointerProfileProvider.cs +++ b/src/SwitchifyPc.Windows/Input/WindowsPointerProfileProvider.cs @@ -53,7 +53,10 @@ public PointerMovementProfile GetPointerProfile() AccelerationDurationMs: mouseRepeatSettings.AccelerationDurationMs, AccelerationDurationOptionsMs: MouseRepeatSettingsModel.AccelerationDurationOptionsMs, AccelerationInitialScalePercent: MouseRepeatSettingsModel.AccelerationInitialScalePercent), - PointerSpeed: PointerProfile.PointerSpeedFor(settings))); + PointerSpeed: PointerProfile.PointerSpeedFor(settings), + DisplayNavigation: new DisplayNavigationCapabilities( + Supported: true, + DisplayCount: Math.Clamp(nativeInput.GetDisplays().Count, 1, 64)))); } private static int DeltaFor(double shortEdge, PointerMovementSettings settings, PointerMovementSizeKey size)