/ src / modules / cmdpal / Microsoft.CmdPal.UI / Controls / ContextMenu.xaml.cs
ContextMenu.xaml.cs
  1  // Copyright (c) Microsoft Corporation
  2  // The Microsoft Corporation licenses this file to you under the MIT license.
  3  // See the LICENSE file in the project root for more information.
  4  
  5  using CommunityToolkit.Mvvm.Messaging;
  6  using CommunityToolkit.WinUI;
  7  using Microsoft.CmdPal.Core.ViewModels;
  8  using Microsoft.CmdPal.Core.ViewModels.Messages;
  9  using Microsoft.CmdPal.UI.Messages;
 10  using Microsoft.UI.Input;
 11  using Microsoft.UI.Xaml;
 12  using Microsoft.UI.Xaml.Controls;
 13  using Microsoft.UI.Xaml.Input;
 14  using Windows.System;
 15  using Windows.UI.Core;
 16  
 17  namespace Microsoft.CmdPal.UI.Controls;
 18  
 19  public sealed partial class ContextMenu : UserControl,
 20      IRecipient<OpenContextMenuMessage>,
 21      IRecipient<UpdateCommandBarMessage>,
 22      IRecipient<TryCommandKeybindingMessage>
 23  {
 24      public ContextMenuViewModel ViewModel { get; } = new();
 25  
 26      public ContextMenu()
 27      {
 28          this.InitializeComponent();
 29  
 30          // RegisterAll isn't AOT compatible
 31          WeakReferenceMessenger.Default.Register<OpenContextMenuMessage>(this);
 32          WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
 33          WeakReferenceMessenger.Default.Register<TryCommandKeybindingMessage>(this);
 34  
 35          if (ViewModel is not null)
 36          {
 37              ViewModel.PropertyChanged += ViewModel_PropertyChanged;
 38          }
 39      }
 40  
 41      public void Receive(OpenContextMenuMessage message)
 42      {
 43          ViewModel.FilterOnTop = message.ContextMenuFilterLocation == ContextMenuFilterLocation.Top;
 44          ViewModel.ResetContextMenu();
 45  
 46          UpdateUiForStackChange();
 47      }
 48  
 49      public void Receive(UpdateCommandBarMessage message)
 50      {
 51          UpdateUiForStackChange();
 52      }
 53  
 54      public void Receive(TryCommandKeybindingMessage msg)
 55      {
 56          var result = ViewModel?.CheckKeybinding(msg.Ctrl, msg.Alt, msg.Shift, msg.Win, msg.Key);
 57  
 58          if (result == ContextKeybindingResult.Hide)
 59          {
 60              msg.Handled = true;
 61              WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>();
 62              UpdateUiForStackChange();
 63          }
 64          else if (result == ContextKeybindingResult.KeepOpen)
 65          {
 66              UpdateUiForStackChange();
 67              msg.Handled = true;
 68          }
 69          else if (result == ContextKeybindingResult.Unhandled)
 70          {
 71              msg.Handled = false;
 72          }
 73      }
 74  
 75      private void CommandsDropdown_ItemClick(object sender, ItemClickEventArgs e)
 76      {
 77          if (e.ClickedItem is CommandContextItemViewModel item)
 78          {
 79              if (InvokeCommand(item) == ContextKeybindingResult.Hide)
 80              {
 81                  WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>();
 82              }
 83  
 84              UpdateUiForStackChange();
 85          }
 86      }
 87  
 88      private void CommandsDropdown_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
 89      {
 90          if (e.Handled)
 91          {
 92              return;
 93          }
 94  
 95          var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
 96          var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
 97          var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
 98          var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
 99              InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
100  
101          var result = ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key);
102  
103          if (result == ContextKeybindingResult.Hide)
104          {
105              e.Handled = true;
106              WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>();
107              UpdateUiForStackChange();
108          }
109          else if (result == ContextKeybindingResult.KeepOpen)
110          {
111              e.Handled = true;
112          }
113          else if (result == ContextKeybindingResult.Unhandled)
114          {
115              e.Handled = false;
116          }
117      }
118  
119      /// <summary>
120      /// Handles Escape to close the context menu and return focus to the "More" button.
121      /// </summary>
122      private void UserControl_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
123      {
124          if (e.Key == VirtualKey.Escape)
125          {
126              // Close the context menu (if not already handled)
127              WeakReferenceMessenger.Default.Send(new CloseContextMenuMessage());
128  
129              // Find the parent CommandBar and set focus to MoreCommandsButton
130              var parent = this.FindParent<CommandBar>();
131              parent?.FocusMoreCommandsButton();
132  
133              e.Handled = true;
134          }
135      }
136  
137      private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
138      {
139          var prop = e.PropertyName;
140  
141          if (prop == nameof(ContextMenuViewModel.FilteredItems))
142          {
143              UpdateUiForStackChange();
144          }
145      }
146  
147      private void ContextFilterBox_TextChanged(object sender, TextChangedEventArgs e)
148      {
149          ViewModel?.SetSearchText(ContextFilterBox.Text);
150  
151          if (CommandsDropdown.SelectedIndex == -1)
152          {
153              CommandsDropdown.SelectedIndex = 0;
154          }
155      }
156  
157      private void ContextFilterBox_KeyDown(object sender, KeyRoutedEventArgs e)
158      {
159          var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
160          var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
161          var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
162          var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
163              InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
164  
165          if (e.Key == VirtualKey.Enter)
166          {
167              if (CommandsDropdown.SelectedItem is CommandContextItemViewModel item)
168              {
169                  if (InvokeCommand(item) == ContextKeybindingResult.Hide)
170                  {
171                      WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>();
172                  }
173  
174                  UpdateUiForStackChange();
175  
176                  e.Handled = true;
177              }
178          }
179          else if (e.Key == VirtualKey.Escape ||
180              (e.Key == VirtualKey.Left && altPressed))
181          {
182              if (ViewModel.CanPopContextStack())
183              {
184                  ViewModel.PopContextStack();
185                  UpdateUiForStackChange();
186              }
187              else
188              {
189                  WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>();
190                  WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
191                  UpdateUiForStackChange();
192              }
193  
194              e.Handled = true;
195          }
196      }
197  
198      private void ContextFilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
199      {
200          if (e.Key == VirtualKey.Up)
201          {
202              NavigateUp();
203  
204              e.Handled = true;
205          }
206          else if (e.Key == VirtualKey.Down)
207          {
208              NavigateDown();
209  
210              e.Handled = true;
211          }
212  
213          CommandsDropdown_PreviewKeyDown(sender, e);
214      }
215  
216      private void NavigateUp()
217      {
218          var newIndex = CommandsDropdown.SelectedIndex;
219  
220          if (CommandsDropdown.SelectedIndex > 0)
221          {
222              newIndex--;
223  
224              while (
225                  newIndex >= 0 &&
226                  IsSeparator(CommandsDropdown.Items[newIndex]) &&
227                  newIndex != CommandsDropdown.SelectedIndex)
228              {
229                  newIndex--;
230              }
231  
232              if (newIndex < 0)
233              {
234                  newIndex = CommandsDropdown.Items.Count - 1;
235  
236                  while (
237                      newIndex >= 0 &&
238                      IsSeparator(CommandsDropdown.Items[newIndex]) &&
239                      newIndex != CommandsDropdown.SelectedIndex)
240                  {
241                      newIndex--;
242                  }
243              }
244          }
245          else
246          {
247              newIndex = CommandsDropdown.Items.Count - 1;
248          }
249  
250          CommandsDropdown.SelectedIndex = newIndex;
251      }
252  
253      private void NavigateDown()
254      {
255          var newIndex = CommandsDropdown.SelectedIndex;
256  
257          if (CommandsDropdown.SelectedIndex == CommandsDropdown.Items.Count - 1)
258          {
259              newIndex = 0;
260          }
261          else
262          {
263              newIndex++;
264  
265              while (
266                  newIndex < CommandsDropdown.Items.Count &&
267                  IsSeparator(CommandsDropdown.Items[newIndex]) &&
268                  newIndex != CommandsDropdown.SelectedIndex)
269              {
270                  newIndex++;
271              }
272  
273              if (newIndex >= CommandsDropdown.Items.Count)
274              {
275                  newIndex = 0;
276  
277                  while (
278                      newIndex < CommandsDropdown.Items.Count &&
279                      IsSeparator(CommandsDropdown.Items[newIndex]) &&
280                      newIndex != CommandsDropdown.SelectedIndex)
281                  {
282                      newIndex++;
283                  }
284              }
285          }
286  
287          CommandsDropdown.SelectedIndex = newIndex;
288      }
289  
290      private bool IsSeparator(object item)
291      {
292          return item is SeparatorViewModel;
293      }
294  
295      private void UpdateUiForStackChange()
296      {
297          ContextFilterBox.Text = string.Empty;
298          ViewModel?.SetSearchText(string.Empty);
299          CommandsDropdown.SelectedIndex = 0;
300      }
301  
302      /// <summary>
303      /// Manually focuses our search box. This needs to be called after we're actually
304      /// In the UI tree - if we're in a Flyout, that's not until Opened()
305      /// </summary>
306      internal void FocusSearchBox()
307      {
308          ContextFilterBox.Focus(FocusState.Programmatic);
309      }
310  
311      private ContextKeybindingResult InvokeCommand(CommandItemViewModel command) => ViewModel.InvokeCommand(command);
312  }