Skip to content

Commit d87cc8b

Browse files
Merge pull request #22622 from unoplatform/copilot/update-standard-ui-command-shortcuts
feat: Use Command key for StandardUICommand shortcuts on Apple platforms
2 parents da24116 + f9bd4d6 commit d87cc8b

File tree

6 files changed

+120
-88
lines changed

6 files changed

+120
-88
lines changed

src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Given_TextBox.skia.cs

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using SamplesApp.UITests;
1515
using Uno.Disposables;
1616
using Uno.Extensions;
17+
using Uno.UI.Helpers;
1718
using Uno.UI.RuntimeTests.Helpers;
1819
using Uno.UI.Toolkit.DevTools.Input;
1920
using Uno.UI.Xaml.Core;
@@ -38,8 +39,8 @@ namespace Uno.UI.RuntimeTests.Tests.Windows_UI_Xaml_Controls
3839
/// </summary>
3940
public partial class Given_TextBox
4041
{
41-
// most macOS keyboard shortcuts uses Command (mapped as Window) and not Control (Ctrl)
42-
private readonly VirtualKeyModifiers _platformCtrlKey = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? VirtualKeyModifiers.Windows : VirtualKeyModifiers.Control;
42+
// Apple platforms (macOS, iOS, Mac Catalyst, tvOS) use Command key for standard shortcuts
43+
private readonly VirtualKeyModifiers _platformCtrlKey = DeviceTargetHelper.PlatformCommandModifier;
4344

4445
[TestMethod]
4546
public async Task When_Basic_Input()
@@ -369,10 +370,10 @@ public async Task When_Ctrl_End_ScrollViewer_Vertical_Offset()
369370

370371
Assert.AreEqual(0, ((ScrollViewer)SUT.ContentElement).VerticalOffset);
371372

372-
// on macOS moving to the end of the document is done with `Command` + `Down`
373-
var macOS = OperatingSystem.IsMacOS();
374-
var key = macOS ? VirtualKey.Down : VirtualKey.End;
375-
var mod = macOS ? VirtualKeyModifiers.Windows : VirtualKeyModifiers.Control;
373+
// on Apple platforms moving to the end of the document is done with `Command` + `Down`
374+
var isAppleKeyboard = DeviceTargetHelper.UsesAppleKeyboardLayout;
375+
var key = isAppleKeyboard ? VirtualKey.Down : VirtualKey.End;
376+
var mod = isAppleKeyboard ? VirtualKeyModifiers.Windows : VirtualKeyModifiers.Control;
376377
SUT.SafeRaiseEvent(UIElement.KeyDownEvent, new KeyRoutedEventArgs(SUT, key, mod));
377378
await WindowHelper.WaitForIdle();
378379

@@ -463,14 +464,14 @@ public async Task When_Ctrl_Home_End()
463464
SUT.Focus(FocusState.Programmatic);
464465
await WindowHelper.WaitForIdle();
465466

466-
var key = OperatingSystem.IsMacOS() ? VirtualKey.Down : VirtualKey.End;
467+
var key = DeviceTargetHelper.UsesAppleKeyboardLayout ? VirtualKey.Down : VirtualKey.End;
467468
SUT.SafeRaiseEvent(UIElement.KeyDownEvent, new KeyRoutedEventArgs(SUT, key, _platformCtrlKey));
468469
await WindowHelper.WaitForIdle();
469470

470471
Assert.AreEqual(SUT.Text.Length, SUT.SelectionStart);
471472
Assert.AreEqual(0, SUT.SelectionLength);
472473

473-
key = OperatingSystem.IsMacOS() ? VirtualKey.Up : VirtualKey.Home;
474+
key = DeviceTargetHelper.UsesAppleKeyboardLayout ? VirtualKey.Up : VirtualKey.Home;
474475
SUT.SafeRaiseEvent(UIElement.KeyDownEvent, new KeyRoutedEventArgs(SUT, key, _platformCtrlKey));
475476
await WindowHelper.WaitForIdle();
476477

@@ -499,8 +500,8 @@ public async Task When_Ctrl_Delete()
499500
Assert.AreEqual(0, SUT.SelectionStart);
500501
Assert.AreEqual(0, SUT.SelectionLength);
501502

502-
// on macOS it's option (menu/alt) and backspace to delete a word
503-
var mod = OperatingSystem.IsMacOS() ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control;
503+
// on Apple platforms it's option (menu/alt) and backspace to delete a word
504+
var mod = DeviceTargetHelper.UsesAppleKeyboardLayout ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control;
504505
SUT.SafeRaiseEvent(UIElement.KeyDownEvent, new KeyRoutedEventArgs(SUT, VirtualKey.Delete, mod));
505506
await WindowHelper.WaitForIdle();
506507

@@ -537,8 +538,8 @@ public async Task When_Ctrl_Backspace()
537538
SUT.Select(SUT.Text.Length, 0);
538539
await WindowHelper.WaitForIdle();
539540

540-
// on macOS it's option (menu/alt) and backspace to delete a word
541-
var mod = OperatingSystem.IsMacOS() ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control;
541+
// on Apple platforms it's option (menu/alt) and backspace to delete a word
542+
var mod = DeviceTargetHelper.UsesAppleKeyboardLayout ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control;
542543
SUT.SafeRaiseEvent(UIElement.KeyDownEvent, new KeyRoutedEventArgs(SUT, VirtualKey.Back, mod));
543544
await WindowHelper.WaitForIdle();
544545

@@ -610,8 +611,8 @@ public async Task When_Selection_With_Keyboard_NoMod_Ctrl_And_Shift()
610611
SUT.SafeRaiseEvent(UIElement.KeyDownEvent, new KeyRoutedEventArgs(SUT, VirtualKey.Home, VirtualKeyModifiers.None));
611612
await WindowHelper.WaitForIdle();
612613

613-
// on macOS you use `option` (alt/menu) and `right` to move to the next work
614-
var mod = OperatingSystem.IsMacOS() ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control;
614+
// on Apple platforms you use `option` (alt/menu) and `right` to move to the next word
615+
var mod = DeviceTargetHelper.UsesAppleKeyboardLayout ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control;
615616
SUT.SafeRaiseEvent(UIElement.KeyDownEvent, new KeyRoutedEventArgs(SUT, VirtualKey.Right, mod));
616617
await WindowHelper.WaitForIdle();
617618
Assert.AreEqual(6, SUT.SelectionStart);
@@ -1003,8 +1004,8 @@ public async Task When_Scrolling_Updates_After_Backspace()
10031004
Assert.AreEqual(svRight, LayoutInformation.GetLayoutSlot(SUT).Right);
10041005
}
10051006

1006-
// on macOS we use `option` (menu/alt) + `delete` to remove word at the left
1007-
var mod = OperatingSystem.IsMacOS() ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control;
1007+
// on Apple platforms we use `option` (menu/alt) + `delete` to remove word at the left
1008+
var mod = DeviceTargetHelper.UsesAppleKeyboardLayout ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control;
10081009
for (var i = 0; i < 10; i++)
10091010
{
10101011
SUT.SafeRaiseEvent(UIElement.KeyDownEvent, new KeyRoutedEventArgs(SUT, VirtualKey.Back, mod));
@@ -1930,9 +1931,9 @@ public async Task When_NonAscii_Characters()
19301931
[CombinatorialData]
19311932
public async Task When_Copy_Paste(bool useInsert)
19321933
{
1933-
if (useInsert && OperatingSystem.IsMacOS())
1934+
if (useInsert && DeviceTargetHelper.UsesAppleKeyboardLayout)
19341935
{
1935-
Assert.Inconclusive("There's no `Insert` key on Mac keyboards");
1936+
Assert.Inconclusive("There's no `Insert` key on Apple keyboards");
19361937
// it's replaced by the `fn` key, which is a modifier
19371938
}
19381939
if (OperatingSystem.IsBrowser())
@@ -2539,8 +2540,8 @@ public async Task When_Multiline_Keyboard_Chunking()
25392540
Assert.AreEqual(0, SUT.SelectionStart);
25402541
Assert.AreEqual(0, SUT.SelectionLength);
25412542

2542-
// on macOS selecting the next word is `shift` + `option` (alt/menu) + `right`
2543-
var mod = VirtualKeyModifiers.Shift | (OperatingSystem.IsMacOS() ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control);
2543+
// on Apple platforms selecting the next word is `shift` + `option` (alt/menu) + `right`
2544+
var mod = VirtualKeyModifiers.Shift | (DeviceTargetHelper.UsesAppleKeyboardLayout ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control);
25442545
SUT.SafeRaiseEvent(UIElement.KeyDownEvent, new KeyRoutedEventArgs(SUT, VirtualKey.Right, mod));
25452546
await WindowHelper.WaitForIdle();
25462547
Assert.AreEqual(0, SUT.SelectionStart);
@@ -4189,8 +4190,8 @@ public async Task When_Ctrl_Delete_Undo_Redo()
41894190
SUT.Focus(FocusState.Programmatic);
41904191
await WindowHelper.WaitForIdle();
41914192

4192-
// on macOS it's option (menu/alt) and backspace to delete a word
4193-
var mod = OperatingSystem.IsMacOS() ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control;
4193+
// on Apple platforms it's option (menu/alt) and backspace to delete a word
4194+
var mod = DeviceTargetHelper.UsesAppleKeyboardLayout ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.Control;
41944195
SUT.SafeRaiseEvent(UIElement.KeyDownEvent, new KeyRoutedEventArgs(SUT, VirtualKey.Delete, mod));
41954196
await WindowHelper.WaitForIdle();
41964197

src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Input/Given_StandardUICommand.cs

Lines changed: 47 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Linq;
12
using System.Threading.Tasks;
23
using System.Windows.Input;
34
using Private.Infrastructure;
@@ -90,123 +91,131 @@ public void When_Child_Command_CanExecute()
9091
"Cut",
9192
"Remove the selected content and put it on the clipboard",
9293
Symbol.Cut,
93-
VirtualKey.X,
94-
VirtualKeyModifiers.Control)]
94+
VirtualKey.X)]
9595
[DataRow(
9696
StandardUICommandKind.Copy,
9797
"Copy",
9898
"Copy the selected content to the clipboard",
9999
Symbol.Copy,
100-
VirtualKey.C,
101-
VirtualKeyModifiers.Control)]
100+
VirtualKey.C)]
102101
[DataRow(
103102
StandardUICommandKind.Paste,
104103
"Paste",
105104
"Insert the contents of the clipboard at the current location",
106105
Symbol.Paste,
107-
VirtualKey.V,
108-
VirtualKeyModifiers.Control)]
106+
VirtualKey.V)]
109107
[DataRow(
110108
StandardUICommandKind.SelectAll,
111109
"Select All",
112110
"Select all content",
113111
Symbol.SelectAll,
114-
VirtualKey.A,
115-
VirtualKeyModifiers.Control)]
112+
VirtualKey.A)]
116113
[DataRow(
117114
StandardUICommandKind.Delete,
118115
"Delete",
119116
"Delete the selected content",
120117
Symbol.Delete,
121-
VirtualKey.Delete,
122-
VirtualKeyModifiers.None)]
118+
VirtualKey.Delete)]
123119
[DataRow(
124120
StandardUICommandKind.Share,
125121
"Share",
126122
"Share the selected content",
127123
Symbol.Share,
128-
VirtualKey.None,
129-
VirtualKeyModifiers.None)]
124+
VirtualKey.None)]
130125
[DataRow(
131126
StandardUICommandKind.Save,
132127
"Save",
133128
"Save your changes",
134129
Symbol.Save,
135-
VirtualKey.S,
136-
VirtualKeyModifiers.Control)]
130+
VirtualKey.S)]
137131
[DataRow(
138132
StandardUICommandKind.Open,
139133
"Open",
140134
"Open",
141135
Symbol.OpenFile,
142-
VirtualKey.O,
143-
VirtualKeyModifiers.Control)]
136+
VirtualKey.O)]
144137
[DataRow(
145138
StandardUICommandKind.Close,
146139
"Close",
147140
"Close",
148141
Symbol.Cancel,
149-
VirtualKey.W,
150-
VirtualKeyModifiers.Control)]
142+
VirtualKey.W)]
151143
[DataRow(
152144
StandardUICommandKind.Pause,
153145
"Pause",
154146
"Pause",
155147
Symbol.Pause,
156-
VirtualKey.None,
157-
VirtualKeyModifiers.None)]
148+
VirtualKey.None)]
158149
[DataRow(
159150
StandardUICommandKind.Play,
160151
"Play",
161152
"Play",
162153
Symbol.Play,
163-
VirtualKey.None,
164-
VirtualKeyModifiers.None)]
154+
VirtualKey.None)]
165155
[DataRow(
166156
StandardUICommandKind.Stop,
167157
"Stop",
168158
"Stop",
169159
Symbol.Stop,
170-
VirtualKey.None,
171-
VirtualKeyModifiers.None)]
160+
VirtualKey.None)]
172161
[DataRow(
173162
StandardUICommandKind.Forward,
174163
"Forward",
175164
"Go to the next item",
176165
Symbol.Forward,
177-
VirtualKey.None,
178-
VirtualKeyModifiers.None)]
166+
VirtualKey.None)]
179167
[DataRow(
180168
StandardUICommandKind.Backward,
181169
"Backward",
182170
"Go to the previous item",
183171
Symbol.Back,
184-
VirtualKey.None,
185-
VirtualKeyModifiers.None)]
172+
VirtualKey.None)]
186173
[DataRow(
187174
StandardUICommandKind.Undo,
188175
"Undo",
189176
"Reverse the most recent action",
190177
Symbol.Undo,
191-
VirtualKey.Z,
192-
VirtualKeyModifiers.Control)]
178+
VirtualKey.Z)]
193179
[DataRow(
194180
StandardUICommandKind.Redo,
195181
"Redo",
196182
"Repeat the most recently undone action",
197183
Symbol.Redo,
198-
VirtualKey.Y,
199-
VirtualKeyModifiers.Control)]
184+
VirtualKey.Y)]
200185
public void When_Predefined_StandardUICommand(
201186
StandardUICommandKind kind,
202187
string label,
203188
string description,
204189
Symbol symbol,
205-
VirtualKey virtualKey,
206-
VirtualKeyModifiers modifiers)
190+
VirtualKey virtualKey)
207191
{
208192
var SUT = new StandardUICommand(kind);
209-
AssertStandardUICommandProperties(SUT, label, description, symbol, virtualKey, modifiers);
193+
var expectedModifiers = GetExpectedModifierForKey(virtualKey);
194+
AssertStandardUICommandProperties(SUT, label, description, symbol, virtualKey, expectedModifiers);
195+
}
196+
197+
/// <summary>
198+
/// Gets the expected modifier key based on the virtual key and platform.
199+
/// On Apple platforms (macOS, iOS, Mac Catalyst) and WASM on Apple devices,
200+
/// uses VirtualKeyModifiers.Windows (which maps to the Command key).
201+
/// On other platforms, uses Control key.
202+
/// </summary>
203+
private static VirtualKeyModifiers GetExpectedModifierForKey(VirtualKey virtualKey)
204+
{
205+
// Commands that use platform-specific command modifier
206+
var commandKeys = new[] { VirtualKey.X, VirtualKey.C, VirtualKey.V, VirtualKey.A,
207+
VirtualKey.S, VirtualKey.O, VirtualKey.W, VirtualKey.Z, VirtualKey.Y };
208+
209+
if (!commandKeys.Contains(virtualKey))
210+
{
211+
return VirtualKeyModifiers.None;
212+
}
213+
214+
#if HAS_UNO_WINUI
215+
return Uno.UI.Helpers.DeviceTargetHelper.PlatformCommandModifier;
216+
#else
217+
return VirtualKeyModifiers.Control;
218+
#endif
210219
}
211220

212221
[TestMethod]
@@ -228,7 +237,7 @@ public void When_StandardUICommand_In_Xaml()
228237
"Copy the selected content to the clipboard",
229238
Symbol.Copy,
230239
VirtualKey.C,
231-
VirtualKeyModifiers.Control);
240+
GetExpectedModifierForKey(VirtualKey.C));
232241
}
233242

234243
[TestMethod]
@@ -252,7 +261,7 @@ public void When_Kind_Changes_Overrides_Default_Properties()
252261
"Copy the selected content to the clipboard",
253262
Symbol.Copy,
254263
VirtualKey.C,
255-
VirtualKeyModifiers.Control);
264+
GetExpectedModifierForKey(VirtualKey.C));
256265

257266
SUT.Kind = StandardUICommandKind.Cut;
258267

@@ -262,7 +271,7 @@ public void When_Kind_Changes_Overrides_Default_Properties()
262271
"Remove the selected content and put it on the clipboard",
263272
Symbol.Cut,
264273
VirtualKey.X,
265-
VirtualKeyModifiers.Control);
274+
GetExpectedModifierForKey(VirtualKey.X));
266275
}
267276

268277
[TestMethod]

src/Uno.UI/Helpers/DeviceTargetHelper.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
using System;
2+
using Windows.System;
23

34
namespace Uno.UI.Helpers;
45

56
internal static class DeviceTargetHelper
67
{
8+
private static readonly Lazy<bool> _usesAppleKeyboardLayout = new(() =>
9+
OperatingSystem.IsMacOS() || OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst() || OperatingSystem.IsTvOS() ||
10+
(OperatingSystem.IsBrowser() &&
11+
Uno.Foundation.WebAssemblyImports.EvalBool("/Mac|iPhone|iPad|iPod/.test(navigator?.platform ?? '')")));
12+
713
internal static bool IsNonDesktop() =>
814
OperatingSystem.IsBrowser() ||
915
IsMobile();
@@ -21,4 +27,19 @@ internal static bool IsUIKit() =>
2127
OperatingSystem.IsIOS() ||
2228
OperatingSystem.IsMacCatalyst() ||
2329
OperatingSystem.IsTvOS();
30+
31+
/// <summary>
32+
/// Returns true when the keyboard layout follows Apple conventions (using Command key).
33+
/// This covers native Apple platforms (macOS, iOS, Mac Catalyst, tvOS) and WebAssembly
34+
/// running in a browser on Apple devices (macOS, iPhone, iPad, iPod).
35+
/// </summary>
36+
internal static bool UsesAppleKeyboardLayout => _usesAppleKeyboardLayout.Value;
37+
38+
/// <summary>
39+
/// Gets the platform-appropriate modifier key for standard commands (Cut, Copy, Paste, etc.).
40+
/// Returns VirtualKeyModifiers.Windows (Command key) on Apple keyboards,
41+
/// VirtualKeyModifiers.Control on all others.
42+
/// </summary>
43+
internal static VirtualKeyModifiers PlatformCommandModifier =>
44+
UsesAppleKeyboardLayout ? VirtualKeyModifiers.Windows : VirtualKeyModifiers.Control;
2445
}

src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.skia.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ partial class TextBlock : FrameworkElement, IBlock, UnicodeText.IFontCacheUpdate
3434
private MenuFlyout? _contextMenu;
3535
private IDisposable? _selectionHighlightBrushChangedSubscription;
3636
private readonly Dictionary<ContextMenuItem, MenuFlyoutItem> _flyoutItems = new();
37-
private readonly VirtualKeyModifiers _platformCtrlKey = OperatingSystem.IsMacOS() ? VirtualKeyModifiers.Windows : VirtualKeyModifiers.Control;
37+
private readonly VirtualKeyModifiers _platformCtrlKey = Uno.UI.Helpers.DeviceTargetHelper.PlatformCommandModifier;
3838
private Size _lastInlinesArrangeWithPadding;
3939
private readonly Dictionary<TextHighlighter, IDisposable> _textHighlighterDisposables = new();
4040

0 commit comments

Comments
 (0)