mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +02:00
CmdPal: Lightning-fast mode (#45764)
## Summary of the Pull Request
This PR unlocks lightning-fast mode for Command Palette:
- Hides visual and motion distractions when updating the result list:
- Ensures the first interactable result item is selected as early as
possible after the result list is updated, reducing flashing and
blinking caused by the selection highlight moving around.
- Removes the list item selection indicator animation (unfortunately by
removing the pill altogether for now) and prevents it from temporarily
appearing on other items as the selection moves.
- Adds a new "Results" section header above the home page results when
no other section is present.
- This ensures the first item on the home page has consistent visuals
and styling, preventing offsets and excessive visual changes when
elements are replaced in place.
- Improves update performance and container reuse:
- Fixes the `removed` output parameter in `ListHelper.UpdateInPlace` to
only include items that were actually removed (items that were merely
moved to a different position should not be reported as removed).
- Adds unit tests to prevent regression.
- Updates `ListHelper.UpdateInPlace` for `ObservableCollection` to use
`Move` instead of `Remove`/`Add`, and avoids `Clear` to prevent
`ListView` resets (which force recreation of all item containers and are
expensive).
- Adds a simple cache for list page item view models to reduce
unnecessary recreation during forward incremental search.
- `ListViewModel` and `FetchItems` have no notion of item lifetime or
incremental search phase, so the cache intentionally remains simple
rather than clever.
- Updates ListPage templates to make them a little lighter:
- Tag template uses OneTime, instead of OneWay - since Tag is immutable
- Replaces ItemsControl with ItemsRepeater for Tag list on list items
- Increases the debounce for showing the details pane and adds a
debounce for hiding it. This improves performance when browsing the list
and prevents the details pane animation from bouncing left and right
## Pictures? Moving!
https://github.com/user-attachments/assets/36428d20-cf46-4321-83c0-d94d6d4a2299
<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist
- [x] Closes: #44407
- [x] Closes: #45691
This commit is contained in:
@@ -3,9 +3,12 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.CmdPal.Common;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Models;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
@@ -16,9 +19,10 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class ListViewModel : PageViewModel, IDisposable
|
||||
{
|
||||
// private readonly HashSet<ListItemViewModel> _itemCache = [];
|
||||
private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
|
||||
|
||||
private readonly Dictionary<IListItem, ListItemViewModel> _vmCache = new(new ProxyReferenceEqualityComparer());
|
||||
|
||||
// TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items?
|
||||
|
||||
// Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change
|
||||
@@ -37,7 +41,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
private InterlockedBoolean _isLoading;
|
||||
private bool _isFetching;
|
||||
|
||||
public event TypedEventHandler<ListViewModel, object>? ItemsUpdated;
|
||||
public event TypedEventHandler<ListViewModel, ItemsUpdatedEventArgs>? ItemsUpdated;
|
||||
|
||||
public bool ShowEmptyContent =>
|
||||
IsInitialized &&
|
||||
@@ -80,6 +84,9 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
private ListItemViewModel? _lastSelectedItem;
|
||||
|
||||
// For cancelling a deferred SafeSlowInit when the user navigates rapidly
|
||||
private CancellationTokenSource? _selectedItemCts;
|
||||
|
||||
public override bool IsInitialized
|
||||
{
|
||||
get => base.IsInitialized; protected set
|
||||
@@ -113,10 +120,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
protected override void OnSearchTextBoxUpdated(string searchTextBox)
|
||||
{
|
||||
//// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view
|
||||
//// and manage filtering below, but we should be smarter about this and understand caching and other requirements...
|
||||
//// Investigate if we re-use src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\ListHelpers.cs InPlaceUpdateList and FilterList?
|
||||
|
||||
// Dynamic pages will handler their own filtering. They will tell us if
|
||||
// something needs to change, by raising ItemsChanged.
|
||||
if (_isDynamic)
|
||||
@@ -132,24 +135,24 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
// concurrently.
|
||||
_ = filterTaskFactory.StartNew(
|
||||
() =>
|
||||
{
|
||||
filterCancellationTokenSource.Token.ThrowIfCancellationRequested();
|
||||
{
|
||||
filterCancellationTokenSource.Token.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
if (_model.Unsafe is IDynamicListPage dynamic)
|
||||
try
|
||||
{
|
||||
dynamic.SearchText = searchTextBox;
|
||||
if (_model.Unsafe is IDynamicListPage dynamic)
|
||||
{
|
||||
dynamic.SearchText = searchTextBox;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowException(ex, _model?.Unsafe?.Name);
|
||||
}
|
||||
},
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowException(ex, _model?.Unsafe?.Name);
|
||||
}
|
||||
},
|
||||
filterCancellationTokenSource.Token,
|
||||
TaskCreationOptions.None,
|
||||
filterTaskFactory.Scheduler!);
|
||||
@@ -162,7 +165,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
ApplyFilterUnderLock();
|
||||
}
|
||||
|
||||
ItemsUpdated?.Invoke(this, EventArgs.Empty);
|
||||
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(true));
|
||||
UpdateEmptyContent();
|
||||
_isLoading.Clear();
|
||||
}
|
||||
@@ -198,12 +201,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
var cancellationToken = _fetchItemsCancellationTokenSource.Token;
|
||||
|
||||
// TEMPORARY: just plop all the items into a single group
|
||||
// see 9806fe5d8 for the last commit that had this with sections
|
||||
_isFetching = true;
|
||||
|
||||
// Collect all the items into new viewmodels
|
||||
Collection<ListItemViewModel> newViewModels = [];
|
||||
List<ListItemViewModel> newViewModels = [];
|
||||
|
||||
try
|
||||
{
|
||||
@@ -221,11 +222,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO we can probably further optimize this by also keeping a
|
||||
// HashSet of every ExtensionObject we currently have, and only
|
||||
// building new viewmodels for the ones we haven't already built.
|
||||
var showsTitle = GridProperties?.ShowTitle ?? true;
|
||||
var showsSubtitle = GridProperties?.ShowSubtitle ?? true;
|
||||
var created = 0;
|
||||
var reused = 0;
|
||||
foreach (var item in newItems)
|
||||
{
|
||||
// Check for cancellation during item processing
|
||||
@@ -234,17 +234,33 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
ListItemViewModel viewModel = new(item, new(this));
|
||||
if (_vmCache.TryGetValue(item, out var existing))
|
||||
{
|
||||
existing.LayoutShowsTitle = showsTitle;
|
||||
existing.LayoutShowsSubtitle = showsSubtitle;
|
||||
newViewModels.Add(existing);
|
||||
reused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var viewModel = new ListItemViewModel(item, new(this));
|
||||
|
||||
// If an item fails to load, silently ignore it.
|
||||
if (viewModel.SafeFastInit())
|
||||
{
|
||||
viewModel.LayoutShowsTitle = showsTitle;
|
||||
viewModel.LayoutShowsSubtitle = showsSubtitle;
|
||||
|
||||
_vmCache[item] = viewModel;
|
||||
newViewModels.Add(viewModel);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
CoreLogger.LogInfo($"[ListViewModel] FetchItems: {created} created, {reused} reused, {_vmCache.Count} cached");
|
||||
#endif
|
||||
|
||||
// Check for cancellation before initializing first twenty items
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -271,13 +287,22 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
List<ListItemViewModel> removedItems = [];
|
||||
List<ListItemViewModel> removedItems;
|
||||
lock (_listLock)
|
||||
{
|
||||
// Now that we have new ViewModels for everything from the
|
||||
// extension, smartly update our list of VMs
|
||||
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
|
||||
|
||||
_vmCache.Clear();
|
||||
foreach (var vm in newViewModels)
|
||||
{
|
||||
if (vm.Model.Unsafe is { } li)
|
||||
{
|
||||
_vmCache[li] = vm;
|
||||
}
|
||||
}
|
||||
|
||||
// DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
|
||||
// you'll clean up list items that we've now transferred into
|
||||
// .Items
|
||||
@@ -288,9 +313,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
{
|
||||
removedItem.SafeCleanup();
|
||||
}
|
||||
|
||||
// TODO: Iterate over everything in Items, and prune items from the
|
||||
// cache if we don't need them anymore
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -342,13 +364,14 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
{
|
||||
// A dynamic list? Even better! Just stick everything into
|
||||
// FilteredItems. The extension already did any filtering it cared about.
|
||||
ListHelpers.InPlaceUpdateList(FilteredItems, Items.Where(i => !i.IsInErrorState));
|
||||
var snapshot = Items.Where(i => !i.IsInErrorState).ToList();
|
||||
ListHelpers.InPlaceUpdateList(FilteredItems, snapshot);
|
||||
}
|
||||
|
||||
UpdateEmptyContent();
|
||||
}
|
||||
|
||||
ItemsUpdated?.Invoke(this, EventArgs.Empty);
|
||||
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(!IsNested));
|
||||
_isLoading.Clear();
|
||||
});
|
||||
}
|
||||
@@ -485,40 +508,58 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
private void SetSelectedItem(ListItemViewModel item)
|
||||
{
|
||||
if (!item.SafeSlowInit())
|
||||
{
|
||||
// Even if initialization fails, we need to hide any previously shown details
|
||||
DoOnUiThread(() =>
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GH #322:
|
||||
// For inexplicable reasons, if you try updating the command bar and
|
||||
// the details on the same UI thread tick as updating the list, we'll
|
||||
// explode
|
||||
DoOnUiThread(
|
||||
() =>
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
|
||||
|
||||
if (ShowDetails && item.HasDetails)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
|
||||
}
|
||||
else
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||
}
|
||||
|
||||
TextToSuggest = item.TextToSuggest;
|
||||
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
|
||||
});
|
||||
|
||||
_lastSelectedItem = item;
|
||||
_lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged;
|
||||
|
||||
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
|
||||
|
||||
// Cancel any in-flight slow init from a previous selection and defer
|
||||
// the expensive work (extension IPC for MoreCommands, details) so
|
||||
// rapid arrow-key navigation skips intermediate items entirely.
|
||||
_selectedItemCts?.Cancel();
|
||||
var cts = _selectedItemCts = new CancellationTokenSource();
|
||||
var ct = cts.Token;
|
||||
|
||||
_ = Task.Run(
|
||||
() =>
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.SafeSlowInit())
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// SafeSlowInit completed on a background thread — details
|
||||
// messages will be marshalled to the UI thread by the receiver.
|
||||
if (ShowDetails && item.HasDetails)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
|
||||
}
|
||||
else
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||
}
|
||||
|
||||
TextToSuggest = item.TextToSuggest;
|
||||
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
@@ -557,21 +598,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
private void ClearSelectedItem()
|
||||
{
|
||||
// GH #322:
|
||||
// For inexplicable reasons, if you try updating the command bar and
|
||||
// the details on the same UI thread tick as updating the list, we'll
|
||||
// explode
|
||||
DoOnUiThread(
|
||||
() =>
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
|
||||
_selectedItemCts?.Cancel();
|
||||
|
||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||
|
||||
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
|
||||
|
||||
TextToSuggest = string.Empty;
|
||||
});
|
||||
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
|
||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
|
||||
TextToSuggest = string.Empty;
|
||||
}
|
||||
|
||||
public override void InitializeProperties()
|
||||
@@ -763,6 +795,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
_fetchItemsCancellationTokenSource?.Cancel();
|
||||
_fetchItemsCancellationTokenSource?.Dispose();
|
||||
_fetchItemsCancellationTokenSource = null;
|
||||
|
||||
_selectedItemCts?.Cancel();
|
||||
_selectedItemCts?.Dispose();
|
||||
_selectedItemCts = null;
|
||||
}
|
||||
|
||||
protected override void UnsafeCleanup()
|
||||
@@ -775,6 +811,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
_cancellationTokenSource?.Cancel();
|
||||
filterCancellationTokenSource?.Cancel();
|
||||
_fetchItemsCancellationTokenSource?.Cancel();
|
||||
_selectedItemCts?.Cancel();
|
||||
|
||||
lock (_listLock)
|
||||
{
|
||||
@@ -801,4 +838,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
model.ItemsChanged -= Model_ItemsChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProxyReferenceEqualityComparer : IEqualityComparer<IListItem>
|
||||
{
|
||||
public bool Equals(IListItem? x, IListItem? y) => ReferenceEquals(x, y);
|
||||
|
||||
public int GetHashCode(IListItem obj) => RuntimeHelpers.GetHashCode(obj);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user