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 }