From 1e80877e17cabd6c7a2e2be82832f380e2efe6bd Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 3 Jul 2026 21:27:07 +0100 Subject: [PATCH 1/4] Add toggleable modifier key support --- src/SwitchifyPc.App/App.xaml.cs | 10 +- .../Control/ControlSession.cs | 11 +- .../Control/RemoteControlSession.cs | 2 +- .../Input/DesktopCommandExecutor.cs | 107 +++++++- .../Input/DesktopInputAdapter.cs | 7 + src/SwitchifyPc.Protocol/ProtocolConstants.cs | 12 + src/SwitchifyPc.Protocol/ProtocolValidator.cs | 4 + .../BluetoothControlFrameProcessorTests.cs | 6 + .../BluetoothRemoteFrameProcessorTests.cs | 6 + src/SwitchifyPc.Tests/ControlSessionTests.cs | 31 ++- .../DesktopCommandExecutorTests.cs | 124 ++++++++- .../MouseRepeatControllerTests.cs | 2 + src/SwitchifyPc.Tests/PointerProfileTests.cs | 4 + .../ProtocolConstantsTests.cs | 12 + .../ProtocolValidatorTests.cs | 15 ++ .../RemoteControlSessionTests.cs | 6 + .../WindowsDesktopInputAdapterTests.cs | 29 ++ .../WindowsModifierKeyOverlayNotifierTests.cs | 49 ++++ .../Input/WindowsDesktopInputAdapter.cs | 8 + .../WindowsModifierKeyOverlayNotifier.cs | 253 ++++++++++++++++++ 20 files changed, 682 insertions(+), 16 deletions(-) create mode 100644 src/SwitchifyPc.Tests/WindowsModifierKeyOverlayNotifierTests.cs create mode 100644 src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs diff --git a/src/SwitchifyPc.App/App.xaml.cs b/src/SwitchifyPc.App/App.xaml.cs index 003aff7..a965825 100644 --- a/src/SwitchifyPc.App/App.xaml.cs +++ b/src/SwitchifyPc.App/App.xaml.cs @@ -19,6 +19,7 @@ using SwitchifyPc.Windows.Bluetooth; using SwitchifyPc.Windows.CursorOverlay; using SwitchifyPc.Windows.Input; +using SwitchifyPc.Windows.ModifierOverlay; using SwitchifyPc.Windows.Startup; using SwitchifyPc.Windows.Updates; using SwitchifyPc.Protocol; @@ -44,6 +45,7 @@ public partial class App : System.Windows.Application private DesktopCommandExecutor? commandExecutor; private MouseRepeatController? mouseRepeatController; private WindowsCursorOverlayNotifier? cursorOverlay; + private WindowsModifierKeyOverlayNotifier? modifierOverlay; private DispatcherTimer? pairingExpiryTimer; private AppThemeManager? themeManager; private bool isQuitting; @@ -117,6 +119,8 @@ protected override void OnExit(ExitEventArgs e) bluetoothServer = null; cursorOverlay?.Dispose(); cursorOverlay = null; + modifierOverlay?.Dispose(); + modifierOverlay = null; themeManager?.Dispose(); themeManager = null; commandExecutor = null; @@ -275,7 +279,8 @@ private async Task StartBluetoothAsync() SendInputWindowsNativeInput nativeInput = new(); WindowsDesktopInputAdapter inputAdapter = new(nativeInput, pointerSettingsStore.Load()); cursorOverlay = new WindowsCursorOverlayNotifier(nativeInput, cursorOverlaySettingsStore); - commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay); + modifierOverlay = new WindowsModifierKeyOverlayNotifier(nativeInput); + commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay, modifierOverlay: modifierOverlay); mouseRepeatController = new MouseRepeatController(commandExecutor, mouseRepeatSettingsStore); ControlSession controlSession = new( new CommandAuthValidator(pairingStore), @@ -328,10 +333,11 @@ private void HandleBluetoothEvent(BluetoothHelperEvent helperEvent) if (commandExecutor is not null) { - await commandExecutor.ReleaseHeldMouseButtonsAsync(); + await commandExecutor.ReleaseHeldInputsAsync(); } cursorOverlay?.EndControlSession(); + modifierOverlay?.EndControlSession(); } break; case BluetoothDiagnosticEvent diagnostic: diff --git a/src/SwitchifyPc.Core/Control/ControlSession.cs b/src/SwitchifyPc.Core/Control/ControlSession.cs index fe610a8..a3f8c9f 100644 --- a/src/SwitchifyPc.Core/Control/ControlSession.cs +++ b/src/SwitchifyPc.Core/Control/ControlSession.cs @@ -86,6 +86,8 @@ public async Task ProcessMessageAsync(string rawMessage, C await StopRepeatAsync(failedDeviceId!).ConfigureAwait(false); } + await commandExecutor.ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false); + commandExecutor.EndControlSession(); return ControlSessionResult.Response(ErrorResponse(RequestIdOrNull(request), auth.Reason ?? "invalid_auth", "Command authentication failed.")) with { AuthFailureReason = auth.Reason ?? "invalid_auth" }; } @@ -93,7 +95,7 @@ public async Task ProcessMessageAsync(string rawMessage, C if (type == "connection.disconnecting") { await StopRepeatAsync(auth.DeviceId ?? "").ConfigureAwait(false); - await commandExecutor.ReleaseHeldMouseButtonsAsync(cancellationToken).ConfigureAwait(false); + await commandExecutor.ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false); commandExecutor.EndControlSession(); return WithAuth(AckOrNoResponse(request), auth); } @@ -147,6 +149,13 @@ public async Task ProcessMessageAsync(string rawMessage, C public Task StopAllRepeatsAsync() => mouseRepeatController?.StopAllAsync() ?? Task.CompletedTask; + public async Task EndControlSessionAsync(CancellationToken cancellationToken = default) + { + await StopAllRepeatsAsync().ConfigureAwait(false); + await commandExecutor.ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false); + commandExecutor.EndControlSession(); + } + private static ControlSessionResult WithAuth(ControlSessionResult result, AuthValidationResult auth) { return result with diff --git a/src/SwitchifyPc.Core/Control/RemoteControlSession.cs b/src/SwitchifyPc.Core/Control/RemoteControlSession.cs index aa957ce..2c0fc64 100644 --- a/src/SwitchifyPc.Core/Control/RemoteControlSession.cs +++ b/src/SwitchifyPc.Core/Control/RemoteControlSession.cs @@ -133,7 +133,7 @@ public RemoteSessionResult ExpirePendingPairingRequests() public void RemoveConnection(string connectionId) { - _ = commandSession.StopAllRepeatsAsync(); + _ = commandSession.EndControlSessionAsync(); string[] requestIds = pendingConnectionsByRequestId .Where(entry => entry.Value.ConnectionId == connectionId) .Select(entry => entry.Key) diff --git a/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs b/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs index cca7ebd..4a70540 100644 --- a/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs +++ b/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs @@ -15,14 +15,21 @@ public sealed class DesktopCommandExecutor private readonly IDesktopInputAdapter adapter; private readonly ICursorOverlayNotifier? cursorOverlay; + private readonly IModifierKeyOverlayNotifier? modifierOverlay; private readonly Func now; private readonly Dictionary textInputStreams = new(StringComparer.Ordinal); + private readonly HashSet activeModifierKeys = new(StringComparer.Ordinal); private string? activeDragButton; - public DesktopCommandExecutor(IDesktopInputAdapter adapter, ICursorOverlayNotifier? cursorOverlay = null, Func? now = null) + public DesktopCommandExecutor( + IDesktopInputAdapter adapter, + ICursorOverlayNotifier? cursorOverlay = null, + Func? now = null, + IModifierKeyOverlayNotifier? modifierOverlay = null) { this.adapter = adapter; this.cursorOverlay = cursorOverlay; + this.modifierOverlay = modifierOverlay; this.now = now ?? (() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); } @@ -52,6 +59,8 @@ public async Task ExecuteAsync(JsonElement command, Canc "mouse.rightClick" => await RightClickMouseAsync(cancellationToken), "mouse.scroll" => await ScrollMouseAsync(payload, 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), "keyboard.shortcut" => await PressShortcutAsync(payload.GetProperty("keys"), cancellationToken), "keyboard.typeText" => await TypeTextAsync(payload.GetProperty("text").GetString() ?? "", cancellationToken), "keyboard.textStream.open" => OpenTextInputStream(command.GetProperty("deviceId").GetString() ?? "", payload.GetProperty("streamId").GetString() ?? ""), @@ -79,18 +88,21 @@ public async Task ExecuteAsync(JsonElement command, Canc public async Task ReleaseHeldMouseButtonsAsync(CancellationToken cancellationToken = default) { - if (activeDragButton is null) return; - string button = activeDragButton; - await adapter.SetMouseButtonDownAsync(button, false, cancellationToken); - activeDragButton = null; - cursorOverlay?.SetDragActive(false); - cursorOverlay?.Hide(); + await ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task ReleaseHeldInputsAsync(CancellationToken cancellationToken = default) + { + await ReleaseHeldMouseButtonAsync(cancellationToken).ConfigureAwait(false); + await ReleaseHeldModifiersAsync(cancellationToken).ConfigureAwait(false); } public void EndControlSession() { activeDragButton = null; + activeModifierKeys.Clear(); cursorOverlay?.EndControlSession(); + modifierOverlay?.EndControlSession(); } private async Task MoveMouseAsync(JsonElement payload, CancellationToken cancellationToken) @@ -171,6 +183,37 @@ private async Task PressKeyAsync(string key, Cancellatio return CommandExecutionResult.Success; } + private async Task SetModifierAsync(string key, bool down, CancellationToken cancellationToken) + { + if (down) + { + if (activeModifierKeys.Contains(key)) + { + return CommandExecutionResult.Success; + } + + await adapter.SetKeyDownAsync(key, true, cancellationToken); + activeModifierKeys.Add(key); + UpdateModifierOverlay(); + return CommandExecutionResult.Success; + } + + if (!activeModifierKeys.Remove(key)) + { + return CommandExecutionResult.Success; + } + + try + { + await adapter.SetKeyDownAsync(key, false, cancellationToken); + return CommandExecutionResult.Success; + } + finally + { + UpdateModifierOverlay(); + } + } + private async Task PressShortcutAsync(JsonElement keysElement, CancellationToken cancellationToken) { string[] keys = keysElement.EnumerateArray().Select(key => key.GetString() ?? "").ToArray(); @@ -310,6 +353,56 @@ 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"; } + private async Task ReleaseHeldMouseButtonAsync(CancellationToken cancellationToken) + { + if (activeDragButton is null) return; + string button = activeDragButton; + activeDragButton = null; + await adapter.SetMouseButtonDownAsync(button, false, cancellationToken); + cursorOverlay?.SetDragActive(false); + cursorOverlay?.Hide(); + } + + private async Task ReleaseHeldModifiersAsync(CancellationToken cancellationToken) + { + foreach (string key in new[] { "Meta", "Shift", "Alt", "Ctrl" }) + { + if (!activeModifierKeys.Remove(key)) continue; + try + { + await adapter.SetKeyDownAsync(key, false, cancellationToken); + } + finally + { + UpdateModifierOverlay(); + } + } + } + + private void UpdateModifierOverlay() + { + modifierOverlay?.SetActiveModifiers(ActiveModifierLabels()); + } + + private IReadOnlyList ActiveModifierLabels() + { + List labels = []; + foreach (string key in new[] { "Ctrl", "Alt", "Shift", "Meta" }) + { + if (activeModifierKeys.Contains(key)) + { + labels.Add(DisplayModifierLabel(key)); + } + } + + return labels; + } + + private static string DisplayModifierLabel(string key) + { + return key == "Meta" ? "Start" : key; + } + private static string TextInputStreamKey(string deviceId, string streamId) { return $"{deviceId}:{streamId}"; diff --git a/src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs b/src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs index 4cb41fa..50934e5 100644 --- a/src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs +++ b/src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs @@ -8,6 +8,7 @@ public interface IDesktopInputAdapter Task DoubleClickMouseAsync(string button, CancellationToken cancellationToken = default); Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellationToken = default); Task PressKeyAsync(string key, CancellationToken cancellationToken = default); + Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default); Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default); Task TypeTextAsync(string text, CancellationToken cancellationToken = default); Task TypeCharacterAsync(string text, CancellationToken cancellationToken = default); @@ -33,3 +34,9 @@ public interface ICursorOverlayNotifier void MarkControlActive(); void SetDragActive(bool active); } + +public interface IModifierKeyOverlayNotifier +{ + void SetActiveModifiers(IReadOnlyCollection activeModifiers); + void EndControlSession(); +} diff --git a/src/SwitchifyPc.Protocol/ProtocolConstants.cs b/src/SwitchifyPc.Protocol/ProtocolConstants.cs index aa5f104..0845ee5 100644 --- a/src/SwitchifyPc.Protocol/ProtocolConstants.cs +++ b/src/SwitchifyPc.Protocol/ProtocolConstants.cs @@ -24,6 +24,8 @@ public static class ProtocolConstants "mouse.dragStart", "mouse.dragEnd", "keyboard.key", + "keyboard.modifierDown", + "keyboard.modifierUp", "keyboard.shortcut", "keyboard.typeText", "keyboard.textStream.open", @@ -53,6 +55,8 @@ public static class ProtocolConstants "mouse.dragStart", "mouse.dragEnd", "keyboard.key", + "keyboard.modifierDown", + "keyboard.modifierUp", "keyboard.shortcut", "keyboard.typeText", "keyboard.textStream.char", @@ -103,6 +107,14 @@ public static class ProtocolConstants KeyboardKeys.Concat(["Ctrl", "Alt", "Shift", "Meta", "A", "C", "V", "X", "Z", "Y"]), StringComparer.Ordinal); + public static readonly IReadOnlySet ModifierKeys = new HashSet(StringComparer.Ordinal) + { + "Ctrl", + "Alt", + "Shift", + "Meta" + }; + public static readonly IReadOnlySet MediaActions = new HashSet(StringComparer.Ordinal) { "playPause", diff --git a/src/SwitchifyPc.Protocol/ProtocolValidator.cs b/src/SwitchifyPc.Protocol/ProtocolValidator.cs index e858266..09918cb 100644 --- a/src/SwitchifyPc.Protocol/ProtocolValidator.cs +++ b/src/SwitchifyPc.Protocol/ProtocolValidator.cs @@ -148,6 +148,10 @@ private static ProtocolValidationResult ValidateCommandPayload(string type, Json TryGetString(payload, "key", out string? key) && ProtocolConstants.KeyboardKeys.Contains(key) ? Valid(payload) : Invalid("invalid_payload", "Keyboard key is invalid."), + "keyboard.modifierDown" or "keyboard.modifierUp" => + TryGetString(payload, "key", out string? modifierKey) && ProtocolConstants.ModifierKeys.Contains(modifierKey) + ? Valid(payload) + : Invalid("invalid_payload", "Modifier key is invalid."), "keyboard.shortcut" => ValidateShortcutPayload(payload), "keyboard.typeText" => TryGetString(payload, "text", out string? text) && IsSafeTextPayload(text) diff --git a/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs b/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs index 4bd49e7..bfc4473 100644 --- a/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs +++ b/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs @@ -198,6 +198,12 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa return Task.CompletedTask; } + public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default) + { + Calls.Add($"setKeyDown:{key}:{down}"); + return Task.CompletedTask; + } + public Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default) { Calls.Add($"pressShortcut:{string.Join("+", keys)}"); diff --git a/src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs b/src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs index aa8afed..8c2b010 100644 --- a/src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs +++ b/src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs @@ -320,6 +320,12 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa return Task.CompletedTask; } + public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default) + { + Calls.Add($"setKeyDown:{key}:{down}"); + return Task.CompletedTask; + } + public Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default) { Calls.Add($"pressShortcut:{string.Join("+", keys)}"); diff --git a/src/SwitchifyPc.Tests/ControlSessionTests.cs b/src/SwitchifyPc.Tests/ControlSessionTests.cs index 2a0edff..e0fcc66 100644 --- a/src/SwitchifyPc.Tests/ControlSessionTests.cs +++ b/src/SwitchifyPc.Tests/ControlSessionTests.cs @@ -56,6 +56,20 @@ public async Task SuppressesResponseForNoAckCommands() Assert.Equal(["moveMouseBy:10,-2"], adapter.Calls); } + [Fact] + public async Task AuthenticatedModifierCommandsRouteToExecutor() + { + FakeInputAdapter adapter = new(); + ControlSession session = CreateSession(adapter); + + ControlSessionResult down = await session.ProcessMessageAsync(SignedCommand("keyboard.modifierDown", new { key = "Ctrl" })); + ControlSessionResult up = await session.ProcessMessageAsync(SignedCommand("keyboard.modifierUp", new { key = "Ctrl" }, id: "request-2")); + + Assert.True(down.HasResponse); + Assert.True(up.HasResponse); + Assert.Equal(["setKeyDown:Ctrl:True", "setKeyDown:Ctrl:False"], adapter.Calls); + } + [Fact] public async Task RejectsInvalidJsonAndMalformedPayloads() { @@ -121,24 +135,27 @@ public async Task AuthenticatedRepeatStartExecutesInitialCommandAndStopAcknowled } [Fact] - public async Task DisconnectingReleasesHeldMouseButtons() + public async Task DisconnectingReleasesHeldInputs() { FakeInputAdapter adapter = new(); FakeCursorOverlay overlay = new(); ControlSession session = CreateSession(adapter, overlay); await session.ProcessMessageAsync(SignedCommand("mouse.dragStart", new { button = "left" }, id: "request-1")); - ControlSessionResult result = await session.ProcessMessageAsync(SignedCommand("connection.disconnecting", new { }, id: "request-2")); + await session.ProcessMessageAsync(SignedCommand("keyboard.modifierDown", new { key = "Ctrl" }, id: "request-2")); + ControlSessionResult result = await session.ProcessMessageAsync(SignedCommand("connection.disconnecting", new { }, id: "request-3")); Assert.True(result.HasResponse); Assert.Equal( [ "setMouseButtonDown:left:True", - "setMouseButtonDown:left:False" + "setKeyDown:Ctrl:True", + "setMouseButtonDown:left:False", + "setKeyDown:Ctrl:False" ], adapter.Calls); Assert.Equal([true, false], overlay.DragActiveChanges); - Assert.Equal(1, overlay.HideCount); + Assert.Equal(2, overlay.HideCount); Assert.Equal(1, overlay.EndSessionCount); } @@ -301,6 +318,12 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa return Task.CompletedTask; } + public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default) + { + Calls.Add($"setKeyDown:{key}:{down}"); + return Task.CompletedTask; + } + public Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default) { Calls.Add($"pressShortcut:{string.Join("+", keys)}"); diff --git a/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs b/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs index adb2527..20da156 100644 --- a/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs +++ b/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs @@ -122,6 +122,98 @@ public async Task TracksDragStateAndReleasesHeldButtons() Assert.Equal(1, overlay.HideCount); } + [Fact] + public async Task TracksModifierStateAndUpdatesOverlay() + { + FakeInputAdapter adapter = new(); + FakeModifierOverlay modifierOverlay = new(); + DesktopCommandExecutor executor = new(adapter, modifierOverlay: modifierOverlay); + + await executor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Shift" })); + await executor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Ctrl" })); + await executor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Meta" })); + await executor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Ctrl" })); + await executor.ExecuteAsync(Command("keyboard.modifierUp", new { key = "Ctrl" })); + await executor.ExecuteAsync(Command("keyboard.modifierUp", new { key = "Alt" })); + await executor.ExecuteAsync(Command("keyboard.modifierUp", new { key = "Shift" })); + await executor.ExecuteAsync(Command("keyboard.modifierUp", new { key = "Meta" })); + + Assert.Equal( + [ + "setKeyDown:Shift:True", + "setKeyDown:Ctrl:True", + "setKeyDown:Meta:True", + "setKeyDown:Ctrl:False", + "setKeyDown:Shift:False", + "setKeyDown:Meta:False" + ], + adapter.Calls); + Assert.Equal( + [ + ["Shift"], + ["Ctrl", "Shift"], + ["Ctrl", "Shift", "Start"], + ["Shift", "Start"], + ["Start"], + [] + ], + modifierOverlay.Changes); + } + + [Fact] + public async Task ReleaseHeldInputsReleasesDragAndModifiers() + { + FakeInputAdapter adapter = new(); + FakeCursorOverlay cursorOverlay = new(); + FakeModifierOverlay modifierOverlay = new(); + DesktopCommandExecutor executor = new(adapter, cursorOverlay, modifierOverlay: modifierOverlay); + + await executor.ExecuteAsync(Command("mouse.dragStart", new { button = "left" })); + await executor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Ctrl" })); + await executor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Meta" })); + await executor.ReleaseHeldInputsAsync(); + + Assert.Equal( + [ + "setMouseButtonDown:left:True", + "setKeyDown:Ctrl:True", + "setKeyDown:Meta:True", + "setMouseButtonDown:left:False", + "setKeyDown:Meta:False", + "setKeyDown:Ctrl:False" + ], + adapter.Calls); + Assert.Equal([true, false], cursorOverlay.DragActiveChanges); + Assert.Equal(["Ctrl"], modifierOverlay.Changes[0]); + Assert.Equal(["Ctrl", "Start"], modifierOverlay.Changes[1]); + Assert.Equal(["Ctrl"], modifierOverlay.Changes[2]); + Assert.Equal([], modifierOverlay.Changes[3]); + } + + [Fact] + public async Task ModifierFailuresKeepOverlayConsistent() + { + FakeInputAdapter downFailure = new() { ThrowOnSetKeyDown = true }; + FakeModifierOverlay downOverlay = new(); + DesktopCommandExecutor downExecutor = new(downFailure, modifierOverlay: downOverlay); + + CommandExecutionResult down = await downExecutor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Ctrl" })); + + Assert.False(down.Ok); + Assert.Empty(downOverlay.Changes); + + FakeInputAdapter upFailure = new(); + FakeModifierOverlay upOverlay = new(); + DesktopCommandExecutor upExecutor = new(upFailure, modifierOverlay: upOverlay); + await upExecutor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Ctrl" })); + upFailure.ThrowOnSetKeyDown = true; + + CommandExecutionResult up = await upExecutor.ExecuteAsync(Command("keyboard.modifierUp", new { key = "Ctrl" })); + + Assert.False(up.Ok); + Assert.Equal([["Ctrl"], []], upOverlay.Changes); + } + [Fact] public async Task RejectsUnsafePayloads() { @@ -151,11 +243,13 @@ public async Task LeavesServerOwnedCommandsUnsupported() public void EndControlSessionHidesCursorOverlaySession() { FakeCursorOverlay overlay = new(); - DesktopCommandExecutor executor = new(new FakeInputAdapter(), overlay); + FakeModifierOverlay modifierOverlay = new(); + DesktopCommandExecutor executor = new(new FakeInputAdapter(), overlay, modifierOverlay: modifierOverlay); executor.EndControlSession(); Assert.Equal(1, overlay.EndSessionCount); + Assert.Equal(1, modifierOverlay.EndSessionCount); } private static JsonElement Command(string type, object payload, string? responseMode = null) @@ -181,6 +275,7 @@ private static JsonElement Command(string type, object payload, string? response private sealed class FakeInputAdapter : IDesktopInputAdapter { public List Calls { get; } = []; + public bool ThrowOnSetKeyDown { get; set; } public Task MoveMouseByAsync(double dx, double dy, CancellationToken cancellationToken = default) { @@ -218,6 +313,17 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa return Task.CompletedTask; } + public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default) + { + if (ThrowOnSetKeyDown) + { + throw new DesktopInputException("adapter_failure", "Set key failed."); + } + + Calls.Add($"setKeyDown:{key}:{down}"); + return Task.CompletedTask; + } + public Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default) { Calls.Add($"pressShortcut:{string.Join("+", keys)}"); @@ -282,4 +388,20 @@ public void SetDragActive(bool active) DragActiveChanges.Add(active); } } + + private sealed class FakeModifierOverlay : IModifierKeyOverlayNotifier + { + public List> Changes { get; } = []; + public int EndSessionCount { get; private set; } + + public void SetActiveModifiers(IReadOnlyCollection activeModifiers) + { + Changes.Add(activeModifiers.ToArray()); + } + + public void EndControlSession() + { + EndSessionCount += 1; + } + } } diff --git a/src/SwitchifyPc.Tests/MouseRepeatControllerTests.cs b/src/SwitchifyPc.Tests/MouseRepeatControllerTests.cs index 70a96c0..ba97673 100644 --- a/src/SwitchifyPc.Tests/MouseRepeatControllerTests.cs +++ b/src/SwitchifyPc.Tests/MouseRepeatControllerTests.cs @@ -257,6 +257,8 @@ public Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellatio public Task PressKeyAsync(string key, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task TypeTextAsync(string text, CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/src/SwitchifyPc.Tests/PointerProfileTests.cs b/src/SwitchifyPc.Tests/PointerProfileTests.cs index 69bb07c..f6d0f92 100644 --- a/src/SwitchifyPc.Tests/PointerProfileTests.cs +++ b/src/SwitchifyPc.Tests/PointerProfileTests.cs @@ -17,7 +17,11 @@ public void CreatesStableBaselineDeltasForScaledDisplay() Assert.Equal(new RecommendedDeltas(32, 86, 187), profile.RecommendedDeltas); Assert.True(profile.Capabilities.NoAckMouseMove); Assert.Contains("keyboard.textStream.char", profile.Capabilities.NoAckCommands); + Assert.Contains("keyboard.modifierDown", profile.Capabilities.NoAckCommands); + Assert.Contains("keyboard.modifierUp", profile.Capabilities.NoAckCommands); Assert.Contains("keyboard.textStream.open", profile.Capabilities.SupportedCommands); + Assert.Contains("keyboard.modifierDown", profile.Capabilities.SupportedCommands); + Assert.Contains("keyboard.modifierUp", profile.Capabilities.SupportedCommands); Assert.Equal(250, profile.Capabilities.MouseRepeat.IntervalMs); Assert.Equal(250, profile.Capabilities.MouseRepeat.MoveIntervalMs); Assert.Equal(250, profile.Capabilities.MouseRepeat.ScrollIntervalMs); diff --git a/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs b/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs index 293cbd4..dbeb5ae 100644 --- a/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs +++ b/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs @@ -38,6 +38,8 @@ public void IncludesCurrentCommandTypes() "mouse.dragStart", "mouse.dragEnd", "keyboard.key", + "keyboard.modifierDown", + "keyboard.modifierUp", "keyboard.shortcut", "keyboard.typeText", "keyboard.textStream.open", @@ -67,4 +69,14 @@ public void RepeatCommandsRequireAckResponses() Assert.DoesNotContain("mouse.repeat.start", ProtocolConstants.NoAckControlCommandTypes); Assert.DoesNotContain("mouse.repeat.stop", ProtocolConstants.NoAckControlCommandTypes); } + + [Fact] + public void ModifierCommandsCanUseNoAckResponses() + { + Assert.Contains("keyboard.modifierDown", ProtocolConstants.NoAckControlCommandTypes); + Assert.Contains("keyboard.modifierUp", ProtocolConstants.NoAckControlCommandTypes); + Assert.Equal( + ["Alt", "Ctrl", "Meta", "Shift"], + ProtocolConstants.ModifierKeys.Order(StringComparer.Ordinal)); + } } diff --git a/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs b/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs index 7945378..882cfa2 100644 --- a/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs +++ b/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs @@ -20,6 +20,14 @@ public void AcceptsCurrentCommandPayloads() new { type = "mouse.dragEnd", payload = new { button = "left" } }, new { type = "keyboard.key", payload = new { key = "Enter" } }, new { type = "keyboard.key", payload = new { key = "Meta" } }, + new { type = "keyboard.modifierDown", payload = new { key = "Ctrl" } }, + new { type = "keyboard.modifierDown", payload = new { key = "Alt" } }, + new { type = "keyboard.modifierDown", payload = new { key = "Shift" } }, + new { type = "keyboard.modifierDown", payload = new { key = "Meta" } }, + new { type = "keyboard.modifierUp", payload = new { key = "Ctrl" } }, + new { type = "keyboard.modifierUp", payload = new { key = "Alt" } }, + new { type = "keyboard.modifierUp", payload = new { key = "Shift" } }, + new { type = "keyboard.modifierUp", payload = new { key = "Meta" } }, new { type = "keyboard.shortcut", payload = new { keys = new[] { "Ctrl", "C" } } }, new { type = "keyboard.shortcut", payload = new { keys = new[] { "Meta" } } }, new { type = "keyboard.typeText", payload = new { text = "Hello" } }, @@ -56,6 +64,8 @@ public void AcceptsNoResponseModeOnlyForUserControlCommands() ["mouse.dragStart"] = new { button = "left" }, ["mouse.dragEnd"] = new { button = "left" }, ["keyboard.key"] = new { key = "Meta" }, + ["keyboard.modifierDown"] = new { key = "Ctrl" }, + ["keyboard.modifierUp"] = new { key = "Ctrl" }, ["keyboard.shortcut"] = new { keys = new[] { "Ctrl", "C" } }, ["keyboard.typeText"] = new { text = "Hello" }, ["keyboard.textStream.char"] = new { streamId = "stream-1", seq = 0, text = "H" }, @@ -128,6 +138,11 @@ public void RejectsUnsafePayloads() BaseCommand("keyboard.key", new { key = "Win" }), BaseCommand("keyboard.key", new { key = "Windows" }), BaseCommand("keyboard.key", new { key = "Super" }), + BaseCommand("keyboard.modifierDown", new { key = "Win" }), + BaseCommand("keyboard.modifierDown", new { key = "A" }), + BaseCommand("keyboard.modifierDown", new { key = "Enter" }), + BaseCommand("keyboard.modifierDown", new { }), + BaseCommand("keyboard.modifierUp", new { key = 1 }), BaseCommand("keyboard.typeText", new { text = new string('x', 2001) }), BaseCommand("keyboard.typeText", new { text = "hello\0world" }), BaseCommand("keyboard.textStream.open", new { streamId = "bad stream" }), diff --git a/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs b/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs index eb6b070..fb8266f 100644 --- a/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs +++ b/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs @@ -371,6 +371,12 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa return Task.CompletedTask; } + public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default) + { + Calls.Add($"setKeyDown:{key}:{down}"); + return Task.CompletedTask; + } + public Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default) { Calls.Add($"pressShortcut:{string.Join("+", keys)}"); diff --git a/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs b/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs index 642fca0..acd37b7 100644 --- a/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs +++ b/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs @@ -74,6 +74,35 @@ public async Task PressesMetaAsWindowsKey() Assert.Equal(["key:91:down", "key:91:up"], native.Calls); } + [Fact] + public async Task SetsModifierKeysDownAndUp() + { + FakeNativeInput native = new(); + WindowsDesktopInputAdapter adapter = new(native); + + await adapter.SetKeyDownAsync("Ctrl", true); + await adapter.SetKeyDownAsync("Alt", true); + await adapter.SetKeyDownAsync("Shift", true); + await adapter.SetKeyDownAsync("Meta", true); + await adapter.SetKeyDownAsync("Meta", false); + await adapter.SetKeyDownAsync("Shift", false); + await adapter.SetKeyDownAsync("Alt", false); + await adapter.SetKeyDownAsync("Ctrl", false); + + Assert.Equal( + [ + "key:17:down", + "key:18:down", + "key:16:down", + "key:91:down", + "key:91:up", + "key:16:up", + "key:18:up", + "key:17:up" + ], + native.Calls); + } + [Fact] public async Task PressesShortcutsDownInOrderAndUpInReverse() { diff --git a/src/SwitchifyPc.Tests/WindowsModifierKeyOverlayNotifierTests.cs b/src/SwitchifyPc.Tests/WindowsModifierKeyOverlayNotifierTests.cs new file mode 100644 index 0000000..ae15800 --- /dev/null +++ b/src/SwitchifyPc.Tests/WindowsModifierKeyOverlayNotifierTests.cs @@ -0,0 +1,49 @@ +using SwitchifyPc.Windows.Input; +using SwitchifyPc.Windows.ModifierOverlay; + +namespace SwitchifyPc.Tests; + +public sealed class WindowsModifierKeyOverlayNotifierTests +{ + [Fact] + public void CanConstructAndDisposeWithoutCreatingOverlayWindow() + { + using WindowsModifierKeyOverlayNotifier notifier = new(new FakeNativeInput()); + } + + [Fact] + public void EndControlSessionCanBeCalledBeforeShow() + { + using WindowsModifierKeyOverlayNotifier notifier = new(new FakeNativeInput()); + + notifier.EndControlSession(); + } + + [Fact] + public void CanSetActiveModifiersAndClearWithoutThrowing() + { + using WindowsModifierKeyOverlayNotifier notifier = new(new FakeNativeInput()); + + notifier.SetActiveModifiers(["Ctrl", "Start"]); + notifier.SetActiveModifiers([]); + } + + private sealed class FakeNativeInput : IWindowsNativeInput + { + public PointerPosition GetCursorPosition() => new(10, 10); + + public PointerDisplay GetDisplayForPosition(PointerPosition position) => new(new PointerDisplayBounds(0, 0, 1920, 1080), 1); + + public void MoveCursorTo(PointerPosition position) { } + + public void SetMouseButtonDown(string button, bool down) { } + + public void Scroll(PointerDelta delta) { } + + public void SetKeyDown(ushort virtualKey, bool down) { } + + public void TypeUnicodeText(string text) { } + + public void ControlWindow(string action) { } + } +} diff --git a/src/SwitchifyPc.Windows/Input/WindowsDesktopInputAdapter.cs b/src/SwitchifyPc.Windows/Input/WindowsDesktopInputAdapter.cs index a9ad1f4..77f6c05 100644 --- a/src/SwitchifyPc.Windows/Input/WindowsDesktopInputAdapter.cs +++ b/src/SwitchifyPc.Windows/Input/WindowsDesktopInputAdapter.cs @@ -75,6 +75,14 @@ public async Task PressKeyAsync(string key, CancellationToken cancellationToken await TapKeyAsync(virtualKey, cancellationToken).ConfigureAwait(false); } + public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ushort virtualKey = WindowsInputMapper.KeyboardVirtualKey(key); + nativeInput.SetKeyDown(virtualKey, down); + return Task.CompletedTask; + } + public async Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs b/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs new file mode 100644 index 0000000..0f70abb --- /dev/null +++ b/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs @@ -0,0 +1,253 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using SwitchifyPc.Core.Input; +using SwitchifyPc.Windows.CursorOverlay; +using SwitchifyPc.Windows.Input; +using Forms = System.Windows.Forms; + +namespace SwitchifyPc.Windows.ModifierOverlay; + +public sealed class WindowsModifierKeyOverlayNotifier : IModifierKeyOverlayNotifier, IDisposable +{ + private readonly IWindowsNativeInput nativeInput; + private readonly Lazy overlayThread; + private bool disposed; + + public WindowsModifierKeyOverlayNotifier(IWindowsNativeInput nativeInput) + { + this.nativeInput = nativeInput; + overlayThread = new Lazy(() => new OverlayThread(nativeInput)); + } + + public void SetActiveModifiers(IReadOnlyCollection activeModifiers) + { + if (disposed) return; + string[] labels = NormalizeLabels(activeModifiers); + overlayThread.Value.Post(form => form.SetActiveModifiers(labels)); + } + + public void EndControlSession() + { + if (disposed) return; + if (overlayThread.IsValueCreated) + { + overlayThread.Value.Post(form => form.HideOverlay()); + } + } + + public void Dispose() + { + if (disposed) return; + disposed = true; + if (overlayThread.IsValueCreated) + { + overlayThread.Value.Dispose(); + } + } + + internal static string[] NormalizeLabels(IEnumerable activeModifiers) + { + HashSet labels = new(activeModifiers, StringComparer.Ordinal); + List ordered = []; + foreach (string label in new[] { "Ctrl", "Alt", "Shift", "Start" }) + { + if (labels.Contains(label)) + { + ordered.Add(label); + } + } + + return ordered.ToArray(); + } + + private sealed class OverlayThread : IDisposable + { + private readonly IWindowsNativeInput nativeInput; + private readonly TaskCompletionSource formReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Thread thread; + + public OverlayThread(IWindowsNativeInput nativeInput) + { + this.nativeInput = nativeInput; + thread = new Thread(Run) + { + IsBackground = true, + Name = "Switchify modifier overlay" + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + + public void Post(Action action) + { + OverlayForm form = formReady.Task.GetAwaiter().GetResult(); + if (form.IsDisposed) return; + form.BeginInvoke(() => + { + try + { + action(form); + } + catch + { + form.HideOverlay(); + } + }); + } + + public void Dispose() + { + if (!formReady.Task.IsCompletedSuccessfully) return; + OverlayForm form = formReady.Task.Result; + if (!form.IsDisposed) + { + form.BeginInvoke(() => + { + form.HideOverlay(); + form.Close(); + Forms.Application.ExitThread(); + }); + } + } + + private void Run() + { + Forms.Application.EnableVisualStyles(); + using OverlayForm form = new(nativeInput); + _ = form.Handle; + formReady.SetResult(form); + Forms.Application.Run(); + } + } + + private sealed class OverlayForm : Forms.Form + { + private const int MarginPx = 16; + private const int PaddingPx = 10; + private const int ChipPaddingX = 10; + private const int ChipHeight = 24; + private const int GapPx = 6; + private static readonly Color PanelColor = Color.FromArgb(0x1F, 0x1F, 0x23); + private static readonly Color BrandRed = Color.FromArgb(0xD3, 0x2F, 0x2F); + private readonly IWindowsNativeInput nativeInput; + + public OverlayForm(IWindowsNativeInput nativeInput) + { + this.nativeInput = nativeInput; + AutoScaleMode = Forms.AutoScaleMode.None; + BackColor = PanelColor; + ClientSize = new Size(160, 44); + ControlBox = false; + FormBorderStyle = Forms.FormBorderStyle.None; + MaximizeBox = false; + MinimizeBox = false; + Name = "SwitchifyModifierOverlay"; + ShowIcon = false; + ShowInTaskbar = false; + StartPosition = Forms.FormStartPosition.Manual; + TopMost = true; + } + + protected override bool ShowWithoutActivation => true; + + protected override CreateParams CreateParams + { + get + { + CreateParams createParams = base.CreateParams; + createParams.ExStyle |= + CursorOverlayNativeMethods.WS_EX_TRANSPARENT | + CursorOverlayNativeMethods.WS_EX_TOPMOST | + CursorOverlayNativeMethods.WS_EX_TOOLWINDOW | + CursorOverlayNativeMethods.WS_EX_NOACTIVATE; + return createParams; + } + } + + public void SetActiveModifiers(IReadOnlyList activeModifiers) + { + Controls.Clear(); + if (activeModifiers.Count == 0) + { + HideOverlay(); + return; + } + + using Graphics graphics = CreateGraphics(); + int x = PaddingPx; + foreach (string label in activeModifiers) + { + SizeF textSize = graphics.MeasureString(label, Font); + int width = (int)Math.Ceiling(textSize.Width) + ChipPaddingX * 2; + Forms.Label chip = new() + { + AutoSize = false, + BackColor = BrandRed, + ForeColor = Color.White, + Text = label, + TextAlign = ContentAlignment.MiddleCenter, + Font = new Font(Font.FontFamily, 9.0f, FontStyle.Bold), + Bounds = new Rectangle(x, PaddingPx, width, ChipHeight) + }; + Controls.Add(chip); + x += width + GapPx; + } + + ClientSize = new Size(Math.Max(48, x - GapPx + PaddingPx), ChipHeight + PaddingPx * 2); + Region?.Dispose(); + Region = new Region(RoundedRectangle(new Rectangle(System.Drawing.Point.Empty, ClientSize), 8)); + Reposition(); + Show(); + ApplyTopMostNoActivate(); + } + + public void HideOverlay() + { + Controls.Clear(); + Hide(); + } + + protected override void OnPaint(Forms.PaintEventArgs e) + { + base.OnPaint(e); + using Pen border = new(BrandRed, 1); + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + e.Graphics.DrawPath(border, RoundedRectangle(new Rectangle(0, 0, ClientSize.Width - 1, ClientSize.Height - 1), 8)); + } + + private void Reposition() + { + PointerPosition cursor = nativeInput.GetCursorPosition(); + Forms.Screen screen = Forms.Screen.FromPoint(new System.Drawing.Point((int)Math.Round(cursor.X), (int)Math.Round(cursor.Y))); + Rectangle workArea = screen.WorkingArea; + Location = new System.Drawing.Point(workArea.Right - Width - MarginPx, workArea.Top + MarginPx); + } + + private void ApplyTopMostNoActivate() + { + CursorOverlayNativeMethods.SetWindowPos( + Handle, + CursorOverlayNativeMethods.HWND_TOPMOST, + 0, + 0, + 0, + 0, + CursorOverlayNativeMethods.SWP_NOMOVE | + CursorOverlayNativeMethods.SWP_NOSIZE | + CursorOverlayNativeMethods.SWP_NOACTIVATE | + CursorOverlayNativeMethods.SWP_SHOWWINDOW); + } + + private static GraphicsPath RoundedRectangle(Rectangle rectangle, int radius) + { + int diameter = radius * 2; + GraphicsPath path = new(); + path.AddArc(rectangle.Left, rectangle.Top, diameter, diameter, 180, 90); + path.AddArc(rectangle.Right - diameter, rectangle.Top, diameter, diameter, 270, 90); + path.AddArc(rectangle.Right - diameter, rectangle.Bottom - diameter, diameter, diameter, 0, 90); + path.AddArc(rectangle.Left, rectangle.Bottom - diameter, diameter, diameter, 90, 90); + path.CloseFigure(); + return path; + } + } +} From 011e84f8853fd9e45a880918ac4a1c096c7a4bde Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 4 Jul 2026 08:47:24 +0100 Subject: [PATCH 2/4] Increase modifier overlay padding --- .../ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs b/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs index 0f70abb..07c4cdb 100644 --- a/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs +++ b/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs @@ -123,10 +123,10 @@ private void Run() private sealed class OverlayForm : Forms.Form { private const int MarginPx = 16; - private const int PaddingPx = 10; - private const int ChipPaddingX = 10; - private const int ChipHeight = 24; - private const int GapPx = 6; + private const int PaddingPx = 14; + private const int ChipPaddingX = 12; + private const int ChipHeight = 28; + private const int GapPx = 8; private static readonly Color PanelColor = Color.FromArgb(0x1F, 0x1F, 0x23); private static readonly Color BrandRed = Color.FromArgb(0xD3, 0x2F, 0x2F); private readonly IWindowsNativeInput nativeInput; From fcfe1bf7a8abb7605de6b85959c4f9f5fa531798 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 4 Jul 2026 08:55:21 +0100 Subject: [PATCH 3/4] Increase modifier overlay chip size --- .../WindowsModifierKeyOverlayNotifier.cs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs b/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs index 07c4cdb..5444baf 100644 --- a/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs +++ b/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs @@ -124,12 +124,13 @@ private sealed class OverlayForm : Forms.Form { private const int MarginPx = 16; private const int PaddingPx = 14; - private const int ChipPaddingX = 12; - private const int ChipHeight = 28; + private const int ChipPaddingX = 14; + private const int ChipHeight = 32; private const int GapPx = 8; private static readonly Color PanelColor = Color.FromArgb(0x1F, 0x1F, 0x23); private static readonly Color BrandRed = Color.FromArgb(0xD3, 0x2F, 0x2F); private readonly IWindowsNativeInput nativeInput; + private readonly Font chipFont; public OverlayForm(IWindowsNativeInput nativeInput) { @@ -146,6 +147,7 @@ public OverlayForm(IWindowsNativeInput nativeInput) ShowInTaskbar = false; StartPosition = Forms.FormStartPosition.Manual; TopMost = true; + chipFont = new Font(Font.FontFamily, 9.5f, FontStyle.Bold); } protected override bool ShowWithoutActivation => true; @@ -177,8 +179,8 @@ public void SetActiveModifiers(IReadOnlyList activeModifiers) int x = PaddingPx; foreach (string label in activeModifiers) { - SizeF textSize = graphics.MeasureString(label, Font); - int width = (int)Math.Ceiling(textSize.Width) + ChipPaddingX * 2; + SizeF textSize = graphics.MeasureString(label, chipFont); + int width = (int)Math.Ceiling(textSize.Width) + ChipPaddingX * 2 + 2; Forms.Label chip = new() { AutoSize = false, @@ -186,7 +188,7 @@ public void SetActiveModifiers(IReadOnlyList activeModifiers) ForeColor = Color.White, Text = label, TextAlign = ContentAlignment.MiddleCenter, - Font = new Font(Font.FontFamily, 9.0f, FontStyle.Bold), + Font = chipFont, Bounds = new Rectangle(x, PaddingPx, width, ChipHeight) }; Controls.Add(chip); @@ -215,6 +217,16 @@ protected override void OnPaint(Forms.PaintEventArgs e) e.Graphics.DrawPath(border, RoundedRectangle(new Rectangle(0, 0, ClientSize.Width - 1, ClientSize.Height - 1), 8)); } + protected override void Dispose(bool disposing) + { + if (disposing) + { + chipFont.Dispose(); + } + + base.Dispose(disposing); + } + private void Reposition() { PointerPosition cursor = nativeInput.GetCursorPosition(); From 98d91742ec4dfd24a389e8be52ab46ca1283ce9e Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 4 Jul 2026 09:03:35 +0100 Subject: [PATCH 4/4] Prevent modifier overlay text clipping --- .../WindowsModifierKeyOverlayNotifier.cs | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs b/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs index 5444baf..eada3db 100644 --- a/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs +++ b/src/SwitchifyPc.Windows/ModifierOverlay/WindowsModifierKeyOverlayNotifier.cs @@ -123,10 +123,10 @@ private void Run() private sealed class OverlayForm : Forms.Form { private const int MarginPx = 16; - private const int PaddingPx = 14; - private const int ChipPaddingX = 14; - private const int ChipHeight = 32; - private const int GapPx = 8; + private const int PaddingPx = 16; + private const int ChipPaddingX = 18; + private const int ChipHeight = 38; + private const int GapPx = 10; private static readonly Color PanelColor = Color.FromArgb(0x1F, 0x1F, 0x23); private static readonly Color BrandRed = Color.FromArgb(0xD3, 0x2F, 0x2F); private readonly IWindowsNativeInput nativeInput; @@ -147,7 +147,7 @@ public OverlayForm(IWindowsNativeInput nativeInput) ShowInTaskbar = false; StartPosition = Forms.FormStartPosition.Manual; TopMost = true; - chipFont = new Font(Font.FontFamily, 9.5f, FontStyle.Bold); + chipFont = new Font(Font.FontFamily, 10.0f, FontStyle.Bold); } protected override bool ShowWithoutActivation => true; @@ -175,12 +175,15 @@ public void SetActiveModifiers(IReadOnlyList activeModifiers) return; } - using Graphics graphics = CreateGraphics(); int x = PaddingPx; foreach (string label in activeModifiers) { - SizeF textSize = graphics.MeasureString(label, chipFont); - int width = (int)Math.Ceiling(textSize.Width) + ChipPaddingX * 2 + 2; + Size textSize = Forms.TextRenderer.MeasureText( + label, + chipFont, + Size.Empty, + Forms.TextFormatFlags.NoPadding | Forms.TextFormatFlags.SingleLine); + int width = Math.Max(MinimumChipWidth(label), textSize.Width + ChipPaddingX * 2); Forms.Label chip = new() { AutoSize = false, @@ -203,6 +206,18 @@ public void SetActiveModifiers(IReadOnlyList activeModifiers) ApplyTopMostNoActivate(); } + private static int MinimumChipWidth(string label) + { + return label switch + { + "Ctrl" => 68, + "Alt" => 60, + "Shift" => 74, + "Start" => 74, + _ => 68 + }; + } + public void HideOverlay() { Controls.Clear();