From c23ba227b4734d859f9c07054c077c429b761162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Tue, 3 Mar 2026 19:22:41 +0100 Subject: [PATCH] CmdPal: Debounce SelectedItem updates in CommandBarViewModel (#45782) ## Summary of the Pull Request This PR adds DispatcherQueueTimer-based debounce to SelectedItem updates when receiving UpdateCommandBarMessage, preventing rapid consecutive changes and prevents blinking when items change to fast (e.g. during search). That's right - Command Palette is too fast! ## PR Checklist - [x] Closes: #45776 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .../CommandBarViewModel.cs | 71 +++++++++++++------ 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs index 6a38698ff6..2681718485 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs @@ -2,31 +2,54 @@ // 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 CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; +using CommunityToolkit.WinUI; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.System; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; +using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer; namespace Microsoft.CmdPal.UI.ViewModels; -public partial class CommandBarViewModel : ObservableObject, +public sealed partial class CommandBarViewModel : ObservableObject, IRecipient { + private readonly DispatcherQueueTimer _debounceTimer; + + private volatile ICommandBarContext? _pendingSelectedItem; + public ICommandBarContext? SelectedItem { - get => field; + get; set { - if (field != null) + // TODO: verify if we can safely return early + // if (ReferenceEquals(field, value)) + // { + // return; + // } + if (field is not null) { field.PropertyChanged -= SelectedItemPropertyChanged; } field = value; - SetSelectedItem(value); - OnPropertyChanged(nameof(SelectedItem)); + if (field is not null) + { + PrimaryCommand = field.PrimaryCommand; + field.PropertyChanged += SelectedItemPropertyChanged; + } + else + { + PrimaryCommand = null; + } + + UpdateContextItems(); + OnPropertyChanged(); } } @@ -34,6 +57,8 @@ public partial class CommandBarViewModel : ObservableObject, [NotifyPropertyChangedFor(nameof(HasPrimaryCommand))] public partial CommandItemViewModel? PrimaryCommand { get; set; } + // TODO: PrimaryCommand.ShouldBeVisible is not observed, if it changes the bar won't refresh; + // but at this moment CommandItemViewModel won't raise INPC for ShouldBeVisible anyway. public bool HasPrimaryCommand => PrimaryCommand is not null && PrimaryCommand.ShouldBeVisible; [ObservableProperty] @@ -50,29 +75,31 @@ public partial class CommandBarViewModel : ObservableObject, public CommandBarViewModel() { + var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + if (dispatcherQueue is null) + { + throw new InvalidOperationException("DispatcherQueue is not available for the current thread."); + } + + _debounceTimer = dispatcherQueue.CreateTimer(); WeakReferenceMessenger.Default.Register(this); } - public void Receive(UpdateCommandBarMessage message) => SelectedItem = message.ViewModel; - - private void SetSelectedItem(ICommandBarContext? value) + public void Receive(UpdateCommandBarMessage message) { - if (value is not null) - { - PrimaryCommand = value.PrimaryCommand; - value.PropertyChanged += SelectedItemPropertyChanged; - } - else - { - if (SelectedItem is not null) - { - SelectedItem.PropertyChanged -= SelectedItemPropertyChanged; - } + _pendingSelectedItem = message.ViewModel; - PrimaryCommand = null; - } + // immediate: false is intentional — the timer tick always fires on the + // dispatcher queue thread, which guarantees ApplyPendingSelectedItem + // runs on the UI thread even if Receive is called from a background + // thread. Using immediate: true would invoke the delegate synchronously + // on the calling thread, bypassing the dispatcher. + _debounceTimer.Debounce(ApplyPendingSelectedItem, TimeSpan.FromMilliseconds(50)); + } - UpdateContextItems(); + private void ApplyPendingSelectedItem() + { + SelectedItem = _pendingSelectedItem; } private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)