diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs index bc07fca640..68da30a006 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs @@ -5,12 +5,13 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Core.ViewModels; [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] -public partial class CommandContextItemViewModel(ICommandContextItem contextItem, WeakReference context) : CommandItemViewModel(new(contextItem), context), IContextItemViewModel +public partial class CommandContextItemViewModel(ICommandContextItem contextItem, WeakReference context) : + CommandItemViewModel(new(contextItem), context), + ICommandContextItemViewModel { private readonly KeyChord nullKeyChord = new(0, 0, 0); @@ -45,3 +46,16 @@ public partial class CommandContextItemViewModel(ICommandContextItem contextItem contextItem.RequestedShortcut.ScanCode); } } + +public interface ICommandContextItemViewModel : IContextItemViewModel +{ + public string Title { get; } + + public IconInfoViewModel Icon { get; } + + public bool IsCritical { get; } + + public KeyChord? RequestedShortcut { get; } + + public bool HasRequestedShortcut { get; } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs index 09040f5c12..10a447b897 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs @@ -10,7 +10,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Core.ViewModels; -public partial class ListItemViewModel : CommandItemViewModel, IContextItemViewModel +public partial class ListItemViewModel : CommandItemViewModel, ICommandContextItemViewModel { public new ExtensionObject Model { get; } @@ -61,6 +61,12 @@ public partial class ListItemViewModel : CommandItemViewModel, IContextItemViewM } } + bool ICommandContextItemViewModel.IsCritical => false; + + KeyChord? ICommandContextItemViewModel.RequestedShortcut => null; + + bool ICommandContextItemViewModel.HasRequestedShortcut => false; + public ListItemViewModel(IListItem model, WeakReference context) : base(new(model), context) { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs index 418d7d5196..c9dd4d7bb4 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs @@ -4,6 +4,7 @@ using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; +using Windows.Foundation; namespace Microsoft.CmdPal.Core.ViewModels.Messages; @@ -20,12 +21,28 @@ public record PerformCommandMessage public bool TransientPage { get; set; } + public bool OpenAsFlyout { get; private set; } + + public Point? FlyoutPosition { get; private set; } + public PerformCommandMessage(ExtensionObject command) { Command = command; Context = null; } + public static PerformCommandMessage CreateFlyoutMessage(ExtensionObject command, Point flyoutPosition) + { + var message = new PerformCommandMessage(command) + { + WithAnimation = false, + TransientPage = true, + OpenAsFlyout = true, + FlyoutPosition = flyoutPosition, + }; + return message; + } + public PerformCommandMessage(ExtensionObject command, ExtensionObject context) { Command = command; diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowCommandInContextMenuMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowCommandInContextMenuMessage.cs new file mode 100644 index 0000000000..8ddca87374 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowCommandInContextMenuMessage.cs @@ -0,0 +1,11 @@ +// 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.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +public record ShowCommandInContextMenuMessage(IContextMenuContext Context, Point Position); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs index 0b8fb52d44..fed47a0591 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs @@ -10,6 +10,7 @@ using Microsoft.CmdPal.Core.Common; using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; +using Windows.Foundation; namespace Microsoft.CmdPal.Core.ViewModels; @@ -136,6 +137,28 @@ public partial class ShellViewModel : ObservableObject, return true; } + private async Task ShowPageAsFlyoutAsync(PageViewModel viewModel, Point flyoutPosition, CancellationToken cancellationToken = default) + { + if (viewModel is not IContextMenuContext ctx) + { + return; + } + + // Instead of doing normal page navigation, we want to open this + // page as a flyout anchored to the given position. + var initialized = await InitializePageViewModelAsync(viewModel, cancellationToken); + if (initialized) + { + await SetCurrentPageAsync(viewModel, cancellationToken); + + // send message + WeakReferenceMessenger.Default.Send(new ShowCommandInContextMenuMessage(ctx, flyoutPosition)); + + //// now cleanup navigation + // await CleanupNavigationTokenAsync(cancellationToken); + } + } + private async Task LoadPageViewModelAsync(PageViewModel viewModel, CancellationToken cancellationToken = default) { var initialized = await InitializePageViewModelAsync(viewModel, cancellationToken); @@ -215,6 +238,15 @@ public partial class ShellViewModel : ObservableObject, _scheduler); } + private async Task CleanupNavigationTokenAsync(CancellationTokenSource cts) + { + // clean up the navigation token if it's still ours + if (Interlocked.CompareExchange(ref _navigationCts, null, cts) == cts) + { + cts.Dispose(); + } + } + public void Receive(PerformCommandMessage message) { PerformCommand(message); @@ -259,62 +291,7 @@ public partial class ShellViewModel : ObservableObject, if (command is IPage page) { CoreLogger.LogDebug($"Navigating to page"); - - var isMainPage = command == _rootPage; - - // Telemetry: Track extension page navigation for session metrics - if (host is not null) - { - var extensionId = host.GetExtensionDisplayName() ?? "builtin"; - var commandId = command?.Id ?? "unknown"; - var commandName = command?.Name ?? "unknown"; - WeakReferenceMessenger.Default.Send( - new(extensionId, commandId, commandName, true, 0)); - } - - // Construct our ViewModel of the appropriate type and pass it the UI Thread context. - var pageViewModel = _pageViewModelFactory.TryCreatePageViewModel(page, !isMainPage, host!); - if (pageViewModel is null) - { - CoreLogger.LogError($"Failed to create ViewModel for page {page.GetType().Name}"); - throw new NotSupportedException(); - } - - // ------------------------------------------------------------- - // Slice it here. - // Stuff above this, we need to always do, for both commands in the palette and flyout items - // - // Below here, this is all specific to navigating the current page of the palette - _isNested = !isMainPage; - _currentlyTransient = message.TransientPage; - - pageViewModel.IsRootPage = isMainPage; - pageViewModel.HasBackButton = IsNested; - - // Clear command bar, ViewModel initialization can already set new commands if it wants to - OnUIThread(() => WeakReferenceMessenger.Default.Send(new(null))); - - // Kick off async loading of our ViewModel - LoadPageViewModelAsync(pageViewModel, navigationToken) - .ContinueWith( - (Task t) => - { - // clean up the navigation token if it's still ours - if (Interlocked.CompareExchange(ref _navigationCts, null, newCts) == newCts) - { - newCts.Dispose(); - } - }, - navigationToken, - TaskContinuationOptions.None, - _scheduler); - - // While we're loading in the background, immediately move to the next page. - NavigateToPageMessage msg = new(pageViewModel, message.WithAnimation, navigationToken, message.TransientPage); - WeakReferenceMessenger.Default.Send(msg); - - // Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above - // See RootFrame_Navigated event handler. + StartOpenPage(message, page, host, newCts, navigationToken); } else if (command is IInvokableCommand invokable) { @@ -466,6 +443,71 @@ public partial class ShellViewModel : ObservableObject, } } + private void StartOpenPage(PerformCommandMessage message, IPage page, AppExtensionHost? host, CancellationTokenSource cts, CancellationToken navigationToken) + { + var isMainPage = page == _rootPage; + + // Telemetry: Track extension page navigation for session metrics + // TODO! this block is unsafe + if (host is not null) + { + var extensionId = host.GetExtensionDisplayName() ?? "builtin"; + var commandId = page.Id ?? "unknown"; + var commandName = page.Name ?? "unknown"; + WeakReferenceMessenger.Default.Send( + new(extensionId, commandId, commandName, true, 0)); + } + + // Construct our ViewModel of the appropriate type and pass it the UI Thread context. + var pageViewModel = _pageViewModelFactory.TryCreatePageViewModel(page, !isMainPage, host!); + if (pageViewModel is null) + { + CoreLogger.LogError($"Failed to create ViewModel for page {page.GetType().Name}"); + throw new NotSupportedException(); + } + + if (pageViewModel is ListViewModel listViewModel + && message.OpenAsFlyout + && message.FlyoutPosition is Point flyoutPosition) + { + ShowPageAsFlyoutAsync(listViewModel, flyoutPosition, navigationToken) + .ContinueWith( + (Task t) => CleanupNavigationTokenAsync(cts), + navigationToken, + TaskContinuationOptions.None, + _scheduler); + } + + // ------------------------------------------------------------- + // Slice it here. + // Stuff above this, we need to always do, for both commands in the palette and flyout items + // + // Below here, this is all specific to navigating the current page of the palette + _isNested = !isMainPage; + _currentlyTransient = message.TransientPage; + + pageViewModel.IsRootPage = isMainPage; + pageViewModel.HasBackButton = IsNested; + + // Clear command bar, ViewModel initialization can already set new commands if it wants to + OnUIThread(() => WeakReferenceMessenger.Default.Send(new(null))); + + // Kick off async loading of our ViewModel + LoadPageViewModelAsync(pageViewModel, navigationToken) + .ContinueWith( + (Task t) => CleanupNavigationTokenAsync(cts), + navigationToken, + TaskContinuationOptions.None, + _scheduler); + + // While we're loading in the background, immediately move to the next page. + NavigateToPageMessage msg = new(pageViewModel, message.WithAnimation, navigationToken, message.TransientPage); + WeakReferenceMessenger.Default.Send(msg); + + // Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above + // See RootFrame_Navigated event handler. + } + public void GoHome(bool withAnimation = true, bool focusSearch = true) { _rootPageService.GoHome(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml index 36167717ea..88563f09d9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml @@ -29,7 +29,7 @@ Separator="{StaticResource SeparatorContextMenuViewModelTemplate}" /> - + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml.cs index 145ddf0334..36ac26883d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml.cs @@ -11,7 +11,6 @@ using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Dock; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Settings; -using Microsoft.CommandPalette.Extensions; using Microsoft.UI; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -262,22 +261,19 @@ public sealed partial class DockControl : UserControl, IRecipient(new(pos)); - } + // var isPage = command.Model.Unsafe is not IInvokableCommand invokable; + // if (isPage) + // { + // WeakReferenceMessenger.Default.Send(new(pos)); + // } } catch (COMException e) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml index 32329e17a0..00f9c9a49d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml @@ -16,6 +16,29 @@ Closed="MainWindow_Closed" mc:Ignorable="d"> + + + + + + + + , IRecipient, IRecipient, + IRecipient, + IRecipient, IRecipient, IRecipient, IDisposable @@ -142,6 +144,8 @@ public sealed partial class MainWindow : WindowEx, WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -676,6 +680,41 @@ public sealed partial class MainWindow : WindowEx, _sessionErrorCount++; } + public void Receive(ShowCommandInContextMenuMessage message) + { + DispatcherQueue.TryEnqueue(() => + { + ContextMenuControl.ViewModel.SelectedItem = message.Context; + ContextMenuFlyout.ShouldConstrainToRootBounds = false; + ContextMenuFlyout.ShowMode = FlyoutShowMode.Standard; + ContextMenuFlyout.ShowAt(RootElement); + + // ContextMenuFlyout.ShowAt( + // RootElement, + // new FlyoutShowOptions() + // { + // ShowMode = FlyoutShowMode.Standard, + // Position = message.Position, + // }); + }); + } + + public void Receive(CloseContextMenuMessage message) + { + DispatcherQueue.TryEnqueue(() => + { + if (ContextMenuFlyout.IsOpen) + { + ContextMenuFlyout.Hide(); + } + }); + } + + private void ContextMenuFlyout_Opened(object sender, object e) + { + ContextMenuControl.FocusSearchBox(); + } + /// /// Ends the current telemetry session and emits the CmdPal_SessionDuration event. /// Aggregates all session metrics collected since ShowWindow and sends them to telemetry.