Files
PowerToys/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs
2025-11-15 17:03:12 -06:00

727 lines
28 KiB
C#

// 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.ComponentModel;
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using ManagedCommon;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Events;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.Settings;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CommandPalette.Extensions;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media.Animation;
using Windows.UI.Core;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using VirtualKey = Windows.System.VirtualKey;
namespace Microsoft.CmdPal.UI.Pages;
/// <summary>
/// An empty page that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
IRecipient<NavigateBackMessage>,
IRecipient<OpenSettingsMessage>,
IRecipient<HotkeySummonMessage>,
IRecipient<ShowDetailsMessage>,
IRecipient<HideDetailsMessage>,
IRecipient<ClearSearchMessage>,
IRecipient<LaunchUriMessage>,
IRecipient<SettingsWindowClosedMessage>,
IRecipient<GoHomeMessage>,
IRecipient<GoBackMessage>,
IRecipient<ShowConfirmationMessage>,
IRecipient<ShowToastMessage>,
IRecipient<NavigateToPageMessage>,
INotifyPropertyChanged,
IDisposable
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
private readonly SlideNavigationTransitionInfo _slideRightTransition = new() { Effect = SlideNavigationTransitionEffect.FromRight };
private readonly SuppressNavigationTransitionInfo _noAnimation = new();
private readonly ToastWindow _toast = new();
private readonly SettingsModel _settings;
private readonly TopLevelCommandManager tlcManager;
private readonly CompositeFormat _pageNavigatedAnnouncement;
private SettingsWindow? _settingsWindow;
private CancellationTokenSource? _focusAfterLoadedCts;
private WeakReference<Page>? _lastNavigatedPageRef;
public ShellViewModel ViewModel { get; private set; }
public event PropertyChangedEventHandler? PropertyChanged;
public ShellPage(
ShellViewModel shellViewModel,
SettingsModel settingsModel,
TopLevelCommandManager topLevelCommandManager)
{
this.InitializeComponent();
ViewModel = shellViewModel;
_settings = settingsModel;
tlcManager = topLevelCommandManager;
// how we are doing navigation around
WeakReferenceMessenger.Default.Register<NavigateBackMessage>(this);
WeakReferenceMessenger.Default.Register<OpenSettingsMessage>(this);
WeakReferenceMessenger.Default.Register<HotkeySummonMessage>(this);
WeakReferenceMessenger.Default.Register<SettingsWindowClosedMessage>(this);
WeakReferenceMessenger.Default.Register<ShowDetailsMessage>(this);
WeakReferenceMessenger.Default.Register<HideDetailsMessage>(this);
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
WeakReferenceMessenger.Default.Register<LaunchUriMessage>(this);
WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<GoBackMessage>(this);
WeakReferenceMessenger.Default.Register<ShowConfirmationMessage>(this);
WeakReferenceMessenger.Default.Register<ShowToastMessage>(this);
WeakReferenceMessenger.Default.Register<NavigateToPageMessage>(this);
AddHandler(PreviewKeyDownEvent, new KeyEventHandler(ShellPage_OnPreviewKeyDown), true);
AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false);
AddHandler(PointerPressedEvent, new PointerEventHandler(ShellPage_OnPointerPressed), true);
RootFrame.Navigate(typeof(LoadingPage), new AsyncNavigationRequest(ViewModel, CancellationToken.None));
var pageAnnouncementFormat = ResourceLoaderInstance.GetString("ScreenReader_Announcement_NavigatedToPage0");
_pageNavigatedAnnouncement = CompositeFormat.Parse(pageAnnouncementFormat);
}
/// <summary>
/// Gets the default page animation, depending on the settings
/// </summary>
private NavigationTransitionInfo DefaultPageAnimation
{
get
{
return _settings.DisableAnimations ? _noAnimation : _slideRightTransition;
}
}
public void Receive(NavigateBackMessage message)
{
if (RootFrame.CanGoBack)
{
if (!message.FromBackspace ||
_settings.BackspaceGoesBack)
{
GoBack();
}
}
else
{
if (!message.FromBackspace)
{
// If we can't go back then we must be at the top and thus escape again should quit.
WeakReferenceMessenger.Default.Send<DismissMessage>();
PowerToysTelemetry.Log.WriteEvent(new CmdPalDismissedOnEsc());
}
}
}
public void Receive(NavigateToPageMessage message)
{
// TODO GH #526 This needs more better locking too
_ = _queue.TryEnqueue(() =>
{
// Also hide our details pane about here, if we had one
HideDetails();
// Navigate to the appropriate host page for that VM
RootFrame.Navigate(
message.Page switch
{
ListViewModel => typeof(ListPage),
ContentPageViewModel => typeof(ContentPage),
_ => throw new NotSupportedException(),
},
new AsyncNavigationRequest(message.Page, message.CancellationToken),
message.WithAnimation ? DefaultPageAnimation : _noAnimation);
PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth));
if (!ViewModel.IsNested)
{
// todo BODGY
RootFrame.BackStack.Clear();
}
});
}
public void Receive(ShowConfirmationMessage message)
{
DispatcherQueue.TryEnqueue(async () =>
{
try
{
await HandleConfirmArgsOnUiThread(message.Args);
}
catch (Exception ex)
{
Logger.LogError(ex.ToString());
}
});
}
public void Receive(ShowToastMessage message)
{
DispatcherQueue.TryEnqueue(() =>
{
_toast.ShowToast(message.Message);
});
}
// This gets called from the UI thread
private async Task HandleConfirmArgsOnUiThread(IConfirmationArgs? args)
{
if (args is null)
{
return;
}
ConfirmResultViewModel vm = new(args, new(ViewModel.CurrentPage));
var initializeDialogTask = Task.Run(() => { InitializeConfirmationDialog(vm); });
await initializeDialogTask;
var resourceLoader = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance.ResourceLoader;
var confirmText = resourceLoader.GetString("ConfirmationDialog_ConfirmButtonText");
var cancelText = resourceLoader.GetString("ConfirmationDialog_CancelButtonText");
var name = string.IsNullOrEmpty(vm.PrimaryCommand.Name) ? confirmText : vm.PrimaryCommand.Name;
ContentDialog dialog = new()
{
Title = vm.Title,
Content = vm.Description,
PrimaryButtonText = name,
CloseButtonText = cancelText,
XamlRoot = this.XamlRoot,
};
if (vm.IsPrimaryCommandCritical)
{
dialog.DefaultButton = ContentDialogButton.Close;
// TODO: Maybe we need to style the primary button to be red?
// dialog.PrimaryButtonStyle = new Style(typeof(Button))
// {
// Setters =
// {
// new Setter(Button.ForegroundProperty, new SolidColorBrush(Colors.Red)),
// new Setter(Button.BackgroundProperty, new SolidColorBrush(Colors.Red)),
// },
// };
}
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
var performMessage = new PerformCommandMessage(vm);
WeakReferenceMessenger.Default.Send(performMessage);
}
else
{
// cancel
}
}
private void InitializeConfirmationDialog(ConfirmResultViewModel vm)
{
vm.SafeInitializePropertiesSynchronous();
}
public void Receive(OpenSettingsMessage message)
{
_ = DispatcherQueue.TryEnqueue(() =>
{
OpenSettings();
});
}
public void OpenSettings()
{
if (_settingsWindow is null)
{
_settingsWindow = new SettingsWindow();
}
_settingsWindow.Activate();
_settingsWindow.BringToFront();
}
public void Receive(ShowDetailsMessage message)
{
if (ViewModel is not null &&
ViewModel.CurrentPage is not null)
{
if (ViewModel.CurrentPage.PageContext.TryGetTarget(out var pageContext))
{
Task.Factory.StartNew(
() =>
{
// TERRIBLE HACK TODO GH #245
// There's weird wacky bugs with debounce currently.
if (!ViewModel.IsDetailsVisible)
{
ViewModel.Details = message.Details;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage)));
ViewModel.IsDetailsVisible = true;
return;
}
// GH #322:
// For inexplicable reasons, if you try to change the details too fast,
// we'll explode. This seemingly only happens if you change the details
// while we're also scrolling a new list view item into view.
_debounceTimer.Debounce(
() =>
{
ViewModel.Details = message.Details;
// Trigger a re-evaluation of whether we have a hero image based on
// the current theme
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage)));
},
interval: TimeSpan.FromMilliseconds(50),
immediate: ViewModel.IsDetailsVisible == false);
ViewModel.IsDetailsVisible = true;
},
CancellationToken.None,
TaskCreationOptions.None,
pageContext.Scheduler);
}
}
}
public void Receive(HideDetailsMessage message) => HideDetails();
public void Receive(LaunchUriMessage message) => _ = global::Windows.System.Launcher.LaunchUriAsync(message.Uri);
private void HideDetails()
{
ViewModel.Details = null;
ViewModel.IsDetailsVisible = false;
}
public void Receive(ClearSearchMessage message) => SearchBox.ClearSearch();
public void Receive(HotkeySummonMessage message)
{
_ = DispatcherQueue.TryEnqueue(() => SummonOnUiThread(message));
}
public void Receive(SettingsWindowClosedMessage message) => _settingsWindow = null;
private void SummonOnUiThread(HotkeySummonMessage message)
{
var commandId = message.CommandId;
var isRoot = string.IsNullOrEmpty(commandId);
if (isRoot)
{
// If this is the hotkey for the root level, then always show us
WeakReferenceMessenger.Default.Send<ShowWindowMessage>(new(message.Hwnd));
// Depending on the settings, either
// * Go home, or
// * Select the search text (if we should remain open on this page)
if (_settings.HotkeyGoesHome)
{
GoHome(false);
}
else if (_settings.HighlightSearchOnActivate)
{
SearchBox.SelectSearch();
}
}
else
{
try
{
// For a hotkey bound to a command, first lookup the
// command from our list of top level commands.
var topLevelCommand = tlcManager.LookupCommand(commandId);
if (topLevelCommand is not null)
{
var command = topLevelCommand.CommandViewModel.Model.Unsafe;
var isPage = command is not IInvokableCommand;
// If the bound command is an invokable command, then
// we don't want to open the window at all - we want to
// just do it.
if (isPage)
{
// If we're here, then the bound command was a page
// of some kind. Let's pop the stack, show the window, and navigate to it.
GoHome(false);
WeakReferenceMessenger.Default.Send<ShowWindowMessage>(new(message.Hwnd));
}
var msg = topLevelCommand.GetPerformCommandMessage();
msg.WithAnimation = false;
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(msg);
// we can't necessarily SelectSearch() here, because when the page is loaded,
// we'll fetch the SearchText from the page itself, and that'll stomp the
// selection we start now.
// That's probably okay though.
}
}
catch
{
}
}
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
}
public void Receive(GoBackMessage message)
{
_ = DispatcherQueue.TryEnqueue(() => GoBack(message.WithAnimation, message.FocusSearch));
}
private void GoBack(bool withAnimation = true, bool focusSearch = true)
{
HideDetails();
ViewModel.CancelNavigation();
// Note: That we restore the VM state below in RootFrame_Navigated call back after this occurs.
// In the future, we may want to manage the back stack ourselves vs. relying on Frame
// We could replace Frame with a ContentPresenter, but then have to manage transition animations ourselves.
// However, then we have more fine-grained control on the back stack, managing the VM cache, and not
// having that all be a black box, though then we wouldn't cache the XAML page itself, but sometimes that is a drawback.
// However, we do a good job here, see ForwardStack.Clear below, and BackStack.Clear above about managing that.
if (withAnimation)
{
RootFrame.GoBack();
}
else
{
RootFrame.GoBack(_noAnimation);
}
// Don't store pages we're navigating away from in the Frame cache
// TODO: In the future we probably want a short cache (3-5?) of recent VMs in case the user re-navigates
// back to a recent page they visited (like the Pokedex) so we don't have to reload it from scratch.
// That'd be retrieved as we re-navigate in the PerformCommandMessage logic above
RootFrame.ForwardStack.Clear();
if (!RootFrame.CanGoBack)
{
ViewModel.GoHome();
}
if (focusSearch)
{
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
SearchBox.SelectSearch();
}
}
public void Receive(GoHomeMessage message)
{
_ = DispatcherQueue.TryEnqueue(() => GoHome(withAnimation: message.WithAnimation, focusSearch: message.FocusSearch));
}
private void GoHome(bool withAnimation = true, bool focusSearch = true)
{
while (RootFrame.CanGoBack)
{
// don't focus on each step, just at the end
GoBack(withAnimation, focusSearch: false);
}
// focus search box, even if we were already home
if (focusSearch)
{
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
SearchBox.SelectSearch();
}
}
private void BackButton_Clicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
private void RootFrame_Navigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
{
// This listens to the root frame to ensure that we also track the content's page VM as well that we passed as a parameter.
// This is currently used for both forward and backward navigation.
// As when we go back that we restore ourselves to the proper state within our VM
if (e.Parameter is AsyncNavigationRequest request)
{
if (request.NavigationToken.IsCancellationRequested && e.NavigationMode is not (Microsoft.UI.Xaml.Navigation.NavigationMode.Back or Microsoft.UI.Xaml.Navigation.NavigationMode.Forward))
{
return;
}
switch (request.TargetViewModel)
{
case PageViewModel pageViewModel:
ViewModel.CurrentPage = pageViewModel;
break;
case ShellViewModel:
// This one is an exception, for now (LoadingPage is tied to ShellViewModel,
// but ShellViewModel is not PageViewModel.
ViewModel.CurrentPage = ViewModel.NullPage;
break;
default:
ViewModel.CurrentPage = ViewModel.NullPage;
Logger.LogWarning($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(PageViewModel)}");
break;
}
}
else
{
Logger.LogWarning("Unrecognized target for shell navigation: " + e.Parameter);
}
if (e.Content is Page element)
{
_lastNavigatedPageRef = new WeakReference<Page>(element);
element.Loaded += FocusAfterLoaded;
}
}
private void FocusAfterLoaded(object sender, RoutedEventArgs e)
{
var page = (Page)sender;
page.Loaded -= FocusAfterLoaded;
// Only handle focus for the latest navigated page
if (_lastNavigatedPageRef is null || !_lastNavigatedPageRef.TryGetTarget(out var last) || !ReferenceEquals(page, last))
{
return;
}
// Cancel any previous pending focus work
_focusAfterLoadedCts?.Cancel();
_focusAfterLoadedCts?.Dispose();
_focusAfterLoadedCts = new CancellationTokenSource();
var token = _focusAfterLoadedCts.Token;
AnnounceNavigationToPage(page);
var shouldSearchBoxBeVisible = ViewModel.CurrentPage?.HasSearchBox ?? false;
if (shouldSearchBoxBeVisible || page is not ContentPage)
{
ViewModel.IsSearchBoxVisible = shouldSearchBoxBeVisible;
SearchBox.Focus(FocusState.Programmatic);
SearchBox.SelectSearch();
}
else
{
_ = Task.Run(
async () =>
{
if (token.IsCancellationRequested)
{
return;
}
try
{
await page.DispatcherQueue.EnqueueAsync(
async () =>
{
// I hate this so much, but it can take a while for the page to be ready to accept focus;
// focusing page with MarkdownTextBlock takes up to 5 attempts (* 100ms delay between attempts)
for (var i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested();
if (FocusManager.FindFirstFocusableElement(page) is FrameworkElement frameworkElement)
{
var set = frameworkElement.Focus(FocusState.Programmatic);
if (set)
{
break;
}
}
await Task.Delay(100, token);
}
token.ThrowIfCancellationRequested();
// Update the search box visibility based on the current page:
// - We do this here after navigation so the focus is not jumping around too much,
// it messes with screen readers if we do it too early
// - Since this should hide the search box on content pages, it's not a problem if we
// wait for the code above to finish trying to focus the content
ViewModel.IsSearchBoxVisible = ViewModel.CurrentPage?.HasSearchBox ?? false;
});
}
catch (OperationCanceledException)
{
// Swallow cancellation - another FocusAfterLoaded invocation superseded this one
}
catch (Exception ex)
{
Logger.LogError("Error during FocusAfterLoaded async focus work", ex);
}
},
token);
}
}
private void AnnounceNavigationToPage(Page page)
{
var pageTitle = page switch
{
ListPage listPage => listPage.ViewModel?.Title,
ContentPage contentPage => contentPage.ViewModel?.Title,
_ => null,
};
if (string.IsNullOrEmpty(pageTitle))
{
pageTitle = ResourceLoaderInstance.GetString("UntitledPageTitle");
}
var announcement = string.Format(CultureInfo.CurrentCulture, _pageNavigatedAnnouncement.Format, pageTitle);
UIHelper.AnnounceActionForAccessibility(RootFrame, announcement, "CommandPalettePageNavigatedTo");
}
/// <summary>
/// Gets a value indicating whether determines if the current Details have a HeroImage, given the theme
/// we're currently in. This needs to be evaluated in the view, because the
/// viewModel doesn't actually know what the current theme is.
/// </summary>
public bool HasHeroImage
{
get
{
var requestedTheme = ActualTheme;
var iconInfoVM = ViewModel.Details?.HeroImage;
return iconInfoVM?.HasIcon(requestedTheme == Microsoft.UI.Xaml.ElementTheme.Light) ?? false;
}
}
private void Command_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
if (sender is Button button && button.DataContext is CommandViewModel commandViewModel)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(commandViewModel.Model));
}
}
private static void ShellPage_OnPreviewKeyDown(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);
var onlyAlt = altPressed && !ctrlPressed && !shiftPressed && !winPressed;
var onlyCtrl = !altPressed && ctrlPressed && !shiftPressed && !winPressed;
switch (e.Key)
{
case VirtualKey.Left when onlyAlt: // Alt+Left arrow
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
e.Handled = true;
break;
case VirtualKey.Home when onlyAlt: // Alt+Home
WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(WithAnimation: false));
e.Handled = true;
break;
case (VirtualKey)188 when onlyCtrl: // Ctrl+,
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>(new());
e.Handled = true;
break;
default:
{
// The CommandBar is responsible for handling all the item keybindings,
// since the bound context item may need to then show another
// context menu
TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key);
WeakReferenceMessenger.Default.Send(msg);
e.Handled = msg.Handled;
break;
}
}
}
private static void ShellPage_OnKeyDown(object sender, KeyRoutedEventArgs e)
{
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
if (ctrlPressed && e.Key == VirtualKey.Enter)
{
// ctrl+enter
WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>();
e.Handled = true;
}
else if (e.Key == VirtualKey.Enter)
{
WeakReferenceMessenger.Default.Send<ActivateSelectedListItemMessage>();
e.Handled = true;
}
else if (ctrlPressed && e.Key == VirtualKey.K)
{
// ctrl+k
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom));
e.Handled = true;
}
else if (e.Key == VirtualKey.Escape)
{
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
e.Handled = true;
}
}
private void ShellPage_OnPointerPressed(object sender, PointerRoutedEventArgs e)
{
try
{
var ptr = e.Pointer;
if (ptr.PointerDeviceType == PointerDeviceType.Mouse)
{
var ptrPt = e.GetCurrentPoint(this);
if (ptrPt.Properties.IsXButton1Pressed)
{
WeakReferenceMessenger.Default.Send(new NavigateBackMessage());
}
}
}
catch (Exception ex)
{
Logger.LogError("Error handling mouse button press event", ex);
}
}
public void Dispose()
{
_focusAfterLoadedCts?.Cancel();
_focusAfterLoadedCts?.Dispose();
_focusAfterLoadedCts = null;
}
}