Skip to content

Commit 66e7fdb

Browse files
authored
Merge pull request #368 from switchifyapp/feat/alphabet-shortcuts-pc-367
Support full alphabet keyboard shortcuts
2 parents 2b9aed7 + d111ade commit 66e7fdb

8 files changed

Lines changed: 140 additions & 6 deletions

File tree

src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,35 @@ private async Task<CommandExecutionResult> PressShortcutAsync(JsonElement keysEl
222222
return CommandExecutionResult.Failure("unsafe_payload", "Shortcut key count is invalid.");
223223
}
224224

225-
await adapter.PressShortcutAsync(keys, cancellationToken);
225+
await PressShortcutRespectingHeldModifiersAsync(keys, cancellationToken);
226226
return CommandExecutionResult.Success;
227227
}
228228

229+
private async Task PressShortcutRespectingHeldModifiersAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken)
230+
{
231+
List<string> temporaryKeys = [];
232+
try
233+
{
234+
foreach (string key in keys)
235+
{
236+
if (activeModifierKeys.Contains(key))
237+
{
238+
continue;
239+
}
240+
241+
await adapter.SetKeyDownAsync(key, true, cancellationToken).ConfigureAwait(false);
242+
temporaryKeys.Add(key);
243+
}
244+
}
245+
finally
246+
{
247+
foreach (string key in temporaryKeys.AsEnumerable().Reverse())
248+
{
249+
await adapter.SetKeyDownAsync(key, false, cancellationToken).ConfigureAwait(false);
250+
}
251+
}
252+
}
253+
229254
private async Task<CommandExecutionResult> TypeTextAsync(string text, CancellationToken cancellationToken)
230255
{
231256
if (text.Length > ProtocolConstants.MaxTextLength)

src/SwitchifyPc.Protocol/ProtocolConstants.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public static class ProtocolConstants
104104
};
105105

106106
public static readonly IReadOnlySet<string> ShortcutKeys = new HashSet<string>(
107-
KeyboardKeys.Concat(["Ctrl", "Alt", "Shift", "Meta", "A", "C", "V", "X", "Z", "Y"]),
107+
KeyboardKeys.Concat(["Ctrl", "Alt", "Shift", "Meta"]).Concat(Enumerable.Range('A', 26).Select(code => ((char)code).ToString())),
108108
StringComparer.Ordinal);
109109

110110
public static readonly IReadOnlySet<string> ModifierKeys = new HashSet<string>(StringComparer.Ordinal)

src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,12 @@ public async Task MapsKeyboardTextMediaWindowAndPingCommands()
5151
Assert.Equal(
5252
[
5353
"pressKey:Meta",
54-
"pressShortcut:Meta",
55-
"pressShortcut:Ctrl+C",
54+
"setKeyDown:Meta:True",
55+
"setKeyDown:Meta:False",
56+
"setKeyDown:Ctrl:True",
57+
"setKeyDown:C:True",
58+
"setKeyDown:C:False",
59+
"setKeyDown:Ctrl:False",
5660
"typeText:Hello",
5761
"mediaControl:playPause",
5862
"controlWindow:switchNext"
@@ -61,6 +65,59 @@ public async Task MapsKeyboardTextMediaWindowAndPingCommands()
6165
Assert.Equal(7, overlay.HideCount);
6266
}
6367

68+
[Fact]
69+
public async Task KeyboardShortcutsUseTemporaryKeyDownAndUp()
70+
{
71+
FakeInputAdapter adapter = new();
72+
DesktopCommandExecutor executor = new(adapter);
73+
74+
await executor.ExecuteAsync(Command("keyboard.shortcut", new { keys = new[] { "Ctrl", "C" } }));
75+
76+
Assert.Equal(
77+
[
78+
"setKeyDown:Ctrl:True",
79+
"setKeyDown:C:True",
80+
"setKeyDown:C:False",
81+
"setKeyDown:Ctrl:False"
82+
],
83+
adapter.Calls);
84+
}
85+
86+
[Fact]
87+
public async Task KeyboardShortcutsDoNotReleaseHeldModifiers()
88+
{
89+
FakeInputAdapter adapter = new();
90+
FakeModifierOverlay modifierOverlay = new();
91+
DesktopCommandExecutor executor = new(adapter, modifierOverlay: modifierOverlay);
92+
93+
await executor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Ctrl" }));
94+
await executor.ExecuteAsync(Command("keyboard.shortcut", new { keys = new[] { "Ctrl", "C" } }));
95+
await executor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Shift" }));
96+
await executor.ExecuteAsync(Command("keyboard.shortcut", new { keys = new[] { "Ctrl", "Shift", "Z" } }));
97+
await executor.ExecuteAsync(Command("keyboard.shortcut", new { keys = new[] { "Ctrl", "Alt", "F" } }));
98+
99+
Assert.Equal(
100+
[
101+
"setKeyDown:Ctrl:True",
102+
"setKeyDown:C:True",
103+
"setKeyDown:C:False",
104+
"setKeyDown:Shift:True",
105+
"setKeyDown:Z:True",
106+
"setKeyDown:Z:False",
107+
"setKeyDown:Alt:True",
108+
"setKeyDown:F:True",
109+
"setKeyDown:F:False",
110+
"setKeyDown:Alt:False"
111+
],
112+
adapter.Calls);
113+
Assert.Equal(["Ctrl", "Shift"], modifierOverlay.Changes.Last());
114+
115+
await executor.ReleaseHeldInputsAsync();
116+
117+
Assert.EndsWith("setKeyDown:Shift:False", adapter.Calls[^2]);
118+
Assert.EndsWith("setKeyDown:Ctrl:False", adapter.Calls[^1]);
119+
}
120+
64121
[Fact]
65122
public async Task StreamsTextCharactersKeysAndChunks()
66123
{

src/SwitchifyPc.Tests/ProtocolConstantsTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ public void PreservesCurrentProtocolLimits()
2323
Assert.Equal(300, ProtocolConstants.MaxErrorMessageLength);
2424
}
2525

26+
[Fact]
27+
public void ShortcutKeysIncludeUppercaseAlphabetOnly()
28+
{
29+
foreach (string key in Enumerable.Range('A', 26).Select(code => ((char)code).ToString()))
30+
{
31+
Assert.Contains(key, ProtocolConstants.ShortcutKeys);
32+
}
33+
34+
Assert.DoesNotContain("a", ProtocolConstants.ShortcutKeys);
35+
Assert.Equal(6, ProtocolConstants.MaxShortcutKeys);
36+
}
37+
2638
[Fact]
2739
public void IncludesCurrentCommandTypes()
2840
{

src/SwitchifyPc.Tests/ProtocolValidatorTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ public void AcceptsCurrentCommandPayloads()
2929
new { type = "keyboard.modifierUp", payload = new { key = "Shift" } },
3030
new { type = "keyboard.modifierUp", payload = new { key = "Meta" } },
3131
new { type = "keyboard.shortcut", payload = new { keys = new[] { "Ctrl", "C" } } },
32+
new { type = "keyboard.shortcut", payload = new { keys = new[] { "Ctrl", "A" } } },
33+
new { type = "keyboard.shortcut", payload = new { keys = new[] { "Ctrl", "Shift", "Z" } } },
34+
new { type = "keyboard.shortcut", payload = new { keys = new[] { "Alt", "F" } } },
3235
new { type = "keyboard.shortcut", payload = new { keys = new[] { "Meta" } } },
3336
new { type = "keyboard.typeText", payload = new { text = "Hello" } },
3437
new { type = "keyboard.textStream.open", payload = new { streamId = "android-stream-1" } },
@@ -134,6 +137,9 @@ public void RejectsUnsafePayloads()
134137
[
135138
BaseCommand("mouse.move", new { dx = 501, dy = 0 }),
136139
BaseCommand("keyboard.shortcut", new { keys = Array.Empty<string>() }),
140+
BaseCommand("keyboard.shortcut", new { keys = new[] { "Ctrl", "a" } }),
141+
BaseCommand("keyboard.shortcut", new { keys = new[] { "Ctrl", "1" } }),
142+
BaseCommand("keyboard.shortcut", new { keys = new[] { "Ctrl", "." } }),
137143
BaseCommand("keyboard.key", new { key = "F13" }),
138144
BaseCommand("keyboard.key", new { key = "Win" }),
139145
BaseCommand("keyboard.key", new { key = "Windows" }),

src/SwitchifyPc.Tests/RemoteControlSessionTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,8 @@ public async Task DelegatesShortcutTextMediaAndWindowCommandsToCommandSession()
206206
result => Assert.Equal("ble-1", Assert.Single(result.OutgoingMessages).ConnectionId));
207207
Assert.Equal(
208208
[
209-
"pressShortcut:Meta",
209+
"setKeyDown:Meta:True",
210+
"setKeyDown:Meta:False",
210211
"typeText:Hello",
211212
"mediaControl:playPause",
212213
"controlWindow:showDesktop"

src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,34 @@ public async Task PressesMetaAsWindowsKey()
7474
Assert.Equal(["key:91:down", "key:91:up"], native.Calls);
7575
}
7676

77+
[Fact]
78+
public async Task MapsUppercaseShortcutLettersToVirtualKeys()
79+
{
80+
FakeNativeInput native = new();
81+
WindowsDesktopInputAdapter adapter = new(native);
82+
83+
await adapter.PressShortcutAsync(["A", "M", "Z"]);
84+
85+
Assert.Equal(
86+
[
87+
"key:65:down",
88+
"key:77:down",
89+
"key:90:down",
90+
"key:90:up",
91+
"key:77:up",
92+
"key:65:up"
93+
],
94+
native.Calls);
95+
}
96+
97+
[Fact]
98+
public async Task RejectsLowercaseShortcutLetters()
99+
{
100+
WindowsDesktopInputAdapter adapter = new(new FakeNativeInput());
101+
102+
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => adapter.PressShortcutAsync(["a"]));
103+
}
104+
77105
[Fact]
78106
public async Task SetsModifierKeysDownAndUp()
79107
{

src/SwitchifyPc.Windows/Input/WindowsInputMapper.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public static ushort KeyboardVirtualKey(string key)
6565
"Alt" => VkAlt,
6666
"Shift" => VkShift,
6767
"Meta" => VkLeftWindows,
68-
"A" or "C" or "V" or "X" or "Y" or "Z" => key[0],
68+
_ when IsUppercaseLetterKey(key) => key[0],
6969
_ when IsFunctionKey(key, out ushort virtualKey) => virtualKey,
7070
_ => throw new ArgumentOutOfRangeException(nameof(key), key, null)
7171
};
@@ -106,4 +106,9 @@ private static bool IsFunctionKey(string key, out ushort virtualKey)
106106
virtualKey = (ushort)(VkF1 + functionIndex - 1);
107107
return true;
108108
}
109+
110+
private static bool IsUppercaseLetterKey(string key)
111+
{
112+
return key.Length == 1 && key[0] is >= 'A' and <= 'Z';
113+
}
109114
}

0 commit comments

Comments
 (0)