/ src / modules / cmdpal / Core / Microsoft.CmdPal.Core.ViewModels / ContextMenuViewModel.cs
ContextMenuViewModel.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 System.Collections.ObjectModel;
  6  using CommunityToolkit.Mvvm.ComponentModel;
  7  using CommunityToolkit.Mvvm.Messaging;
  8  using Microsoft.CmdPal.Core.Common;
  9  using Microsoft.CmdPal.Core.ViewModels.Messages;
 10  using Microsoft.CommandPalette.Extensions;
 11  using Microsoft.CommandPalette.Extensions.Toolkit;
 12  using Windows.System;
 13  
 14  namespace Microsoft.CmdPal.Core.ViewModels;
 15  
 16  public partial class ContextMenuViewModel : ObservableObject,
 17      IRecipient<UpdateCommandBarMessage>
 18  {
 19      public ICommandBarContext? SelectedItem
 20      {
 21          get => field;
 22          set
 23          {
 24              field = value;
 25              UpdateContextItems();
 26          }
 27      }
 28  
 29      [ObservableProperty]
 30      private partial ObservableCollection<List<IContextItemViewModel>> ContextMenuStack { get; set; } = [];
 31  
 32      private List<IContextItemViewModel>? CurrentContextMenu => ContextMenuStack.LastOrDefault();
 33  
 34      [ObservableProperty]
 35      public partial ObservableCollection<IContextItemViewModel> FilteredItems { get; set; } = [];
 36  
 37      [ObservableProperty]
 38      public partial bool FilterOnTop { get; set; } = false;
 39  
 40      private string _lastSearchText = string.Empty;
 41  
 42      public ContextMenuViewModel()
 43      {
 44          WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
 45      }
 46  
 47      public void Receive(UpdateCommandBarMessage message)
 48      {
 49          SelectedItem = message.ViewModel;
 50      }
 51  
 52      public void UpdateContextItems()
 53      {
 54          if (SelectedItem is not null)
 55          {
 56              if (SelectedItem.PrimaryCommand is not null || SelectedItem.HasMoreCommands)
 57              {
 58                  ContextMenuStack.Clear();
 59                  PushContextStack(SelectedItem.AllCommands);
 60              }
 61          }
 62      }
 63  
 64      public void SetSearchText(string searchText)
 65      {
 66          if (searchText == _lastSearchText)
 67          {
 68              return;
 69          }
 70  
 71          if (SelectedItem is null)
 72          {
 73              return;
 74          }
 75  
 76          _lastSearchText = searchText;
 77  
 78          if (CurrentContextMenu is null)
 79          {
 80              ListHelpers.InPlaceUpdateList(FilteredItems, []);
 81              return;
 82          }
 83  
 84          if (string.IsNullOrEmpty(searchText))
 85          {
 86              ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu);
 87              return;
 88          }
 89  
 90          var commands = CurrentContextMenu
 91                              .OfType<CommandContextItemViewModel>()
 92                              .Where(c => c.ShouldBeVisible);
 93  
 94          var newResults = ListHelpers.FilterList<CommandContextItemViewModel>(commands, searchText, ScoreContextCommand);
 95          ListHelpers.InPlaceUpdateList(FilteredItems, newResults);
 96      }
 97  
 98      private static int ScoreContextCommand(string query, CommandContextItemViewModel item)
 99      {
100          if (string.IsNullOrEmpty(query) || string.IsNullOrWhiteSpace(query))
101          {
102              return 1;
103          }
104  
105          if (string.IsNullOrEmpty(item.Title))
106          {
107              return 0;
108          }
109  
110          var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Title);
111  
112          var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Subtitle);
113  
114          return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max();
115      }
116  
117      /// <summary>
118      /// Generates a mapping of key -> command item for this particular item's
119      /// MoreCommands. (This won't include the primary Command, but it will
120      /// include the secondary one). This map can be used to quickly check if a
121      /// shortcut key was pressed. In case there are duplicate keybindings, the first
122      /// one is used and the rest are ignored.
123      /// </summary>
124      /// <returns>a dictionary of KeyChord -> Context commands, for all commands
125      /// that have a shortcut key set.</returns>
126      private Dictionary<KeyChord, CommandContextItemViewModel> Keybindings()
127      {
128          var result = new Dictionary<KeyChord, CommandContextItemViewModel>();
129  
130          var menu = CurrentContextMenu;
131          if (menu is null)
132          {
133              return result;
134          }
135  
136          foreach (var item in menu)
137          {
138              if (item is CommandContextItemViewModel cmd && cmd.HasRequestedShortcut)
139              {
140                  var key = cmd.RequestedShortcut ?? new KeyChord(0, 0, 0);
141                  var added = result.TryAdd(key, cmd);
142                  if (!added)
143                  {
144                      CoreLogger.LogWarning($"Ignoring duplicate keyboard shortcut {KeyChordHelpers.FormatForDebug(key)} on command '{cmd.Title ?? cmd.Name ?? "(unknown)"}'");
145                  }
146              }
147          }
148  
149          return result;
150      }
151  
152      public ContextKeybindingResult? CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key)
153      {
154          var keybindings = Keybindings();
155  
156          // Does the pressed key match any of the keybindings?
157          var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0);
158          return keybindings.TryGetValue(pressedKeyChord, out var item) ? InvokeCommand(item) : null;
159      }
160  
161      public bool CanPopContextStack()
162      {
163          return ContextMenuStack.Count > 1;
164      }
165  
166      public void PopContextStack()
167      {
168          if (ContextMenuStack.Count > 1)
169          {
170              ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1);
171          }
172  
173          OnPropertyChanging(nameof(CurrentContextMenu));
174          OnPropertyChanged(nameof(CurrentContextMenu));
175  
176          ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu!);
177      }
178  
179      private void PushContextStack(IEnumerable<IContextItemViewModel> commands)
180      {
181          ContextMenuStack.Add(commands.ToList());
182          OnPropertyChanging(nameof(CurrentContextMenu));
183          OnPropertyChanged(nameof(CurrentContextMenu));
184  
185          ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu!);
186      }
187  
188      public void ResetContextMenu()
189      {
190          while (ContextMenuStack.Count > 1)
191          {
192              ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1);
193          }
194  
195          OnPropertyChanging(nameof(CurrentContextMenu));
196          OnPropertyChanged(nameof(CurrentContextMenu));
197  
198          if (CurrentContextMenu is not null)
199          {
200              ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu!);
201          }
202      }
203  
204      public ContextKeybindingResult InvokeCommand(CommandItemViewModel? command)
205      {
206          if (command is null)
207          {
208              return ContextKeybindingResult.Unhandled;
209          }
210  
211          if (command.HasMoreCommands)
212          {
213              // Display the commands child commands
214              PushContextStack(command.AllCommands);
215              OnPropertyChanging(nameof(FilteredItems));
216              OnPropertyChanged(nameof(FilteredItems));
217              return ContextKeybindingResult.KeepOpen;
218          }
219          else
220          {
221              WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(command.Command.Model, command.Model));
222              UpdateContextItems();
223              return ContextKeybindingResult.Hide;
224          }
225      }
226  }