diff --git a/PowerToys.slnx b/PowerToys.slnx index c946514fb5..30dd1df814 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -356,6 +356,29 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/DismissMessage.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/DismissMessage.cs new file mode 100644 index 0000000000..693818ff54 --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/DismissMessage.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.UI.Models.Messages; + +public record DismissMessage() +{ +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/GoBackMessage.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/GoBackMessage.cs new file mode 100644 index 0000000000..c00ac64724 --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/GoBackMessage.cs @@ -0,0 +1,10 @@ +// 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.UI.Models.Messages; + +public record GoBackMessage(bool WithAnimation = true, bool FocusSearch = true) +{ + // TODO! sticking these properties here feels like leaking the UI into the models +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/GoHomeMessage.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/GoHomeMessage.cs new file mode 100644 index 0000000000..4f45f94595 --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/GoHomeMessage.cs @@ -0,0 +1,10 @@ +// 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.UI.Models.Messages; + +// TODO! sticking these properties here feels like leaking the UI into the models +public record GoHomeMessage(bool WithAnimation = true, bool FocusSearch = true) +{ +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/HideWindowMessage.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/HideWindowMessage.cs new file mode 100644 index 0000000000..b272d9b980 --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/HideWindowMessage.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.CommandPalette.UI.Models.Messages; + +/// +/// Message to request hiding the window. +/// +public partial record HideWindowMessage() +{ +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/HotkeySummonMessage.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/HotkeySummonMessage.cs index 798a6a562b..e753056633 100644 --- a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/HotkeySummonMessage.cs +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/HotkeySummonMessage.cs @@ -2,7 +2,7 @@ // 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.Models.Messages; +namespace Microsoft.CommandPalette.UI.Models.Messages; public record HotkeySummonMessage(string CommandId, IntPtr Hwnd) { diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/OpenSettingsMessage.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/OpenSettingsMessage.cs index a1c75af570..4ac2e267fa 100644 --- a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/OpenSettingsMessage.cs +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/OpenSettingsMessage.cs @@ -2,7 +2,7 @@ // 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.Models.Messages; +namespace Microsoft.CommandPalette.UI.Models.Messages; public record OpenSettingsMessage() { diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/QuitMessage.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/QuitMessage.cs index 8456e0dbdf..5e2c2820aa 100644 --- a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/QuitMessage.cs +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/QuitMessage.cs @@ -2,7 +2,7 @@ // 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.Models.Messages; +namespace Microsoft.CommandPalette.UI.Models.Messages; /// /// Message which closes the application. Used by via . diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/ReloadCommandsMessage.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/ReloadCommandsMessage.cs new file mode 100644 index 0000000000..b594398317 --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/ReloadCommandsMessage.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.UI.Models.Messages; + +public record ReloadCommandsMessage() +{ +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/ShowWindowMessage.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/ShowWindowMessage.cs new file mode 100644 index 0000000000..a9396d6247 --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Messages/ShowWindowMessage.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.UI.Models.Messages; + +public record ShowWindowMessage(IntPtr Hwnd) +{ +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Microsoft.CommandPalette.UI.Models.csproj b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Microsoft.CommandPalette.UI.Models.csproj index 3ea1e96d99..d3e3dac226 100644 --- a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Microsoft.CommandPalette.UI.Models.csproj +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.Models/Microsoft.CommandPalette.UI.Models.csproj @@ -25,6 +25,11 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + @@ -39,6 +44,7 @@ + diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.ViewModels/DevRibbonViewModel.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.ViewModels/DevRibbonViewModel.cs new file mode 100644 index 0000000000..7b914dfdd8 --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.ViewModels/DevRibbonViewModel.cs @@ -0,0 +1,190 @@ +// 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 System.Diagnostics; +using System.Globalization; +using System.Text.RegularExpressions; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ManagedCommon; +using Microsoft.CommandPalette.UI.ViewModels.Helpers; +using Microsoft.UI; +using Windows.System; +using Windows.UI; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; + +namespace Microsoft.CommandPalette.UI.ViewModels; + +public sealed partial class DevRibbonViewModel : ObservableObject +{ + private const int MaxLogEntries = 2; + private const string Release = "Release"; + private const string Debug = "Debug"; + + private static readonly Color ReleaseAotColor = ColorHelper.FromArgb(255, 124, 58, 237); + private static readonly Color ReleaseColor = ColorHelper.FromArgb(255, 51, 65, 85); + private static readonly Color DebugAotColor = ColorHelper.FromArgb(255, 99, 102, 241); + private static readonly Color DebugColor = ColorHelper.FromArgb(255, 107, 114, 128); + + private readonly DispatcherQueue _dispatcherQueue; + + public DevRibbonViewModel() + { + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + Trace.Listeners.Add(new DevRibbonTraceListener(this)); + + var configLabel = BuildConfiguration == Release ? "RLS" : "DBG"; /* #no-spell-check-line */ + var aotLabel = BuildInfo.IsNativeAot ? "⚡AOT" : "NO AOT"; + Tag = $"{configLabel} | {aotLabel}"; + + TagColor = (BuildConfiguration, BuildInfo.IsNativeAot) switch + { + (Release, true) => ReleaseAotColor, + (Release, false) => ReleaseColor, + (Debug, true) => DebugAotColor, + (Debug, false) => DebugColor, + _ => Colors.Fuchsia, + }; + } + + public string BuildConfiguration => BuildInfo.Configuration; + + public bool IsAotReleaseConfiguration => BuildConfiguration == Release && BuildInfo.IsNativeAot; + + public bool IsAot => BuildInfo.IsNativeAot; + + public bool IsPublishTrimmed => BuildInfo.PublishTrimmed; + + public ObservableCollection LatestLogs { get; } = []; + + [ObservableProperty] + public partial int WarningCount { get; private set; } + + [ObservableProperty] + public partial int ErrorCount { get; private set; } + + [ObservableProperty] + public partial string Tag { get; private set; } + + [ObservableProperty] + public partial Color TagColor { get; private set; } + + [RelayCommand] + private async Task OpenLogFileAsync() + { + var logPath = Logger.CurrentLogFile; + if (File.Exists(logPath)) + { + await Launcher.LaunchUriAsync(new Uri(logPath)); + } + } + + [RelayCommand] + private async Task OpenLogFolderAsync() + { + var logFolderPath = Logger.CurrentVersionLogDirectoryPath; + if (Directory.Exists(logFolderPath)) + { + await Launcher.LaunchFolderPathAsync(logFolderPath); + } + } + + [RelayCommand] + private void ResetErrorCounters() + { + WarningCount = 0; + ErrorCount = 0; + LatestLogs.Clear(); + } + + private sealed partial class DevRibbonTraceListener(DevRibbonViewModel viewModel) : TraceListener + { + private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff"; + + [GeneratedRegex(@"^\[(?.*?)\] \[(?.*?)\] (?.*)")] + private static partial Regex LogRegex(); + + private readonly Lock _lock = new(); + private LogEntryViewModel? _latestLogEntry; + + public override void Write(string? message) + { + // Not required for this scenario. + } + + public override void WriteLine(string? message) + { + if (message is null) + { + return; + } + + lock (_lock) + { + var match = LogRegex().Match(message); + if (match.Success) + { + var severity = match.Groups["severity"].Value; + var isWarning = severity.Equals("Warning", StringComparison.OrdinalIgnoreCase); + var isError = severity.Equals("Error", StringComparison.OrdinalIgnoreCase); + + if (isWarning || isError) + { + var timestampStr = match.Groups["timestamp"].Value; + var timestamp = DateTimeOffset.TryParseExact( + timestampStr, + TimestampFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out var parsed) + ? parsed + : DateTimeOffset.Now; + + var logEntry = new LogEntryViewModel( + timestamp, + severity, + match.Groups["message"].Value, + string.Empty); + + _latestLogEntry = logEntry; + + viewModel._dispatcherQueue.TryEnqueue(() => + { + if (isWarning) + { + viewModel.WarningCount++; + } + else + { + viewModel.ErrorCount++; + } + + viewModel.LatestLogs.Insert(0, logEntry); + + while (viewModel.LatestLogs.Count > MaxLogEntries) + { + viewModel.LatestLogs.RemoveAt(viewModel.LatestLogs.Count - 1); + } + }); + } + else + { + _latestLogEntry = null; + } + + return; + } + + if (IndentLevel > 0 && _latestLogEntry is { } latest) + { + viewModel._dispatcherQueue.TryEnqueue(() => + { + latest.AppendDetails(message); + }); + } + } + } + } +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.ViewModels/Helpers/BuildInfo.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.ViewModels/Helpers/BuildInfo.cs new file mode 100644 index 0000000000..4d7cd003e4 --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.ViewModels/Helpers/BuildInfo.cs @@ -0,0 +1,36 @@ +// 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.Reflection; +using System.Runtime.CompilerServices; + +namespace Microsoft.CommandPalette.UI.ViewModels.Helpers; + +public static class BuildInfo +{ +#if DEBUG + public const string Configuration = "Debug"; +#else + public const string Configuration = "Release"; +#endif + + // Runtime AOT detection + public static bool IsNativeAot => !RuntimeFeature.IsDynamicCodeSupported; + + // From assembly metadata (build-time values) + public static bool PublishTrimmed => GetBoolMetadata("PublishTrimmed", false); + + // From assembly metadata (build-time values) + public static bool PublishAot => GetBoolMetadata("PublishAot", false); + + public static bool IsCiBuild => GetBoolMetadata("CIBuild", false); + + private static string? GetMetadata(string key) => + Assembly.GetExecutingAssembly() + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == key)?.Value; + + private static bool GetBoolMetadata(string key, bool defaultValue) => + bool.TryParse(GetMetadata(key), out var result) ? result : defaultValue; +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI.ViewModels/LogEntryViewModel.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.ViewModels/LogEntryViewModel.cs new file mode 100644 index 0000000000..58d9a86396 --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI.ViewModels/LogEntryViewModel.cs @@ -0,0 +1,77 @@ +// 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.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Microsoft.CommandPalette.UI.ViewModels; + +public sealed partial class LogEntryViewModel : ObservableObject +{ + private const int HeaderMaxLength = 80; + private const string WarningGlyph = "\uE7BA"; + private const string ErrorGlyph = "\uEA39"; + private const string TimestampFormat = "HH:mm:ss"; + + private DateTimeOffset Timestamp { get; } + + private string Severity { get; } + + private string Message { get; } + + private string FormattedTimestamp { get; } + + public string SeverityGlyph { get; } + + [ObservableProperty] + public partial string Header { get; private set; } + + [ObservableProperty] + public partial string Description { get; private set; } + + [ObservableProperty] + public partial string Details { get; private set; } + + public LogEntryViewModel(DateTimeOffset timestamp, string severity, string message, string details) + { + Timestamp = timestamp; + Severity = severity; + Message = message; + Details = details; + + SeverityGlyph = severity.ToUpperInvariant() switch + { + "WARNING" => WarningGlyph, + "ERROR" => ErrorGlyph, + _ => string.Empty, + }; + + FormattedTimestamp = timestamp.ToString(TimestampFormat, CultureInfo.CurrentCulture); + Description = $"{FormattedTimestamp} • {Message}"; + Header = Message; + } + + public void AppendDetails(string? message) + { + if (string.IsNullOrEmpty(message)) + { + return; + } + + Details += Environment.NewLine + message; + + // Make header the second line of details (because that's actually the message itself): + var detailsLines = Details.Split([Environment.NewLine], StringSplitOptions.None); + if (detailsLines.Length < 2) + { + return; + } + + Header = detailsLines[1].Trim(); + if (Header.Length > HeaderMaxLength) + { + Header = Header[..(HeaderMaxLength - 1)] + "…"; + } + } +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Controls/DevRibbon.xaml b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Controls/DevRibbon.xaml new file mode 100644 index 0000000000..804732961a --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Controls/DevRibbon.xaml @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Controls/DevRibbon.xaml.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Controls/DevRibbon.xaml.cs new file mode 100644 index 0000000000..cba0e84e6b --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Controls/DevRibbon.xaml.cs @@ -0,0 +1,41 @@ +// 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.CommandPalette.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; + +namespace Microsoft.CommandPalette.UI.Controls; + +internal sealed partial class DevRibbon : UserControl +{ + public DevRibbonViewModel ViewModel { get; } + + public DevRibbon() + { + InitializeComponent(); + ViewModel = new DevRibbonViewModel(); + + if (FlyoutContent != null) + { + FlyoutContent.DataContext = ViewModel; + } + } + + private void DevRibbonButton_PointerEntered(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(this, "PointerOver", true); + } + + private void DevRibbonButton_PointerExited(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(this, "Normal", true); + } + + private Visibility VisibleIfGreaterThanZero(int value) + { + return value > 0 ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Helpers/LocalKeyboardListener.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Helpers/LocalKeyboardListener.cs new file mode 100644 index 0000000000..4bc83255a5 --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Helpers/LocalKeyboardListener.cs @@ -0,0 +1,157 @@ +// 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 ManagedCommon; + +using Windows.System; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Microsoft.CommandPalette.UI.Helpers; + +/// +/// A class that listens for local keyboard events using a Windows hook. +/// +internal sealed partial class LocalKeyboardListener : IDisposable +{ + /// + /// Event that is raised when a key is pressed down. + /// + public event EventHandler? KeyPressed; + + private bool _disposed; + private UnhookWindowsHookExSafeHandle? _handle; + private HOOKPROC? _hookProc; // Keep reference to prevent GC collection + + /// + /// Registers a global keyboard hook to listen for key down events. + /// + /// + /// Throws if the hook could not be registered, which may happen if the system is unable to set the hook. + /// + public void RegisterKeyboardHook() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_handle is not null && !_handle.IsInvalid) + { + // Hook is already set + return; + } + + _hookProc = KeyEventHook; + if (!SetWindowKeyHook(_hookProc)) + { + throw new InvalidOperationException("Failed to register keyboard hook."); + } + } + + /// + /// Attempts to register a global keyboard hook to listen for key down events. + /// + /// + /// if the keyboard hook was successfully registered; otherwise, . + /// + public bool Start() + { + if (_disposed) + { + return false; + } + + try + { + RegisterKeyboardHook(); + return true; + } + catch (Exception ex) + { + Logger.LogError("Failed to register hook", ex); + return false; + } + } + + private void UnregisterKeyboardHook() + { + if (_handle is not null && !_handle.IsInvalid) + { + // The SafeHandle should automatically call UnhookWindowsHookEx when disposed + _handle.Dispose(); + _handle = null; + } + + _hookProc = null; + } + + private bool SetWindowKeyHook(HOOKPROC hookProc) + { + if (_handle is not null && !_handle.IsInvalid) + { + // Hook is already set + return false; + } + + _handle = PInvoke.SetWindowsHookEx( + WINDOWS_HOOK_ID.WH_KEYBOARD, + hookProc, + PInvoke.GetModuleHandle(null), + PInvoke.GetCurrentThreadId()); + + // Check if the hook was successfully set + return _handle is not null && !_handle.IsInvalid; + } + + private static bool IsKeyDownHook(LPARAM lParam) + { + // The 30th bit tells what the previous key state is with 0 being the "UP" state + // For more info see https://learn.microsoft.com/windows/win32/winmsg/keyboardproc#lparam-in + return ((lParam.Value >> 30) & 1) == 0; + } + + private LRESULT KeyEventHook(int nCode, WPARAM wParam, LPARAM lParam) + { + try + { + if (nCode >= 0 && IsKeyDownHook(lParam)) + { + InvokeKeyDown((VirtualKey)wParam.Value); + } + } + catch (Exception ex) + { + Logger.LogError("Failed when invoking key down keyboard hook event", ex); + } + + // Call next hook in chain - pass null as first parameter for current hook + return PInvoke.CallNextHookEx(null, nCode, wParam, lParam); + } + + private void InvokeKeyDown(VirtualKey virtualKey) + { + if (!_disposed) + { + KeyPressed?.Invoke(this, new LocalKeyboardListenerKeyPressedEventArgs(virtualKey)); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + UnregisterKeyboardHook(); + } + + _disposed = true; + } + } +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Helpers/LocalKeyboardListenerKeyPressedEventArgs.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Helpers/LocalKeyboardListenerKeyPressedEventArgs.cs new file mode 100644 index 0000000000..d24ccd7bab --- /dev/null +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Helpers/LocalKeyboardListenerKeyPressedEventArgs.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 Windows.System; + +namespace Microsoft.CommandPalette.UI.Helpers; + +public class LocalKeyboardListenerKeyPressedEventArgs(VirtualKey key) : EventArgs +{ + public VirtualKey Key { get; } = key; +} diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI/MainWindow.xaml b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/MainWindow.xaml index be1f936653..628c10a000 100644 --- a/src/modules/Deux/UI/Microsoft.CommandPalette.UI/MainWindow.xaml +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/MainWindow.xaml @@ -10,7 +10,9 @@ Height="480" MinWidth="320" MinHeight="240" + Activated="MainWindow_Activated" + Closed="MainWindow_Closed" mc:Ignorable="d"> - - + + diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI/MainWindow.xaml.cs b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/MainWindow.xaml.cs index b71dda9e25..d3f1039f7f 100644 --- a/src/modules/Deux/UI/Microsoft.CommandPalette.UI/MainWindow.xaml.cs +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/MainWindow.xaml.cs @@ -2,30 +2,730 @@ // 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.Diagnostics; using System.Runtime.InteropServices; +using CommunityToolkit.Mvvm.Messaging; +using ManagedCommon; +using Microsoft.CmdPal.UI.Events; +using Microsoft.CommandPalette.UI.Controls; +using Microsoft.CommandPalette.UI.Helpers; +using Microsoft.CommandPalette.UI.Models; +using Microsoft.CommandPalette.UI.Models.Messages; using Microsoft.CommandPalette.UI.Pages; +using Microsoft.CommandPalette.UI.ViewModels.Helpers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.PowerToys.Telemetry; +using Microsoft.UI; +using Microsoft.UI.Composition; +using Microsoft.UI.Composition.SystemBackdrops; +using Microsoft.UI.Input; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; using Windows.ApplicationModel.Activation; +using Windows.Foundation; +using Windows.Graphics; +using Windows.System; +using Windows.UI; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Dwm; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.HiDpi; +using Windows.Win32.UI.Input.KeyboardAndMouse; +using Windows.Win32.UI.WindowsAndMessaging; using WinUIEx; +using RS_ = Microsoft.CommandPalette.UI.Helpers.ResourceLoaderInstance; namespace Microsoft.CommandPalette.UI; -/// -/// An empty window that can be used on its own or navigated to within a Frame. -/// public sealed partial class MainWindow : WindowEx { private readonly ILogger logger; private readonly ShellPage shellPage; + private const int DefaultWidth = 800; + private const int DefaultHeight = 480; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")] + private readonly uint WM_TASKBAR_RESTART; + private readonly HWND _hwnd; + private readonly DispatcherTimer _autoGoHomeTimer; + private readonly WNDPROC? _hotkeyWndProc; + private readonly WNDPROC? _originalWndProc; + + // private readonly List _hotkeys = []; + // private readonly KeyboardListener _keyboardListener; + // private readonly LocalKeyboardListener _localKeyboardListener; + private readonly HiddenOwnerWindowBehavior _hiddenOwnerBehavior = new(); + private bool _ignoreHotKeyWhenFullScreen = true; + + private DesktopAcrylicController? _acrylicController; + private SystemBackdropConfiguration? _configurationSource; + private TimeSpan _autoGoHomeInterval = Timeout.InfiniteTimeSpan; + + private WindowPosition _currentWindowPosition = new(); public MainWindow(ShellPage shellPage, ILogger logger) { - InitializeComponent(); this.logger = logger; this.shellPage = shellPage; - RootElement.Content = this.shellPage; + _autoGoHomeTimer = new DispatcherTimer(); + _autoGoHomeTimer.Tick += OnAutoGoHomeTimerOnTick; + + _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); + + // unsafe + // { + // CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value); + // } + _hiddenOwnerBehavior.ShowInTaskbar(this, Debugger.IsAttached); + + // _keyboardListener = new KeyboardListener(); + // _keyboardListener.Start(); + // _keyboardListener.SetProcessCommand(new CmdPalKeyboardService.ProcessCommand(HandleSummon)); + this.SetIcon(); + AppWindow.Title = RS_.GetString("AppName"); + RestoreWindowPosition(); + UpdateWindowPositionInMemory(); + + SetAcrylic(); + + // WeakReferenceMessenger.Default.Register(this); + // WeakReferenceMessenger.Default.Register(this); + // WeakReferenceMessenger.Default.Register(this); + // WeakReferenceMessenger.Default.Register(this); + // Hide our titlebar. + // We need to both ExtendsContentIntoTitleBar, then set the height to Collapsed + // to hide the old caption buttons. Then, in UpdateRegionsForCustomTitleBar, + // we'll make the top drag-able again. (after our content loads) + ExtendsContentIntoTitleBar = true; + AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed; + SizeChanged += WindowSizeChanged; + RootElement.Loaded += RootElementLoaded; + + WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); + + // LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a + // member (and instead like, use a local), then the pointer we marshal + // into the WindowLongPtr will be useless after we leave this function, + // and our **WindProc will explode**. + _hotkeyWndProc = HotKeyPrc; + var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc); + _originalWndProc = Marshal.GetDelegateForFunctionPointer(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer)); + + // Load our settings, and then also wire up a settings changed handler + HotReloadSettings(); + App.Current.Services.GetService()!.SettingsChanged += SettingsChangedHandler; + + // Make sure that we update the acrylic theme when the OS theme changes + RootElement.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateAcrylic); + + // Hardcoding event name to avoid bringing in the PowerToys.interop dependency. Event name must match CMDPAL_SHOW_EVENT from shared_constants.h + NativeEventWaiter.WaitForEventLoop("Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a", () => + { + Summon(string.Empty); + }); + + // _localKeyboardListener = new LocalKeyboardListener(); + // _localKeyboardListener.KeyPressed += LocalKeyboardListener_OnKeyPressed; + // _localKeyboardListener.Start(); + // Force window to be created, and then cloaked. This will offset initial animation when the window is shown. + HideWindow(); + } + + private void OnAutoGoHomeTimerOnTick(object? s, object e) + { + _autoGoHomeTimer.Stop(); + + // BEAR LOADING: Focus Search must be suppressed here; otherwise it may steal focus (for example, from the system tray icon) + // and prevent the user from opening its context menu. + WeakReferenceMessenger.Default.Send(new GoHomeMessage(WithAnimation: false, FocusSearch: false)); + } + + private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e) + { + if (e.Key == VirtualKey.GoBack) + { + WeakReferenceMessenger.Default.Send(new GoBackMessage()); + } + } + + private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(); + + private void RootElementLoaded(object sender, RoutedEventArgs e) + { + // Now that our content has loaded, we can update our draggable regions + UpdateRegionsForCustomTitleBar(); + + // Add dev ribbon if enabled + if (!BuildInfo.IsCiBuild) + { + RootElement.Children.Add(new DevRibbon { Margin = new Thickness(-1, -1, 120, -1) }); + } + } + + private void WindowSizeChanged(object sender, WindowSizeChangedEventArgs args) => UpdateRegionsForCustomTitleBar(); + + private void PositionCentered() + { + var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest); + PositionCentered(displayArea); + } + + private void RestoreWindowPosition() + { + var settings = App.Current.Services.GetService(); + if (settings?.LastWindowPosition is not WindowPosition savedPosition) + { + PositionCentered(); + return; + } + + if (savedPosition.Width <= 0 || savedPosition.Height <= 0) + { + PositionCentered(); + return; + } + + var newRect = EnsureWindowIsVisible(savedPosition.ToPhysicalWindowRectangle(), new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), savedPosition.Dpi); + AppWindow.MoveAndResize(newRect); + } + + private void PositionCentered(DisplayArea displayArea) + { + if (displayArea is not null) + { + var centeredPosition = AppWindow.Position; + centeredPosition.X = (displayArea.WorkArea.Width - AppWindow.Size.Width) / 2; + centeredPosition.Y = (displayArea.WorkArea.Height - AppWindow.Size.Height) / 2; + + centeredPosition.X += displayArea.WorkArea.X; + centeredPosition.Y += displayArea.WorkArea.Y; + AppWindow.Move(centeredPosition); + } + } + + private void UpdateWindowPositionInMemory() + { + var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary; + _currentWindowPosition = new WindowPosition + { + X = AppWindow.Position.X, + Y = AppWindow.Position.Y, + Width = AppWindow.Size.Width, + Height = AppWindow.Size.Height, + Dpi = (int)this.GetDpiForWindow(), + ScreenWidth = displayArea.WorkArea.Width, + ScreenHeight = displayArea.WorkArea.Height, + }; + } + + private void HotReloadSettings() + { + var settings = App.Current.Services.GetService()!; + + SetupHotkey(settings); + App.Current.Services.GetService()!.SetupTrayIcon(settings.ShowSystemTrayIcon); + + _ignoreHotKeyWhenFullScreen = settings.IgnoreShortcutWhenFullscreen; + + _autoGoHomeInterval = settings.AutoGoHomeInterval; + _autoGoHomeTimer.Interval = _autoGoHomeInterval; + } + + // We want to use DesktopAcrylicKind.Thin and custom colors as this is the default material + // other Shell surfaces are using, this cannot be set in XAML however. + private void SetAcrylic() + { + if (DesktopAcrylicController.IsSupported()) + { + // Hooking up the policy object. + _configurationSource = new SystemBackdropConfiguration + { + // Initial configuration state. + IsInputActive = true, + }; + UpdateAcrylic(); + } + } + + private void UpdateAcrylic() + { + if (_acrylicController != null) + { + _acrylicController.RemoveAllSystemBackdropTargets(); + _acrylicController.Dispose(); + } + + _acrylicController = GetAcrylicConfig(Content); + + // Enable the system backdrop. + // Note: Be sure to have "using WinRT;" to support the Window.As<...>() call. + _acrylicController.AddSystemBackdropTarget(this.As()); + _acrylicController.SetSystemBackdropConfiguration(_configurationSource); + } + + private static DesktopAcrylicController GetAcrylicConfig(UIElement content) + { + var feContent = content as FrameworkElement; + + return feContent?.ActualTheme == ElementTheme.Light + ? new DesktopAcrylicController() + { + Kind = DesktopAcrylicKind.Thin, + TintColor = Color.FromArgb(255, 243, 243, 243), + LuminosityOpacity = 0.90f, + TintOpacity = 0.0f, + FallbackColor = Color.FromArgb(255, 238, 238, 238), + } + : new DesktopAcrylicController() + { + Kind = DesktopAcrylicKind.Thin, + TintColor = Color.FromArgb(255, 32, 32, 32), + LuminosityOpacity = 0.96f, + TintOpacity = 0.5f, + FallbackColor = Color.FromArgb(255, 28, 28, 28), + }; + } + + private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target) + { + StopAutoGoHome(); + + var hwnd = new HWND(hwndValue != 0 ? hwndValue : _hwnd); + + // Remember, IsIconic == "minimized", which is entirely different state + // from "show/hide" + // If we're currently minimized, restore us first, before we reveal + // our window. Otherwise, we'd just be showing a minimized window - + // which would remain not visible to the user. + if (PInvoke.IsIconic(hwnd)) + { + // Make sure our HWND is cloaked before any possible window manipulations + Cloak(); + + PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_RESTORE); + } + + if (target == MonitorBehavior.ToLast) + { + var newRect = EnsureWindowIsVisible(_currentWindowPosition.ToPhysicalWindowRectangle(), new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight), _currentWindowPosition.Dpi); + AppWindow.MoveAndResize(newRect); + } + else + { + var display = GetScreen(hwnd, target); + PositionCentered(display); + } + + // Check if the debugger is attached. If it is, we don't want to apply the tool window style, + // because that would make it hard to debug the app + if (Debugger.IsAttached) + { + _hiddenOwnerBehavior.ShowInTaskbar(this, true); + } + + // Just to be sure, SHOW our hwnd. + PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_SHOW); + + // Once we're done, uncloak to avoid all animations + Uncloak(); + + PInvoke.SetForegroundWindow(hwnd); + PInvoke.SetActiveWindow(hwnd); + + // Push our window to the top of the Z-order and make it the topmost, so that it appears above all other windows. + // We want to remove the topmost status when we hide the window (because we cloak it instead of hiding it). + PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE); + } + + /// + /// Ensures that the window rectangle is visible on-screen. + /// + /// The window rectangle in physical pixels. + /// The desktop area the window was positioned on. + /// The window's original DPI. + /// + /// A window rectangle in physical pixels, moved to the nearest display and resized + /// if the DPI has changed. + /// + private static RectInt32 EnsureWindowIsVisible(RectInt32 windowRect, SizeInt32 originalScreen, int originalDpi) + { + var displayArea = DisplayArea.GetFromRect(windowRect, DisplayAreaFallback.Nearest); + if (displayArea is null) + { + return windowRect; + } + + var workArea = displayArea.WorkArea; + if (workArea.Width <= 0 || workArea.Height <= 0) + { + // Fallback, nothing reasonable to do + return windowRect; + } + + var effectiveDpi = GetEffectiveDpiFromDisplayId(displayArea); + if (originalDpi <= 0) + { + originalDpi = effectiveDpi; // use current DPI as baseline (no scaling adjustment needed) + } + + var hasInvalidSize = windowRect.Width <= 0 || windowRect.Height <= 0; + if (hasInvalidSize) + { + windowRect = new RectInt32(windowRect.X, windowRect.Y, DefaultWidth, DefaultHeight); + } + + // If we have a DPI change, scale the window rectangle accordingly + if (effectiveDpi != originalDpi) + { + var scalingFactor = effectiveDpi / (double)originalDpi; + windowRect = new RectInt32( + (int)Math.Round(windowRect.X * scalingFactor), + (int)Math.Round(windowRect.Y * scalingFactor), + (int)Math.Round(windowRect.Width * scalingFactor), + (int)Math.Round(windowRect.Height * scalingFactor)); + } + + var targetWidth = Math.Min(windowRect.Width, workArea.Width); + var targetHeight = Math.Min(windowRect.Height, workArea.Height); + + // Ensure at least some minimum visible area (e.g., 100 pixels) + // This helps prevent the window from being entirely offscreen, regardless of display scaling. + const int minimumVisibleSize = 100; + var isOffscreen = + windowRect.X + minimumVisibleSize > workArea.X + workArea.Width || + windowRect.X + windowRect.Width - minimumVisibleSize < workArea.X || + windowRect.Y + minimumVisibleSize > workArea.Y + workArea.Height || + windowRect.Y + windowRect.Height - minimumVisibleSize < workArea.Y; + + // if the work area size has changed, re-center the window + var workAreaSizeChanged = + originalScreen.Width != workArea.Width || + originalScreen.Height != workArea.Height; + + int targetX; + int targetY; + var recenter = isOffscreen || workAreaSizeChanged || hasInvalidSize; + if (recenter) + { + targetX = workArea.X + ((workArea.Width - targetWidth) / 2); + targetY = workArea.Y + ((workArea.Height - targetHeight) / 2); + } + else + { + targetX = windowRect.X; + targetY = windowRect.Y; + } + + return new RectInt32(targetX, targetY, targetWidth, targetHeight); + } + + private static int GetEffectiveDpiFromDisplayId(DisplayArea displayArea) + { + var effectiveDpi = 96; + + var hMonitor = (HMONITOR)Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId); + if (!hMonitor.IsNull) + { + var hr = PInvoke.GetDpiForMonitor(hMonitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out var dpiX, out _); + if (hr == 0) + { + effectiveDpi = (int)dpiX; + } + else + { + Logger.LogWarning($"GetDpiForMonitor failed with HRESULT: 0x{hr.Value:X8} on display {displayArea.DisplayId}"); + } + } + + if (effectiveDpi <= 0) + { + effectiveDpi = 96; + } + + return effectiveDpi; + } + + private DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target) + { + // Leaving a note here, in case we ever need it: + // https://github.com/microsoft/microsoft-ui-xaml/issues/6454 + // If we need to ever FindAll, we'll need to iterate manually + var displayAreas = Microsoft.UI.Windowing.DisplayArea.FindAll(); + switch (target) + { + case MonitorBehavior.InPlace: + if (PInvoke.GetWindowRect(currentHwnd, out var bounds)) + { + RectInt32 converted = new(bounds.X, bounds.Y, bounds.Width, bounds.Height); + return DisplayArea.GetFromRect(converted, DisplayAreaFallback.Nearest); + } + + break; + + case MonitorBehavior.ToFocusedWindow: + var foregroundWindowHandle = PInvoke.GetForegroundWindow(); + if (foregroundWindowHandle != IntPtr.Zero) + { + if (PInvoke.GetWindowRect(foregroundWindowHandle, out var fgBounds)) + { + RectInt32 converted = new(fgBounds.X, fgBounds.Y, fgBounds.Width, fgBounds.Height); + return DisplayArea.GetFromRect(converted, DisplayAreaFallback.Nearest); + } + } + + break; + + case MonitorBehavior.ToPrimary: + return DisplayArea.Primary; + + case MonitorBehavior.ToMouse: + default: + if (PInvoke.GetCursorPos(out var cursorPos)) + { + return DisplayArea.GetFromPoint(new PointInt32(cursorPos.X, cursorPos.Y), DisplayAreaFallback.Nearest); + } + + break; + } + + return DisplayArea.Primary; + } + + public void Receive(ShowWindowMessage message) + { + var settings = App.Current.Services.GetService()!; + + ShowHwnd(message.Hwnd, settings.SummonOn); + } + + 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) => + + // This might come in on a background thread + DispatcherQueue.TryEnqueue(() => Close()); + + public void Receive(DismissMessage message) + { + // This might come in off the UI thread. Make sure to hop back. + DispatcherQueue.TryEnqueue(() => + { + HideWindow(); + }); + } + + private void HideWindow() + { + // Cloak our HWND to avoid all animations. + var cloaked = Cloak(); + + // 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); + + if (cloaked) + { + // 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. + + // If the window was not cloaked, then leave it hidden. + // Sure, it's not ideal, but at least it's not visible. + } + + // Start auto-go-home timer + RestartAutoGoHome(); + } + + private void StopAutoGoHome() + { + _autoGoHomeTimer.Stop(); + } + + private void RestartAutoGoHome() + { + if (_autoGoHomeInterval == Timeout.InfiniteTimeSpan) + { + return; + } + + _autoGoHomeTimer.Stop(); + _autoGoHomeTimer.Start(); + } + + private bool Cloak() + { + bool wasCloaked; + unsafe + { + BOOL value = true; + var hr = PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL)); + if (hr.Failed) + { + Logger.LogWarning($"DWM cloaking of the main window failed. HRESULT: {hr.Value}."); + } + + wasCloaked = hr.Succeeded; + } + + if (wasCloaked) + { + // Because we're only cloaking the window, bury it at the bottom in case something can + // see it - e.g. some accessibility helper (note: this also removes the top-most status). + PInvoke.SetWindowPos(_hwnd, HWND.HWND_BOTTOM, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE); + } + + return wasCloaked; + } + + 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; + UpdateWindowPositionInMemory(); + + var settings = serviceProvider.GetService(); + if (settings is not null) + { + settings.LastWindowPosition = new WindowPosition + { + X = _currentWindowPosition.X, + Y = _currentWindowPosition.Y, + Width = _currentWindowPosition.Width, + Height = _currentWindowPosition.Height, + Dpi = _currentWindowPosition.Dpi, + ScreenWidth = _currentWindowPosition.ScreenWidth, + ScreenHeight = _currentWindowPosition.ScreenHeight, + }; + + SettingsModel.SaveSettings(settings, logger); + } + + // var extensionService = serviceProvider.GetService()!; + // extensionService.SignalStopExtensionsAsync(); + App.Current.Services.GetService()!.Destroy(); + + // WinUI bug is causing a crash on shutdown when FailFastOnErrors is set to true (#51773592). + // Workaround by turning it off before shutdown. + App.Current.DebugSettings.FailFastOnErrors = false; + + // _localKeyboardListener.Dispose(); + DisposeAcrylic(); + + // _keyboardListener.Stop(); + Environment.Exit(0); + } + + private void DisposeAcrylic() + { + if (_acrylicController is not null) + { + _acrylicController.Dispose(); + _acrylicController = null!; + _configurationSource = null!; + } + } + + // Updates our window s.t. the top of the window is draggable. + private void UpdateRegionsForCustomTitleBar() + { + // Specify the interactive regions of the title bar. + var scaleAdjustment = RootElement.XamlRoot.RasterizationScale; + + // Get the rectangle around our XAML content. We're going to mark this + // rectangle as "Passthrough", so that the normal window operations + // (resizing, dragging) don't apply in this space. + var transform = RootElement.TransformToVisual(null); + + // Reserve 16px of space at the top for dragging. + var topHeight = 16; + var bounds = transform.TransformBounds(new Rect( + 0, + topHeight, + RootElement.ActualWidth, + RootElement.ActualHeight)); + var contentRect = GetRect(bounds, scaleAdjustment); + var rectArray = new RectInt32[] { contentRect }; + var nonClientInputSrc = InputNonClientPointerSource.GetForWindowId(this.AppWindow.Id); + nonClientInputSrc.SetRegionRects(NonClientRegionKind.Passthrough, rectArray); + + // Add a drag-able region on top + var w = RootElement.ActualWidth; + _ = RootElement.ActualHeight; + var dragSides = new RectInt32[] + { + GetRect(new Rect(0, 0, w, topHeight), scaleAdjustment), // the top, {topHeight=16} tall + }; + nonClientInputSrc.SetRegionRects(NonClientRegionKind.Caption, dragSides); + } + + private static RectInt32 GetRect(Rect bounds, double scale) + { + return new RectInt32( + _X: (int)Math.Round(bounds.X * scale), + _Y: (int)Math.Round(bounds.Y * scale), + _Width: (int)Math.Round(bounds.Width * scale), + _Height: (int)Math.Round(bounds.Height * scale)); + } + + internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args) + { + if (args.WindowActivationState == WindowActivationState.Deactivated) + { + // Save the current window position before hiding the window + UpdateWindowPositionInMemory(); + + // If there's a debugger attached... + if (System.Diagnostics.Debugger.IsAttached) + { + // ... then don't hide the window when it loses focus. + return; + } + + // Are we disabled? If we are, then we don't want to dismiss on focus lost. + // This can happen if an extension wanted to show a modal dialog on top of our + // window i.e. in the case of an MSAL auth window. + if (PInvoke.IsWindowEnabled(_hwnd) == 0) + { + return; + } + + // This will DWM cloak our window: + HideWindow(); + + PowerToysTelemetry.Log.WriteEvent(new DismissedOnLostFocusEvent()); + } + + if (_configurationSource is not null) + { + _configurationSource.IsInputActive = args.WindowActivationState != WindowActivationState.Deactivated; + } } public void HandleLaunchNonUI(AppActivationArguments? activatedEventArgs) @@ -37,7 +737,7 @@ public sealed partial class MainWindow : WindowEx // the activation arguments to be lost. if (activatedEventArgs is null) { - // Summon(string.Empty); + Summon(string.Empty); return; } @@ -62,21 +762,22 @@ public sealed partial class MainWindow : WindowEx } else if (uri.StartsWith("x-cmdpal://settings", StringComparison.OrdinalIgnoreCase)) { - // WeakReferenceMessenger.Default.Send(new()); + WeakReferenceMessenger.Default.Send(new()); return; } else if (uri.StartsWith("x-cmdpal://reload", StringComparison.OrdinalIgnoreCase)) { - // var settings = App.Current.Services.GetService(); - // if (settings?.AllowExternalReload == true) - // { - // Logger.LogInfo("External Reload triggered"); - // WeakReferenceMessenger.Default.Send(new()); - // } - // else - // { - // Logger.LogInfo("External Reload is disabled"); - // } + var settings = App.Current.Services.GetService(); + if (settings?.AllowExternalReload == true) + { + Log_ExternalReloadTriggered(); + WeakReferenceMessenger.Default.Send(new()); + } + else + { + Log_ExternalReloadDisabled(); + } + return; } } @@ -93,27 +794,217 @@ public sealed partial class MainWindow : WindowEx // if the args are not valid or not passed correctly. if (ex.HResult is RPC_S_SERVER_UNAVAILABLE or RPC_S_CALL_FAILED) { - // Logger.LogWarning( - // $"COM exception (HRESULT {ex.HResult}) when accessing activation arguments. " + - // $"This might be due to the calling application not passing them correctly or exiting before we could read them. " + - // $"The application will continue running and fall back to showing the Command Palette window."); + Log_COMExceptionAccessingActivationArguments(ex.HResult); } else { - // Logger.LogError( - // $"COM exception (HRESULT {ex.HResult}) when activating the application. " + - // $"The application will continue running and fall back to showing the Command Palette window.", - // ex); + Log_COMExceptionActivationApplication(ex.HResult); } } - // Summon(string.Empty); + Summon(string.Empty); } - // public void Summon(string commandId) => - // The actual showing and hiding of the window will be done by the - // ShellPage. This is because we don't want to show the window if the - // user bound a hotkey to just an invokable command, which we can't - // know till the message is being handled. - // WeakReferenceManager.Default.Send(new(commandId, _hwnd)); + public void Summon(string commandId) => + + // The actual showing and hiding of the window will be done by the + // ShellPage. This is because we don't want to show the window if the + // user bound a hotkey to just an invokable command, which we can't + // know till the message is being handled. + WeakReferenceMessenger.Default.Send(new(commandId, _hwnd)); + + private void UnregisterHotkeys() + { + _keyboardListener.ClearHotkeys(); + + while (_hotkeys.Count > 0) + { + PInvoke.UnregisterHotKey(_hwnd, _hotkeys.Count - 1); + _hotkeys.RemoveAt(_hotkeys.Count - 1); + } + } + + private void SetupHotkey(SettingsModel settings) + { + UnregisterHotkeys(); + + var globalHotkey = settings.Hotkey; + if (globalHotkey is not null) + { + if (settings.UseLowLevelGlobalHotkey) + { + _keyboardListener.SetHotkeyAction(globalHotkey.Win, globalHotkey.Ctrl, globalHotkey.Shift, globalHotkey.Alt, (byte)globalHotkey.Code, string.Empty); + + _hotkeys.Add(new(globalHotkey, string.Empty)); + } + else + { + var vk = globalHotkey.Code; + var modifiers = + (globalHotkey.Alt ? HOT_KEY_MODIFIERS.MOD_ALT : 0) | + (globalHotkey.Ctrl ? HOT_KEY_MODIFIERS.MOD_CONTROL : 0) | + (globalHotkey.Shift ? HOT_KEY_MODIFIERS.MOD_SHIFT : 0) | + (globalHotkey.Win ? HOT_KEY_MODIFIERS.MOD_WIN : 0) + ; + + var success = PInvoke.RegisterHotKey(_hwnd, _hotkeys.Count, modifiers, (uint)vk); + if (success) + { + _hotkeys.Add(new(globalHotkey, string.Empty)); + } + } + } + + foreach (var commandHotkey in settings.CommandHotkeys) + { + var key = commandHotkey.Hotkey; + + if (key is not null) + { + if (settings.UseLowLevelGlobalHotkey) + { + _keyboardListener.SetHotkeyAction(key.Win, key.Ctrl, key.Shift, key.Alt, (byte)key.Code, commandHotkey.CommandId); + + _hotkeys.Add(new(globalHotkey, string.Empty)); + } + else + { + var vk = key.Code; + var modifiers = + (key.Alt ? HOT_KEY_MODIFIERS.MOD_ALT : 0) | + (key.Ctrl ? HOT_KEY_MODIFIERS.MOD_CONTROL : 0) | + (key.Shift ? HOT_KEY_MODIFIERS.MOD_SHIFT : 0) | + (key.Win ? HOT_KEY_MODIFIERS.MOD_WIN : 0) + ; + + var success = PInvoke.RegisterHotKey(_hwnd, _hotkeys.Count, modifiers, (uint)vk); + if (success) + { + _hotkeys.Add(commandHotkey); + } + } + } + } + } + + private void HandleSummon(string commandId) + { + if (_ignoreHotKeyWhenFullScreen) + { + // If we're in full screen mode, ignore the hotkey + if (WindowHelper.IsWindowFullscreen()) + { + return; + } + } + + HandleSummonCore(commandId); + } + + private void HandleSummonCore(string commandId) + { + var isRootHotkey = string.IsNullOrEmpty(commandId); + 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 + // technically visible, because SHOW/HIDE != iconic (minimized) != cloaked + // (these are all separate states) + long attr = 0; + PInvoke.DwmGetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAKED, &attr, sizeof(long)); + if (attr == 1 /* DWM_CLOAKED_APP */) + { + isVisible = false; + } + } + + // Note to future us: the wParam will have the index of the hotkey we registered. + // We can use that in the future to differentiate the hotkeys we've pressed + // so that we can bind hotkeys to individual commands + if (!isVisible || !isRootHotkey) + { + Summon(commandId); + } + else if (isRootHotkey) + { + // If there's a debugger attached... + if (System.Diagnostics.Debugger.IsAttached) + { + // ... 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. + Cloak(); + this.Hide(); + + return; + } + + HideWindow(); + } + } + + private LRESULT HotKeyPrc( + HWND hwnd, + uint uMsg, + WPARAM wParam, + LPARAM lParam) + { + switch (uMsg) + { + // Prevent the window from maximizing when double-clicking the title bar area + case PInvoke.WM_NCLBUTTONDBLCLK: + return (LRESULT)IntPtr.Zero; + case PInvoke.WM_HOTKEY: + { + var hotkeyIndex = (int)wParam.Value; + if (hotkeyIndex < _hotkeys.Count) + { + var hotkey = _hotkeys[hotkeyIndex]; + HandleSummon(hotkey.CommandId); + } + + return (LRESULT)IntPtr.Zero; + } + + default: + if (uMsg == WM_TASKBAR_RESTART) + { + HotReloadSettings(); + } + + break; + } + + return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam); + } + + public void Dispose() + { + _localKeyboardListener.Dispose(); + DisposeAcrylic(); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "External Reload triggered")] + partial void Log_ExternalReloadTriggered(); + + [LoggerMessage(Level = LogLevel.Information, Message = "External Reload is disabled")] + partial void Log_ExternalReloadDisabled(); + + [LoggerMessage(level: LogLevel.Warning, Message = "GetDpiForMonitor failed with HRESULT: 0x{hr.Value:X8} on display {DisplayArea.DisplayId}")] + partial void LogWarning_GetDpiForMonitorFailed(HRESULT hr, DisplayArea displayArea); + + [LoggerMessage(level: LogLevel.Warning, Message = "DWM cloaking of the main window failed. HRESULT: {hr.Value}.")] + partial void Log_DwmCloakingFailed(HRESULT hr); + + [LoggerMessage( + level: LogLevel.Error, + Message = "COM exception (HRESULT {HResult}) when accessing activation arguments. This might be due to the calling application not passing them correctly or exiting before we could read them. The application will continue running and fall back to showing the Command Palette window.")] + partial void Log_COMExceptionAccessingActivationArguments(int hResult); + + [LoggerMessage( + level: LogLevel.Error, + Message = "COM exception (HRESULT {HResult}) when activating the application. The application will continue running and fall back to showing the Command Palette window.")] + partial void Log_COMExceptionActivationApplication(int hResult); } diff --git a/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Pages/ShellPage.xaml b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Pages/ShellPage.xaml index 6e0cdaef26..f2090d3c4e 100644 --- a/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Pages/ShellPage.xaml +++ b/src/modules/Deux/UI/Microsoft.CommandPalette.UI/Pages/ShellPage.xaml @@ -1,8 +1,9 @@ - + + + + + + @@ -26,178 +35,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -