From 18b61ce9b749d9e307d298376ad3da6bf2c76254 Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Wed, 9 Jul 2025 14:50:07 -0500 Subject: [PATCH 1/2] CmdPal: Give FG back to the previous window (#40445) this is a port of ce15032 onto main, for just the FG change. When we cloak our window, we want to make sure to _manually_ give FG back to another window. Because apparently cloaked windows can have FG. beacause that makes sense :facepalm: Closes #39638 supersedes #40431 Co-authored-by: jiripolasek --- .../Microsoft.CmdPal.UI/MainWindow.xaml.cs | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index 2e30412416..ac54c7c1de 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -209,6 +209,9 @@ public sealed partial class MainWindow : WindowEx, { var hwnd = new HWND(hwndValue != 0 ? hwndValue : _hwnd); + // Make sure our HWND is cloaked before any possible window manipulations + Cloak(); + // Remember, IsIconic == "minimized", which is entirely different state // from "show/hide" // If we're currently minimized, restore us first, before we reveal @@ -222,16 +225,11 @@ public sealed partial class MainWindow : WindowEx, var display = GetScreen(hwnd, target); PositionCentered(display); + // Just to be sure, SHOW our hwnd. PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_SHOW); - // instead of showing the window, uncloak it from DWM - // This will make it visible to the user, without the animation or frames for - // loading XAML with composition - unsafe - { - BOOL value = false; - PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL)); - } + // Once we're done, uncloak to avoid all animations + Uncloak(); PInvoke.SetForegroundWindow(hwnd); PInvoke.SetActiveWindow(hwnd); @@ -290,7 +288,14 @@ public sealed partial class MainWindow : WindowEx, ShowHwnd(message.Hwnd, settings.SummonOn); } - public void Receive(HideWindowMessage message) => HideWindow(); + public void Receive(HideWindowMessage message) + { + // This might come in off the UI thread. Make sure to hop back. + DispatcherQueue.TryEnqueue(() => + { + HideWindow(); + }); + } public void Receive(QuitMessage message) => @@ -302,11 +307,25 @@ public sealed partial class MainWindow : WindowEx, private void HideWindow() { - // Hide our window + // Cloak our HWND to avoid all animations. + Cloak(); - // Instead of hiding the window, cloak it from DWM - // This will make it invisible to the user, such that we can show it again - // by uncloaking it, which avoids an unnecessary "flicker in" that XAML does + // Then hide our HWND, to make sure that the OS gives the FG / focus back to another app + // (there's no way for us to guess what the right hwnd might be, only the OS can do it right) + PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE); + + // TRICKY: show our HWND again. This will trick XAML into painting our + // HWND again, so that we avoid the "flicker" caused by a WinUI3 app + // window being first shown + // SW_SHOWNA will prevent us for trying to fight the focus back + PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNA); + + // Intentionally leave the window cloaked. So our window is "visible", + // but also cloaked, so you can't see it. + } + + private void Cloak() + { unsafe { BOOL value = true; @@ -314,6 +333,15 @@ public sealed partial class MainWindow : WindowEx, } } + private void Uncloak() + { + unsafe + { + BOOL value = false; + PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL)); + } + } + internal void MainWindow_Closed(object sender, WindowEventArgs args) { var serviceProvider = App.Current.Services; @@ -536,6 +564,7 @@ public sealed partial class MainWindow : WindowEx, PowerToysTelemetry.Log.WriteEvent(new CmdPalHotkeySummoned(isRootHotkey)); var isVisible = this.Visible; + unsafe { // We need to check if our window is cloaked or not. A cloaked window is still @@ -565,7 +594,9 @@ public sealed partial class MainWindow : WindowEx, { // ... then manually hide our window. When debugged, we won't get the cool cloaking, // but that's the price to pay for having the HWND not light-dismiss while we're debugging. - PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE); + Cloak(); + this.Hide(); + return; } From 100fca44689e8408e5675978ce8c47ce0ef6a8fc Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Wed, 9 Jul 2025 14:53:47 -0500 Subject: [PATCH 2/2] CmdPal: Refactoring ContextMenu adding separators, IsCritical styling, and right-click context menus for list items (#40189) Refactored ContextMenu into it's own control to allow displaying in CommandBar and in response to right click on list items. - Adds "critical" styling to context menu items flagged as `IsCritical`. This will use the theme to style with correct color. - Added `SeparatorContextItem` and modified `MoreCommands` to allow for both `CommandContextItem`s and `SeparatorContextItem`s. - Right clicking a list item with a context menu will open the context menu at the position of the click and position the filter box at the top of the context menu. ![image](https://github.com/user-attachments/assets/3bef6b04-28bb-4a17-b731-d9ed20c0566f) ![image](https://github.com/user-attachments/assets/37ed497c-6d98-4f04-8114-d9952127ca2e) This PR covers: - closes #38308 - closes #39211 - closes #38307 - closes #38261 --- .../CommandBarViewModel.cs | 66 ++--- .../CommandContextItemViewModel.cs | 4 +- .../CommandItemViewModel.cs | 68 +++-- .../ContentPageViewModel.cs | 84 ++++-- .../ContextMenuStackViewModel.cs | 82 ------ .../ContextMenuViewModel.cs | 267 ++++++++++++++++++ .../IContextItemViewModel.cs | 15 + .../Messages/CloseContextMenuMessage.cs | 12 + .../Messages/OpenContextMenuMessage.cs | 14 +- .../Messages/UpdateCommandBarMessage.cs | 5 +- .../SeparatorContextItemViewModel.cs | 12 + .../TopLevelViewModel.cs | 13 +- .../Controls/CommandBar.xaml | 120 ++------ .../Controls/CommandBar.xaml.cs | 218 +++----------- .../Controls/ContextMenu.xaml | 159 +++++++++++ .../Controls/ContextMenu.xaml.cs | 231 +++++++++++++++ .../Controls/SearchBar.xaml.cs | 2 +- .../Converters/ContextItemTemplateSelector.cs | 28 +- .../ExtViews/ListPage.xaml | 23 +- .../ExtViews/ListPage.xaml.cs | 27 ++ .../Pages/ShellPage.xaml.cs | 1 + .../Pages/SampleListPage.cs | 1 + .../SeparatorContextItem.cs | 9 + 23 files changed, 984 insertions(+), 477 deletions(-) delete mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/IContextItemViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/CloseContextMenuMessage.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SeparatorContextItemViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SeparatorContextItem.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs index 6fcd738e4e..e0112acaff 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs @@ -6,6 +6,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.System; namespace Microsoft.CmdPal.UI.ViewModels; @@ -48,11 +49,6 @@ public partial class CommandBarViewModel : ObservableObject, [ObservableProperty] public partial PageViewModel? CurrentPage { get; set; } - [ObservableProperty] - public partial ObservableCollection ContextMenuStack { get; set; } = []; - - public ContextMenuStackViewModel? ContextMenu => ContextMenuStack.LastOrDefault(); - public CommandBarViewModel() { WeakReferenceMessenger.Default.Register(this); @@ -101,18 +97,9 @@ public partial class CommandBarViewModel : ObservableObject, SecondaryCommand = SelectedItem.SecondaryCommand; - if (SelectedItem.MoreCommands.Count() > 1) - { - ShouldShowContextMenu = true; - - ContextMenuStack.Clear(); - ContextMenuStack.Add(new ContextMenuStackViewModel(SelectedItem)); - OnPropertyChanged(nameof(ContextMenu)); - } - else - { - ShouldShowContextMenu = false; - } + ShouldShowContextMenu = SelectedItem.MoreCommands + .OfType() + .Count() > 1; OnPropertyChanged(nameof(HasSecondaryCommand)); OnPropertyChanged(nameof(SecondaryCommand)); @@ -139,8 +126,18 @@ public partial class CommandBarViewModel : ObservableObject, public ContextKeybindingResult CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) { - var matchedItem = ContextMenu?.CheckKeybinding(ctrl, alt, shift, win, key); - return matchedItem != null ? PerformCommand(matchedItem) : ContextKeybindingResult.Unhandled; + var keybindings = SelectedItem?.Keybindings(); + if (keybindings != null) + { + // Does the pressed key match any of the keybindings? + var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0); + if (keybindings.TryGetValue(pressedKeyChord, out var matchedItem)) + { + return matchedItem != null ? PerformCommand(matchedItem) : ContextKeybindingResult.Unhandled; + } + } + + return ContextKeybindingResult.Unhandled; } private ContextKeybindingResult PerformCommand(CommandItemViewModel? command) @@ -152,10 +149,6 @@ public partial class CommandBarViewModel : ObservableObject, if (command.HasMoreCommands) { - ContextMenuStack.Add(new ContextMenuStackViewModel(command)); - OnPropertyChanging(nameof(ContextMenu)); - OnPropertyChanged(nameof(ContextMenu)); - WeakReferenceMessenger.Default.Send(new(command.Command.Model, command.Model)); return ContextKeybindingResult.KeepOpen; } else @@ -164,33 +157,6 @@ public partial class CommandBarViewModel : ObservableObject, return ContextKeybindingResult.Hide; } } - - public bool CanPopContextStack() - { - return ContextMenuStack.Count > 1; - } - - public void PopContextStack() - { - if (ContextMenuStack.Count > 1) - { - ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1); - } - - OnPropertyChanging(nameof(ContextMenu)); - OnPropertyChanged(nameof(ContextMenu)); - } - - public void ClearContextStack() - { - while (ContextMenuStack.Count > 1) - { - ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1); - } - - OnPropertyChanging(nameof(ContextMenu)); - OnPropertyChanged(nameof(ContextMenu)); - } } public enum ContextKeybindingResult diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs index dfbb1a982a..f1c35b1f50 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs @@ -2,12 +2,14 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.UI.ViewModels; -public partial class CommandContextItemViewModel(ICommandContextItem contextItem, WeakReference context) : CommandItemViewModel(new(contextItem), context) +public partial class CommandContextItemViewModel(ICommandContextItem contextItem, WeakReference context) : CommandItemViewModel(new(contextItem), context), IContextItemViewModel { private readonly KeyChord nullKeyChord = new(0, 0, 0); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs index e8e20974de..0602abeea2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs @@ -46,25 +46,27 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa public CommandViewModel Command { get; private set; } - public List MoreCommands { get; private set; } = []; + public List MoreCommands { get; private set; } = []; - IEnumerable IContextMenuContext.MoreCommands => MoreCommands; + IEnumerable IContextMenuContext.MoreCommands => MoreCommands; - public bool HasMoreCommands => MoreCommands.Count > 0; + private List ActualCommands => MoreCommands.OfType().ToList(); + + public bool HasMoreCommands => ActualCommands.Count > 0; public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty; public CommandItemViewModel? PrimaryCommand => this; - public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? MoreCommands[0] : null; + public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[0] : null; public bool ShouldBeVisible => !string.IsNullOrEmpty(Name); - public List AllCommands + public List AllCommands { get { - List l = _defaultCommandContextItem == null ? + List l = _defaultCommandContextItem == null ? new() : [_defaultCommandContextItem]; @@ -177,18 +179,29 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa if (more != null) { MoreCommands = more - .Where(contextItem => contextItem is ICommandContextItem) - .Select(contextItem => (contextItem as ICommandContextItem)!) - .Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext)) + .Select(item => + { + if (item is ICommandContextItem contextItem) + { + return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel; + } + else + { + return new SeparatorContextItemViewModel() as IContextItemViewModel; + } + }) .ToList(); } // Here, we're already theoretically in the async context, so we can // use Initialize straight up - MoreCommands.ForEach(contextItem => - { - contextItem.SlowInitializeProperties(); - }); + MoreCommands + .OfType() + .ToList() + .ForEach(contextItem => + { + contextItem.SlowInitializeProperties(); + }); if (!string.IsNullOrEmpty(model.Command?.Name)) { @@ -323,19 +336,30 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa if (more != null) { var newContextMenu = more - .Where(contextItem => contextItem is ICommandContextItem) - .Select(contextItem => (contextItem as ICommandContextItem)!) - .Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext)) + .Select(item => + { + if (item is CommandContextItem contextItem) + { + return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel; + } + else + { + return new SeparatorContextItemViewModel() as IContextItemViewModel; + } + }) .ToList(); lock (MoreCommands) { ListHelpers.InPlaceUpdateList(MoreCommands, newContextMenu); } - newContextMenu.ForEach(contextItem => - { - contextItem.InitializeProperties(); - }); + newContextMenu + .OfType() + .ToList() + .ForEach(contextItem => + { + contextItem.InitializeProperties(); + }); } else { @@ -376,7 +400,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa lock (MoreCommands) { - MoreCommands.ForEach(c => c.SafeCleanup()); + MoreCommands.OfType() + .ToList() + .ForEach(c => c.SafeCleanup()); MoreCommands.Clear(); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs index e7a3145e0a..0d37a81112 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs @@ -21,9 +21,9 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext [ObservableProperty] public partial ObservableCollection Content { get; set; } = []; - public List Commands { get; private set; } = []; + public List Commands { get; private set; } = []; - public bool HasCommands => Commands.Count > 0; + public bool HasCommands => ActualCommands.Count > 0; public DetailsViewModel? Details { get; private set; } @@ -31,18 +31,19 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext public bool HasDetails => Details != null; /////// ICommandBarContext /////// - public IEnumerable MoreCommands => Commands.Skip(1); + public IEnumerable MoreCommands => Commands.Skip(1); - public bool HasMoreCommands => Commands.Count > 1; + private List ActualCommands => Commands.OfType().ToList(); + + public bool HasMoreCommands => ActualCommands.Count > 1; public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty; - public CommandItemViewModel? PrimaryCommand => HasCommands ? Commands[0] : null; + public CommandItemViewModel? PrimaryCommand => HasCommands ? ActualCommands[0] : null; - public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? Commands[1] : null; + public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[1] : null; - public List AllCommands => Commands; - /////// /ICommandBarContext /////// + public List AllCommands => Commands; // Remember - "observable" properties from the model (via PropChanged) // cannot be marked [ObservableProperty] @@ -113,14 +114,27 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext } Commands = model.Commands - .Where(contextItem => contextItem is ICommandContextItem) - .Select(contextItem => (contextItem as ICommandContextItem)!) - .Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext)) - .ToList(); - Commands.ForEach(contextItem => - { - contextItem.InitializeProperties(); - }); + .ToList() + .Select(item => + { + if (item is CommandContextItem contextItem) + { + return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel; + } + else + { + return new SeparatorContextItemViewModel() as IContextItemViewModel; + } + }) + .ToList(); + + Commands + .OfType() + .ToList() + .ForEach(contextItem => + { + contextItem.InitializeProperties(); + }); var extensionDetails = model.Details; if (extensionDetails != null) @@ -159,19 +173,32 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext if (more != null) { var newContextMenu = more - .Where(contextItem => contextItem is ICommandContextItem) - .Select(contextItem => (contextItem as ICommandContextItem)!) - .Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext)) - .ToList(); + .ToList() + .Select(item => + { + if (item is CommandContextItem contextItem) + { + return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel; + } + else + { + return new SeparatorContextItemViewModel() as IContextItemViewModel; + } + }) + .ToList(); + lock (Commands) { ListHelpers.InPlaceUpdateList(Commands, newContextMenu); } - Commands.ForEach(contextItem => - { - contextItem.SlowInitializeProperties(); - }); + Commands + .OfType() + .ToList() + .ForEach(contextItem => + { + contextItem.SlowInitializeProperties(); + }); } else { @@ -246,10 +273,11 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext base.UnsafeCleanup(); Details?.SafeCleanup(); - foreach (var item in Commands) - { - item.SafeCleanup(); - } + + Commands + .OfType() + .ToList() + .ForEach(item => item.SafeCleanup()); Commands.Clear(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs deleted file mode 100644 index 2b16bd8f47..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using Microsoft.CmdPal.UI.ViewModels.Messages; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.System; - -namespace Microsoft.CmdPal.UI.ViewModels; - -public partial class ContextMenuStackViewModel : ObservableObject -{ - [ObservableProperty] - public partial ObservableCollection FilteredItems { get; set; } - - private readonly IContextMenuContext _context; - private string _lastSearchText = string.Empty; - - // private Dictionary? _contextKeybindings; - public ContextMenuStackViewModel(IContextMenuContext context) - { - _context = context; - FilteredItems = [.. context.AllCommands]; - } - - public void SetSearchText(string searchText) - { - if (searchText == _lastSearchText) - { - return; - } - - _lastSearchText = searchText; - - var commands = _context.AllCommands.Where(c => c.ShouldBeVisible); - if (string.IsNullOrEmpty(searchText)) - { - ListHelpers.InPlaceUpdateList(FilteredItems, commands); - return; - } - - var newResults = ListHelpers.FilterList(commands, searchText, ScoreContextCommand); - ListHelpers.InPlaceUpdateList(FilteredItems, newResults); - } - - private static int ScoreContextCommand(string query, CommandContextItemViewModel item) - { - if (string.IsNullOrEmpty(query) || string.IsNullOrWhiteSpace(query)) - { - return 1; - } - - if (string.IsNullOrEmpty(item.Title)) - { - return 0; - } - - var nameMatch = StringMatcher.FuzzySearch(query, item.Title); - - var descriptionMatch = StringMatcher.FuzzySearch(query, item.Subtitle); - - return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); - } - - public CommandContextItemViewModel? CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) - { - var keybindings = _context.Keybindings(); - if (keybindings != null) - { - // Does the pressed key match any of the keybindings? - var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0); - if (keybindings.TryGetValue(pressedKeyChord, out var item)) - { - return item; - } - } - - return null; - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuViewModel.cs new file mode 100644 index 0000000000..ed95768865 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuViewModel.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.Diagnostics.Utilities; +using Windows.System; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class ContextMenuViewModel : ObservableObject, + IRecipient, + IRecipient +{ + public ICommandBarContext? SelectedItem + { + get => field; + set + { + if (field != null) + { + field.PropertyChanged -= SelectedItemPropertyChanged; + } + + field = value; + SetSelectedItem(value); + + OnPropertyChanged(nameof(SelectedItem)); + } + } + + [ObservableProperty] + private partial ObservableCollection> ContextMenuStack { get; set; } = []; + + private List? CurrentContextMenu => ContextMenuStack.LastOrDefault(); + + [ObservableProperty] + public partial ObservableCollection FilteredItems { get; set; } = []; + + [ObservableProperty] + public partial bool FilterOnTop { get; set; } = false; + + private string _lastSearchText = string.Empty; + + public ContextMenuViewModel() + { + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + } + + public void Receive(UpdateCommandBarMessage message) + { + SelectedItem = message.ViewModel; + } + + public void Receive(OpenContextMenuMessage message) + { + FilterOnTop = message.ContextMenuFilterLocation == ContextMenuFilterLocation.Top; + + ResetContextMenu(); + + OnPropertyChanging(nameof(FilterOnTop)); + OnPropertyChanged(nameof(FilterOnTop)); + } + + private void SetSelectedItem(ICommandBarContext? value) + { + if (value != null) + { + value.PropertyChanged += SelectedItemPropertyChanged; + } + else + { + if (SelectedItem != null) + { + SelectedItem.PropertyChanged -= SelectedItemPropertyChanged; + } + } + + UpdateContextItems(); + } + + private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(SelectedItem.HasMoreCommands): + UpdateContextItems(); + break; + } + } + + public void UpdateContextItems() + { + if (SelectedItem != null) + { + if (SelectedItem.MoreCommands.Count() > 1) + { + ContextMenuStack.Clear(); + PushContextStack(SelectedItem.AllCommands); + } + } + } + + public void SetSearchText(string searchText) + { + if (searchText == _lastSearchText) + { + return; + } + + if (SelectedItem == null) + { + return; + } + + _lastSearchText = searchText; + + if (CurrentContextMenu == null) + { + ListHelpers.InPlaceUpdateList(FilteredItems, []); + return; + } + + if (string.IsNullOrEmpty(searchText)) + { + ListHelpers.InPlaceUpdateList(FilteredItems, [.. CurrentContextMenu]); + return; + } + + var commands = CurrentContextMenu + .OfType() + .Where(c => c.ShouldBeVisible); + + var newResults = ListHelpers.FilterList(commands, searchText, ScoreContextCommand); + ListHelpers.InPlaceUpdateList(FilteredItems, newResults); + } + + private static int ScoreContextCommand(string query, CommandContextItemViewModel item) + { + if (string.IsNullOrEmpty(query) || string.IsNullOrWhiteSpace(query)) + { + return 1; + } + + if (string.IsNullOrEmpty(item.Title)) + { + return 0; + } + + var nameMatch = StringMatcher.FuzzySearch(query, item.Title); + + var descriptionMatch = StringMatcher.FuzzySearch(query, item.Subtitle); + + return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + } + + /// + /// Generates a mapping of key -> command item for this particular item's + /// MoreCommands. (This won't include the primary Command, but it will + /// include the secondary one). This map can be used to quickly check if a + /// shortcut key was pressed + /// + /// a dictionary of KeyChord -> Context commands, for all commands + /// that have a shortcut key set. + public Dictionary Keybindings() + { + if (CurrentContextMenu == null) + { + return []; + } + + return CurrentContextMenu + .OfType() + .Where(c => c.HasRequestedShortcut) + .ToDictionary( + c => c.RequestedShortcut ?? new KeyChord(0, 0, 0), + c => c); + } + + public ContextKeybindingResult? CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) + { + var keybindings = Keybindings(); + if (keybindings != null) + { + // Does the pressed key match any of the keybindings? + var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0); + if (keybindings.TryGetValue(pressedKeyChord, out var item)) + { + return InvokeCommand(item); + } + } + + return null; + } + + public bool CanPopContextStack() + { + return ContextMenuStack.Count > 1; + } + + public void PopContextStack() + { + if (ContextMenuStack.Count > 1) + { + ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1); + } + + OnPropertyChanging(nameof(CurrentContextMenu)); + OnPropertyChanged(nameof(CurrentContextMenu)); + + ListHelpers.InPlaceUpdateList(FilteredItems, [.. CurrentContextMenu!]); + } + + private void PushContextStack(IEnumerable commands) + { + ContextMenuStack.Add(commands.ToList()); + OnPropertyChanging(nameof(CurrentContextMenu)); + OnPropertyChanged(nameof(CurrentContextMenu)); + + ListHelpers.InPlaceUpdateList(FilteredItems, [.. CurrentContextMenu!]); + } + + private void ResetContextMenu() + { + while (ContextMenuStack.Count > 1) + { + ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1); + } + + OnPropertyChanging(nameof(CurrentContextMenu)); + OnPropertyChanged(nameof(CurrentContextMenu)); + + if (CurrentContextMenu != null) + { + ListHelpers.InPlaceUpdateList(FilteredItems, [.. CurrentContextMenu!]); + } + } + + public ContextKeybindingResult InvokeCommand(CommandItemViewModel? command) + { + if (command == null) + { + return ContextKeybindingResult.Unhandled; + } + + if (command.HasMoreCommands) + { + // Display the commands child commands + PushContextStack(command.AllCommands); + OnPropertyChanging(nameof(FilteredItems)); + OnPropertyChanged(nameof(FilteredItems)); + return ContextKeybindingResult.KeepOpen; + } + else + { + WeakReferenceMessenger.Default.Send(new(command.Command.Model, command.Model)); + UpdateContextItems(); + return ContextKeybindingResult.Hide; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/IContextItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/IContextItemViewModel.cs new file mode 100644 index 0000000000..743687147c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/IContextItemViewModel.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public interface IContextItemViewModel +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/CloseContextMenuMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/CloseContextMenuMessage.cs new file mode 100644 index 0000000000..daa18498e7 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/CloseContextMenuMessage.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Messages; + +/// +/// Used to announce that a context menu should close +/// +public record CloseContextMenuMessage +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenContextMenuMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenContextMenuMessage.cs index c35ab284af..3cdcf72cee 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenContextMenuMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenContextMenuMessage.cs @@ -2,11 +2,21 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls.Primitives; +using Windows.Foundation; + namespace Microsoft.CmdPal.UI.ViewModels.Messages; /// -/// Used to perform a list item's secondary command when the user presses ctrl+enter in the search box +/// Used to announce the context menu should open /// -public record OpenContextMenuMessage +public record OpenContextMenuMessage(FrameworkElement? Element, FlyoutPlacementMode? FlyoutPlacementMode, Point? Point, ContextMenuFilterLocation ContextMenuFilterLocation) { } + +public enum ContextMenuFilterLocation +{ + Top, + Bottom, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs index 929b5995c5..9acec4bfe1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs @@ -16,11 +16,11 @@ public record UpdateCommandBarMessage(ICommandBarContext? ViewModel) public interface IContextMenuContext : INotifyPropertyChanged { - public IEnumerable MoreCommands { get; } + public IEnumerable MoreCommands { get; } public bool HasMoreCommands { get; } - public List AllCommands { get; } + public List AllCommands { get; } /// /// Generates a mapping of key -> command item for this particular item's @@ -33,6 +33,7 @@ public interface IContextMenuContext : INotifyPropertyChanged public Dictionary Keybindings() { return MoreCommands + .OfType() .Where(c => c.HasRequestedShortcut) .ToDictionary( c => c.RequestedShortcut ?? new KeyChord(0, 0, 0), diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SeparatorContextItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SeparatorContextItemViewModel.cs new file mode 100644 index 0000000000..7d700e3625 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SeparatorContextItemViewModel.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class SeparatorContextItemViewModel() : IContextItemViewModel, ISeparatorContextItem +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs index ccd95b90e3..82b5809e0b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs @@ -52,7 +52,18 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem ICommand? ICommandItem.Command => _commandItemViewModel.Command.Model.Unsafe; - IContextItem?[] ICommandItem.MoreCommands => _commandItemViewModel.MoreCommands.Select(i => i.Model.Unsafe).ToArray(); + IContextItem?[] ICommandItem.MoreCommands => _commandItemViewModel.MoreCommands + .Select(item => + { + if (item is ISeparatorContextItem) + { + return item as IContextItem; + } + else + { + return ((CommandContextItemViewModel)item).Model.Unsafe; + } + }).ToArray(); ////// IListItem ITag[] IListItem.Tags => Tags.ToArray(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml index 4a0ea50f46..1bbd0ab619 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml @@ -27,78 +27,28 @@ TrueValue="Collapsed" /> - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - - - - - - - + Visibility="{x:Bind ViewModel.ShouldShowContextMenu, Mode=OneWay}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs index f079f4b513..8adac8c048 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs @@ -18,6 +18,7 @@ namespace Microsoft.CmdPal.UI.Controls; public sealed partial class CommandBar : UserControl, IRecipient, + IRecipient, IRecipient, ICurrentPageAware { @@ -39,9 +40,8 @@ public sealed partial class CommandBar : UserControl, // RegisterAll isn't AOT compatible WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); - - ViewModel.PropertyChanged += ViewModel_PropertyChanged; } public void Receive(OpenContextMenuMessage message) @@ -51,12 +51,43 @@ public sealed partial class CommandBar : UserControl, return; } - var options = new FlyoutShowOptions + if (message.Element == null) { - ShowMode = FlyoutShowMode.Standard, - }; - MoreCommandsButton.Flyout.ShowAt(MoreCommandsButton, options); - UpdateUiForStackChange(); + _ = DispatcherQueue.TryEnqueue( + () => + { + ContextMenuFlyout.ShowAt( + MoreCommandsButton, + new FlyoutShowOptions() + { + ShowMode = FlyoutShowMode.Standard, + Placement = FlyoutPlacementMode.TopEdgeAlignedRight, + }); + }); + } + else + { + _ = DispatcherQueue.TryEnqueue( + () => + { + ContextMenuFlyout.ShowAt( + message.Element!, + new FlyoutShowOptions() + { + ShowMode = FlyoutShowMode.Standard, + Placement = (FlyoutPlacementMode)message.FlyoutPlacementMode!, + Position = message.Point, + }); + }); + } + } + + public void Receive(CloseContextMenuMessage message) + { + if (ContextMenuFlyout.IsOpen) + { + ContextMenuFlyout.Hide(); + } } public void Receive(TryCommandKeybindingMessage msg) @@ -74,17 +105,7 @@ public sealed partial class CommandBar : UserControl, } else if (result == ContextKeybindingResult.KeepOpen) { - if (!MoreCommandsButton.Flyout.IsOpen) - { - var options = new FlyoutShowOptions - { - ShowMode = FlyoutShowMode.Standard, - }; - MoreCommandsButton.Flyout.ShowAt(MoreCommandsButton, options); - } - - UpdateUiForStackChange(); - + WeakReferenceMessenger.Default.Send(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom)); msg.Handled = true; } else if (result == ContextKeybindingResult.Unhandled) @@ -121,164 +142,15 @@ public sealed partial class CommandBar : UserControl, e.Handled = true; } - private void CommandsDropdown_ItemClick(object sender, ItemClickEventArgs e) + private void MoreCommandsButton_Tapped(object sender, TappedRoutedEventArgs e) { - if (e.ClickedItem is CommandContextItemViewModel item) - { - if (ViewModel?.InvokeItem(item) == ContextKeybindingResult.Hide) - { - MoreCommandsButton.Flyout.Hide(); - } - else - { - UpdateUiForStackChange(); - } - } + WeakReferenceMessenger.Default.Send(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom)); } - private void CommandsDropdown_KeyDown(object sender, KeyRoutedEventArgs e) + private void ContextMenuFlyout_Opened(object sender, object e) { - if (e.Handled) - { - return; - } - - var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); - var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); - var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); - var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || - InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); - - var result = ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); - - if (result == ContextKeybindingResult.Hide) - { - e.Handled = true; - MoreCommandsButton.Flyout.Hide(); - WeakReferenceMessenger.Default.Send(); - } - else if (result == ContextKeybindingResult.KeepOpen) - { - e.Handled = true; - } - else if (result == ContextKeybindingResult.Unhandled) - { - e.Handled = false; - } - } - - private void Flyout_Opened(object sender, object e) - { - UpdateUiForStackChange(); - } - - private void Flyout_Closing(FlyoutBase sender, FlyoutBaseClosingEventArgs args) - { - ViewModel?.ClearContextStack(); - WeakReferenceMessenger.Default.Send(); - } - - private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) - { - var prop = e.PropertyName; - if (prop == nameof(ViewModel.ContextMenu)) - { - UpdateUiForStackChange(); - } - } - - private void ContextFilterBox_TextChanged(object sender, TextChangedEventArgs e) - { - ViewModel.ContextMenu?.SetSearchText(ContextFilterBox.Text); - - if (CommandsDropdown.SelectedIndex == -1) - { - CommandsDropdown.SelectedIndex = 0; - } - } - - private void ContextFilterBox_KeyDown(object sender, KeyRoutedEventArgs e) - { - var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); - var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); - var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); - var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || - InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); - - if (e.Key == VirtualKey.Enter) - { - if (CommandsDropdown.SelectedItem is CommandContextItemViewModel item) - { - if (ViewModel?.InvokeItem(item) == ContextKeybindingResult.Hide) - { - MoreCommandsButton.Flyout.Hide(); - WeakReferenceMessenger.Default.Send(); - } - else - { - UpdateUiForStackChange(); - } - - e.Handled = true; - } - } - else if (e.Key == VirtualKey.Escape || - (e.Key == VirtualKey.Left && altPressed)) - { - if (ViewModel.CanPopContextStack()) - { - ViewModel.PopContextStack(); - UpdateUiForStackChange(); - } - else - { - MoreCommandsButton.Flyout.Hide(); - WeakReferenceMessenger.Default.Send(); - } - - e.Handled = true; - } - - CommandsDropdown_KeyDown(sender, e); - } - - private void ContextFilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) - { - if (e.Key == VirtualKey.Up) - { - // navigate previous - if (CommandsDropdown.SelectedIndex > 0) - { - CommandsDropdown.SelectedIndex--; - } - else - { - CommandsDropdown.SelectedIndex = CommandsDropdown.Items.Count - 1; - } - - e.Handled = true; - } - else if (e.Key == VirtualKey.Down) - { - // navigate next - if (CommandsDropdown.SelectedIndex < CommandsDropdown.Items.Count - 1) - { - CommandsDropdown.SelectedIndex++; - } - else - { - CommandsDropdown.SelectedIndex = 0; - } - - e.Handled = true; - } - } - - private void UpdateUiForStackChange() - { - ContextFilterBox.Text = string.Empty; - ViewModel.ContextMenu?.SetSearchText(string.Empty); - CommandsDropdown.SelectedIndex = 0; - ContextFilterBox.Focus(FocusState.Programmatic); + // We need to wait until our flyout is opened to try and toss focus + // at its search box. The control isn't in the UI tree before that + ContextControl.FocusSearchBox(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml new file mode 100644 index 0000000000..ab8b00d6ad --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs new file mode 100644 index 0000000000..f08e8347c5 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using CommunityToolkit.WinUI; +using Microsoft.CmdPal.Ext.System; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.Views; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Input; +using Windows.System; +using Windows.UI.Core; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ContextMenu : UserControl, + IRecipient, + IRecipient, + IRecipient +{ + public ContextMenuViewModel ViewModel { get; } = new(); + + public ContextMenu() + { + this.InitializeComponent(); + + // RegisterAll isn't AOT compatible + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + + if (ViewModel != null) + { + ViewModel.PropertyChanged += ViewModel_PropertyChanged; + } + } + + public void Receive(OpenContextMenuMessage message) + { + UpdateUiForStackChange(); + } + + public void Receive(UpdateCommandBarMessage message) + { + UpdateUiForStackChange(); + } + + public void Receive(TryCommandKeybindingMessage msg) + { + var result = ViewModel?.CheckKeybinding(msg.Ctrl, msg.Alt, msg.Shift, msg.Win, msg.Key); + + if (result == ContextKeybindingResult.Hide) + { + msg.Handled = true; + WeakReferenceMessenger.Default.Send(); + UpdateUiForStackChange(); + } + else if (result == ContextKeybindingResult.KeepOpen) + { + UpdateUiForStackChange(); + msg.Handled = true; + } + else if (result == ContextKeybindingResult.Unhandled) + { + msg.Handled = false; + } + } + + private void CommandsDropdown_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is CommandContextItemViewModel item) + { + if (InvokeCommand(item) == ContextKeybindingResult.Hide) + { + WeakReferenceMessenger.Default.Send(); + } + + UpdateUiForStackChange(); + } + } + + private void CommandsDropdown_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Handled) + { + return; + } + + var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); + var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); + + var result = ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); + + if (result == ContextKeybindingResult.Hide) + { + e.Handled = true; + WeakReferenceMessenger.Default.Send(); + UpdateUiForStackChange(); + } + else if (result == ContextKeybindingResult.KeepOpen) + { + e.Handled = true; + } + else if (result == ContextKeybindingResult.Unhandled) + { + e.Handled = false; + } + } + + private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + var prop = e.PropertyName; + + if (prop == nameof(ContextMenuViewModel.FilteredItems)) + { + UpdateUiForStackChange(); + } + } + + private void ContextFilterBox_TextChanged(object sender, TextChangedEventArgs e) + { + ViewModel?.SetSearchText(ContextFilterBox.Text); + + if (CommandsDropdown.SelectedIndex == -1) + { + CommandsDropdown.SelectedIndex = 0; + } + } + + private void ContextFilterBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); + var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); + + if (e.Key == VirtualKey.Enter) + { + if (CommandsDropdown.SelectedItem is CommandContextItemViewModel item) + { + if (InvokeCommand(item) == ContextKeybindingResult.Hide) + { + WeakReferenceMessenger.Default.Send(); + } + + UpdateUiForStackChange(); + + e.Handled = true; + } + } + else if (e.Key == VirtualKey.Escape || + (e.Key == VirtualKey.Left && altPressed)) + { + if (ViewModel.CanPopContextStack()) + { + ViewModel.PopContextStack(); + UpdateUiForStackChange(); + } + else + { + WeakReferenceMessenger.Default.Send(); + WeakReferenceMessenger.Default.Send(); + UpdateUiForStackChange(); + } + + e.Handled = true; + } + + CommandsDropdown_KeyDown(sender, e); + } + + private void ContextFilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Up) + { + // navigate previous + if (CommandsDropdown.SelectedIndex > 0) + { + CommandsDropdown.SelectedIndex--; + } + else + { + CommandsDropdown.SelectedIndex = CommandsDropdown.Items.Count - 1; + } + + e.Handled = true; + } + else if (e.Key == VirtualKey.Down) + { + // navigate next + if (CommandsDropdown.SelectedIndex < CommandsDropdown.Items.Count - 1) + { + CommandsDropdown.SelectedIndex++; + } + else + { + CommandsDropdown.SelectedIndex = 0; + } + + e.Handled = true; + } + } + + private void UpdateUiForStackChange() + { + ContextFilterBox.Text = string.Empty; + ViewModel?.SetSearchText(string.Empty); + CommandsDropdown.SelectedIndex = 0; + } + + /// + /// Manually focuses our search box. This needs to be called after we're actually + /// In the UI tree - if we're in a Flyout, that's not until Opened() + /// + internal void FocusSearchBox() + { + ContextFilterBox.Focus(FocusState.Programmatic); + } + + private ContextKeybindingResult InvokeCommand(CommandItemViewModel command) => ViewModel.InvokeCommand(command); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index 262401710d..07fc055961 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -122,7 +122,7 @@ public sealed partial class SearchBar : UserControl, else if (ctrlPressed && e.Key == VirtualKey.K) { // ctrl+k - WeakReferenceMessenger.Default.Send(); + WeakReferenceMessenger.Default.Send(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom)); e.Handled = true; } else if (e.Key == VirtualKey.Right) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs index b9ff7c3439..a7ab4efe34 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs @@ -2,9 +2,12 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.Bot.AdaptiveExpressions.Core; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Data; namespace Microsoft.CmdPal.UI; @@ -14,8 +17,29 @@ internal sealed partial class ContextItemTemplateSelector : DataTemplateSelector public DataTemplate? Critical { get; set; } - protected override DataTemplate? SelectTemplateCore(object item) + public DataTemplate? Separator { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject) { - return ((CommandContextItemViewModel)item).IsCritical ? Critical : Default; + DataTemplate? dataTemplate = Default; + + if (dependencyObject is ListViewItem li) + { + li.IsEnabled = true; + + if (item is SeparatorContextItemViewModel) + { + li.IsEnabled = false; + li.AllowFocusWhenDisabled = false; + li.AllowFocusOnInteraction = false; + dataTemplate = Separator; + } + else + { + dataTemplate = ((CommandContextItemViewModel)item).IsCritical ? Critical : Default; + } + } + + return dataTemplate; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml index 215bd8476e..63f1e0e165 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml @@ -118,22 +118,23 @@ ItemClick="ItemsList_ItemClick" ItemTemplate="{StaticResource ListItemViewModelTemplate}" ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}" + RightTapped="ItemsList_RightTapped" SelectionChanged="ItemsList_SelectionChanged"> + + + + + + + + --> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs index f0aa7fbdb8..46721f77c5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs @@ -294,4 +294,31 @@ public sealed partial class ListPage : Page, return null; } + + private void ItemsList_RightTapped(object sender, RightTappedRoutedEventArgs e) + { + if (e.OriginalSource is FrameworkElement element && + element.DataContext is ListItemViewModel item) + { + if (ItemsList.SelectedItem != item) + { + ItemsList.SelectedItem = item; + } + + ViewModel?.UpdateSelectedItemCommand.Execute(item); + + var pos = e.GetPosition(element); + + _ = DispatcherQueue.TryEnqueue( + () => + { + WeakReferenceMessenger.Default.Send( + new OpenContextMenuMessage( + element, + Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft, + pos, + ContextMenuFilterLocation.Top)); + }); + } + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index 5890887db3..2b61acf467 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -17,6 +17,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Media.Animation; using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs index 3cf987e417..1777fb217c 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs @@ -81,6 +81,7 @@ internal sealed partial class SampleListPage : ListPage Title = "I'm a second command", RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), }, + new SeparatorContextItem(), new CommandContextItem( new ToastCommand("Third command invoked", MessageState.Error) { Name = "Do 3", Icon = new IconInfo("\uF148") }) // dial 3 { diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SeparatorContextItem.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SeparatorContextItem.cs new file mode 100644 index 0000000000..c851634f59 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SeparatorContextItem.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public partial class SeparatorContextItem : ISeparatorContextItem +{ +}