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:
Jiří Polášek
2026-02-26 13:17:34 +01:00
committed by GitHub
parent 1b4641a158
commit 169bfe3f04
12 changed files with 1714 additions and 326 deletions

View File

@@ -36,6 +36,11 @@ public sealed partial class MainListPage : DynamicListPage,
private readonly ScoringFunction<IListItem> _fallbackScoringFunction; private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider; private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
// Stable separator instances so that the VM cache and InPlaceUpdateList
// recognise them across successive GetItems() calls
private readonly Separator _resultsSeparator = new(Resources.results);
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
private RoScored<IListItem>[]? _filteredItems; private RoScored<IListItem>[]? _filteredItems;
private RoScored<IListItem>[]? _filteredApps; private RoScored<IListItem>[]? _filteredApps;
@@ -171,9 +176,40 @@ public sealed partial class MainListPage : DynamicListPage,
// filtered results. // filtered results.
if (string.IsNullOrWhiteSpace(SearchText)) if (string.IsNullOrWhiteSpace(SearchText))
{ {
return _tlcManager.TopLevelCommands var allCommands = _tlcManager.TopLevelCommands;
.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
.ToArray(); // First pass: count eligible commands
var eligibleCount = 0;
for (var i = 0; i < allCommands.Count; i++)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
eligibleCount++;
}
}
if (eligibleCount == 0)
{
return [];
}
// +1 for the separator
var result = new IListItem[eligibleCount + 1];
result[0] = _resultsSeparator;
// Second pass: populate
var writeIndex = 1;
for (var i = 0; i < allCommands.Count; i++)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
result[writeIndex++] = cmd;
}
}
return result;
} }
else else
{ {
@@ -190,6 +226,8 @@ public sealed partial class MainListPage : DynamicListPage,
validScoredFallbacks, validScoredFallbacks,
_filteredApps, _filteredApps,
validFallbacks, validFallbacks,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit); AppResultLimit);
} }
} }

View File

@@ -21,6 +21,8 @@ internal static class MainListPageResultFactory
IList<RoScored<IListItem>>? scoredFallbackItems, IList<RoScored<IListItem>>? scoredFallbackItems,
IList<RoScored<IListItem>>? filteredApps, IList<RoScored<IListItem>>? filteredApps,
IList<RoScored<IListItem>>? fallbackItems, IList<RoScored<IListItem>>? fallbackItems,
IListItem resultsSeparator,
IListItem fallbacksSeparator,
int appResultLimit) int appResultLimit)
{ {
if (appResultLimit < 0) if (appResultLimit < 0)
@@ -40,8 +42,13 @@ internal static class MainListPageResultFactory
int nonEmptyFallbackCount = fallbackItems?.Count ?? 0; int nonEmptyFallbackCount = fallbackItems?.Count ?? 0;
// Allocate the exact size of the result array. // Allocate the exact size of the result array.
// We'll add an extra slot for the fallbacks section header if needed. // We'll add an extra slot for the fallbacks section header if needed,
int totalCount = len1 + len2 + len3 + nonEmptyFallbackCount + (nonEmptyFallbackCount > 0 ? 1 : 0); // and another for the "Results" section header when merged results exist.
int mergedCount = len1 + len2 + len3;
bool needsResultsHeader = mergedCount > 0;
int totalCount = mergedCount + nonEmptyFallbackCount
+ (needsResultsHeader ? 1 : 0)
+ (nonEmptyFallbackCount > 0 ? 1 : 0);
var result = new IListItem[totalCount]; var result = new IListItem[totalCount];
@@ -49,6 +56,12 @@ internal static class MainListPageResultFactory
int idx1 = 0, idx2 = 0, idx3 = 0; int idx1 = 0, idx2 = 0, idx3 = 0;
int writePos = 0; int writePos = 0;
// Add "Results" section header when merged results will precede the fallbacks.
if (needsResultsHeader)
{
result[writePos++] = resultsSeparator;
}
// Merge while all three lists have items. To maintain a stable sort, the // Merge while all three lists have items. To maintain a stable sort, the
// priority is: list1 > list2 > list3 when scores are equal. // priority is: list1 > list2 > list3 when scores are equal.
while (idx1 < len1 && idx2 < len2 && idx3 < len3) while (idx1 < len1 && idx2 < len2 && idx3 < len3)
@@ -132,7 +145,7 @@ internal static class MainListPageResultFactory
// Create the fallbacks section header // Create the fallbacks section header
if (fallbackItems.Count > 0) if (fallbackItems.Count > 0)
{ {
result[writePos++] = new Separator(Properties.Resources.fallbacks); result[writePos++] = fallbacksSeparator;
} }
for (int i = 0; i < fallbackItems.Count; i++) for (int i = 0; i < fallbackItems.Count; i++)

View File

@@ -0,0 +1,15 @@
// 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.CmdPal.Core.ViewModels;
public sealed partial class ItemsUpdatedEventArgs : EventArgs
{
public bool ForceFirstItem { get; }
public ItemsUpdatedEventArgs(bool forceFirstItem)
{
ForceFirstItem = forceFirstItem;
}
}

View File

@@ -3,9 +3,12 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.Common.Helpers; using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
@@ -16,9 +19,10 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ListViewModel : PageViewModel, IDisposable public partial class ListViewModel : PageViewModel, IDisposable
{ {
// private readonly HashSet<ListItemViewModel> _itemCache = [];
private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler); 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? // 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 // 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 InterlockedBoolean _isLoading;
private bool _isFetching; private bool _isFetching;
public event TypedEventHandler<ListViewModel, object>? ItemsUpdated; public event TypedEventHandler<ListViewModel, ItemsUpdatedEventArgs>? ItemsUpdated;
public bool ShowEmptyContent => public bool ShowEmptyContent =>
IsInitialized && IsInitialized &&
@@ -80,6 +84,9 @@ public partial class ListViewModel : PageViewModel, IDisposable
private ListItemViewModel? _lastSelectedItem; private ListItemViewModel? _lastSelectedItem;
// For cancelling a deferred SafeSlowInit when the user navigates rapidly
private CancellationTokenSource? _selectedItemCts;
public override bool IsInitialized public override bool IsInitialized
{ {
get => base.IsInitialized; protected set get => base.IsInitialized; protected set
@@ -113,10 +120,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
protected override void OnSearchTextBoxUpdated(string searchTextBox) 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 // Dynamic pages will handler their own filtering. They will tell us if
// something needs to change, by raising ItemsChanged. // something needs to change, by raising ItemsChanged.
if (_isDynamic) if (_isDynamic)
@@ -132,24 +135,24 @@ public partial class ListViewModel : PageViewModel, IDisposable
// concurrently. // concurrently.
_ = filterTaskFactory.StartNew( _ = filterTaskFactory.StartNew(
() => () =>
{ {
filterCancellationTokenSource.Token.ThrowIfCancellationRequested(); filterCancellationTokenSource.Token.ThrowIfCancellationRequested();
try try
{
if (_model.Unsafe is IDynamicListPage dynamic)
{ {
dynamic.SearchText = searchTextBox; if (_model.Unsafe is IDynamicListPage dynamic)
{
dynamic.SearchText = searchTextBox;
}
} }
} catch (OperationCanceledException)
catch (OperationCanceledException) {
{ }
} catch (Exception ex)
catch (Exception ex) {
{ ShowException(ex, _model?.Unsafe?.Name);
ShowException(ex, _model?.Unsafe?.Name); }
} },
},
filterCancellationTokenSource.Token, filterCancellationTokenSource.Token,
TaskCreationOptions.None, TaskCreationOptions.None,
filterTaskFactory.Scheduler!); filterTaskFactory.Scheduler!);
@@ -162,7 +165,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
ApplyFilterUnderLock(); ApplyFilterUnderLock();
} }
ItemsUpdated?.Invoke(this, EventArgs.Empty); ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(true));
UpdateEmptyContent(); UpdateEmptyContent();
_isLoading.Clear(); _isLoading.Clear();
} }
@@ -198,12 +201,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
var cancellationToken = _fetchItemsCancellationTokenSource.Token; 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; _isFetching = true;
// Collect all the items into new viewmodels // Collect all the items into new viewmodels
Collection<ListItemViewModel> newViewModels = []; List<ListItemViewModel> newViewModels = [];
try try
{ {
@@ -221,11 +222,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
return; 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 showsTitle = GridProperties?.ShowTitle ?? true;
var showsSubtitle = GridProperties?.ShowSubtitle ?? true; var showsSubtitle = GridProperties?.ShowSubtitle ?? true;
var created = 0;
var reused = 0;
foreach (var item in newItems) foreach (var item in newItems)
{ {
// Check for cancellation during item processing // Check for cancellation during item processing
@@ -234,17 +234,33 @@ public partial class ListViewModel : PageViewModel, IDisposable
return; 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 an item fails to load, silently ignore it.
if (viewModel.SafeFastInit()) if (viewModel.SafeFastInit())
{ {
viewModel.LayoutShowsTitle = showsTitle; viewModel.LayoutShowsTitle = showsTitle;
viewModel.LayoutShowsSubtitle = showsSubtitle; viewModel.LayoutShowsSubtitle = showsSubtitle;
_vmCache[item] = viewModel;
newViewModels.Add(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 // Check for cancellation before initializing first twenty items
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
{ {
@@ -271,13 +287,22 @@ public partial class ListViewModel : PageViewModel, IDisposable
return; return;
} }
List<ListItemViewModel> removedItems = []; List<ListItemViewModel> removedItems;
lock (_listLock) lock (_listLock)
{ {
// Now that we have new ViewModels for everything from the // Now that we have new ViewModels for everything from the
// extension, smartly update our list of VMs // extension, smartly update our list of VMs
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems); 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, // DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
// you'll clean up list items that we've now transferred into // you'll clean up list items that we've now transferred into
// .Items // .Items
@@ -288,9 +313,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
{ {
removedItem.SafeCleanup(); removedItem.SafeCleanup();
} }
// TODO: Iterate over everything in Items, and prune items from the
// cache if we don't need them anymore
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -342,13 +364,14 @@ public partial class ListViewModel : PageViewModel, IDisposable
{ {
// A dynamic list? Even better! Just stick everything into // A dynamic list? Even better! Just stick everything into
// FilteredItems. The extension already did any filtering it cared about. // 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(); UpdateEmptyContent();
} }
ItemsUpdated?.Invoke(this, EventArgs.Empty); ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(!IsNested));
_isLoading.Clear(); _isLoading.Clear();
}); });
} }
@@ -485,40 +508,58 @@ public partial class ListViewModel : PageViewModel, IDisposable
private void SetSelectedItem(ListItemViewModel item) 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 = item;
_lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged; _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) private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -557,21 +598,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
private void ClearSelectedItem() private void ClearSelectedItem()
{ {
// GH #322: _selectedItemCts?.Cancel();
// 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));
WeakReferenceMessenger.Default.Send<HideDetailsMessage>(); WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty)); WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
TextToSuggest = string.Empty;
TextToSuggest = string.Empty;
});
} }
public override void InitializeProperties() public override void InitializeProperties()
@@ -763,6 +795,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
_fetchItemsCancellationTokenSource?.Cancel(); _fetchItemsCancellationTokenSource?.Cancel();
_fetchItemsCancellationTokenSource?.Dispose(); _fetchItemsCancellationTokenSource?.Dispose();
_fetchItemsCancellationTokenSource = null; _fetchItemsCancellationTokenSource = null;
_selectedItemCts?.Cancel();
_selectedItemCts?.Dispose();
_selectedItemCts = null;
} }
protected override void UnsafeCleanup() protected override void UnsafeCleanup()
@@ -775,6 +811,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
_cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Cancel();
filterCancellationTokenSource?.Cancel(); filterCancellationTokenSource?.Cancel();
_fetchItemsCancellationTokenSource?.Cancel(); _fetchItemsCancellationTokenSource?.Cancel();
_selectedItemCts?.Cancel();
lock (_listLock) lock (_listLock)
{ {
@@ -801,4 +838,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
model.ItemsChanged -= Model_ItemsChanged; 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);
}
} }

View File

@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
// class via a tool like ResGen or Visual Studio. // class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen // To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project. // with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources { public class Resources {
@@ -474,6 +474,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
} }
} }
/// <summary>
/// Looks up a localized string similar to Results.
/// </summary>
public static string results {
get {
return ResourceManager.GetString("results", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Show details. /// Looks up a localized string similar to Show details.
/// </summary> /// </summary>

View File

@@ -264,4 +264,8 @@
<value>Show details</value> <value>Show details</value>
<comment>Name for the command that shows details of an item</comment> <comment>Name for the command that shows details of an item</comment>
</data> </data>
<data name="results" xml:space="preserve">
<value>Results</value>
<comment>Section title for list of all search results that doesn't fall into any other category</comment>
</data>
</root> </root>

View File

@@ -201,12 +201,70 @@
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" /> <Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
</Style> </Style>
<ControlTemplate x:Key="ListViewItemWithoutVisualIndicatorTemplate" TargetType="ListViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource ListViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource ListViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource ListViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource ListViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource ListViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource ListViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource ListViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource ListViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource ListViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource ListViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource ListViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource ListViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource ListViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource ListViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource ListViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource ListViewItemCheckMode}"
CheckPressedBrush="{ThemeResource ListViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{ThemeResource ListViewItemCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource ListViewItemDragBackground}"
DragForeground="{ThemeResource ListViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource ListViewItemFocusBorderBrush}"
FocusSecondaryBorderBrush="{ThemeResource ListViewItemFocusSecondaryBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource ListViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource ListViewItemBackgroundPointerOver}"
PointerOverForeground="{ThemeResource ListViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource ListViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource ListViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource ListViewItemBackgroundSelected}"
SelectedDisabledBackground="{ThemeResource ListViewItemBackgroundSelectedDisabled}"
SelectedForeground="{ThemeResource ListViewItemForegroundSelected}"
SelectedPointerOverBackground="{ThemeResource ListViewItemBackgroundSelectedPointerOver}"
SelectedPressedBackground="{ThemeResource ListViewItemBackgroundSelectedPressed}"
SelectionCheckMarkVisualEnabled="{ThemeResource ListViewItemSelectionCheckMarkVisualEnabled}"
SelectionIndicatorBrush="{ThemeResource ListViewItemSelectionIndicatorBrush}"
SelectionIndicatorCornerRadius="{ThemeResource ListViewItemSelectionIndicatorCornerRadius}"
SelectionIndicatorDisabledBrush="{ThemeResource ListViewItemSelectionIndicatorDisabledBrush}"
SelectionIndicatorPointerOverBrush="{ThemeResource ListViewItemSelectionIndicatorPointerOverBrush}"
SelectionIndicatorPressedBrush="{ThemeResource ListViewItemSelectionIndicatorPressedBrush}"
SelectionIndicatorVisualEnabled="False" />
</ControlTemplate>
<Style <Style
x:Key="ListDefaultContainerStyle" x:Key="ListSingleRowItemContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}" BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem"> TargetType="ListViewItem">
<Setter Property="MinHeight" Value="{StaticResource SingleRowListViewItemHeight}" /> <Setter Property="MinHeight" Value="{StaticResource SingleRowListViewItemHeight}" />
<Setter Property="Height" Value="{StaticResource SingleRowListViewItemHeight}" /> <Setter Property="Height" Value="{StaticResource SingleRowListViewItemHeight}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Template" Value="{StaticResource ListViewItemWithoutVisualIndicatorTemplate}" />
</Style> </Style>
<Style <Style
@@ -246,14 +304,17 @@
</Style> </Style>
<DataTemplate x:Key="TagTemplate" x:DataType="viewModels:TagViewModel"> <DataTemplate x:Key="TagTemplate" x:DataType="viewModels:TagViewModel">
<!--
Tags are immutable, so we don't have to worry about binding mode.
-->
<cpcontrols:Tag <cpcontrols:Tag
AutomationProperties.Name="{x:Bind Text, Mode=OneWay}" AutomationProperties.Name="{x:Bind Text}"
BackgroundColor="{x:Bind Background, Mode=OneWay}" BackgroundColor="{x:Bind Background}"
FontSize="12" FontSize="12"
ForegroundColor="{x:Bind Foreground, Mode=OneWay}" ForegroundColor="{x:Bind Foreground}"
Icon="{x:Bind Icon, Mode=OneWay}" Icon="{x:Bind Icon}"
Text="{x:Bind Text, Mode=OneWay}" Text="{x:Bind Text}"
ToolTipService.ToolTip="{x:Bind ToolTip, Mode=OneWay}" /> ToolTipService.ToolTip="{x:Bind ToolTip}" />
</DataTemplate> </DataTemplate>
<cmdpalUI:ListItemTemplateSelector <cmdpalUI:ListItemTemplateSelector
@@ -265,7 +326,7 @@
<cmdpalUI:ListItemContainerStyleSelector <cmdpalUI:ListItemContainerStyleSelector
x:Key="ListItemContainerStyleSelector" x:Key="ListItemContainerStyleSelector"
Default="{StaticResource ListDefaultContainerStyle}" Default="{StaticResource ListSingleRowItemContainerStyle}"
Section="{StaticResource ListSectionContainerStyle}" Section="{StaticResource ListSectionContainerStyle}"
Separator="{StaticResource ListSeparatorContainerStyle}" /> Separator="{StaticResource ListSeparatorContainerStyle}" />
@@ -310,10 +371,7 @@
Title and subtitle are intentionally in a nested Grid instead in the outer container, Title and subtitle are intentionally in a nested Grid instead in the outer container,
to avoid pushing the following element (tags) out of bounds. to avoid pushing the following element (tags) out of bounds.
--> -->
<Grid <Grid Grid.Column="1" ColumnSpacing="12">
Grid.Column="1"
VerticalAlignment="Center"
ColumnSpacing="12">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
@@ -345,10 +403,11 @@
</Grid> </Grid>
<!-- <!--
8px right margin is added to visually match the spacing between then icon, An 8px right margin is added to visually match the spacing between the icon
and the left margin of the list, as there's and the left margin of the list.
ItemRepeater is a lightweight control (compared to ItemsControl).
--> -->
<ItemsControl <ItemsRepeater
Grid.Column="2" Grid.Column="2"
Margin="0,0,8,0" Margin="0,0,8,0"
VerticalAlignment="Center" VerticalAlignment="Center"
@@ -357,12 +416,10 @@
ItemTemplate="{StaticResource TagTemplate}" ItemTemplate="{StaticResource TagTemplate}"
ItemsSource="{x:Bind Tags, Mode=OneWay}" ItemsSource="{x:Bind Tags, Mode=OneWay}"
Visibility="{x:Bind HasTags, Mode=OneWay}"> Visibility="{x:Bind HasTags, Mode=OneWay}">
<ItemsControl.ItemsPanel> <ItemsRepeater.Layout>
<ItemsPanelTemplate> <StackLayout Orientation="Horizontal" Spacing="4" />
<StackPanel Orientation="Horizontal" Spacing="4" /> </ItemsRepeater.Layout>
</ItemsPanelTemplate> </ItemsRepeater>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid> </Grid>
</DataTemplate> </DataTemplate>

View File

@@ -5,6 +5,7 @@
using System.Diagnostics; using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon; using ManagedCommon;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.UI.Helpers; using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels;
@@ -12,6 +13,7 @@ using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Input;
@@ -35,6 +37,14 @@ public sealed partial class ListPage : Page,
{ {
private InputSource _lastInputSource; private InputSource _lastInputSource;
private int _itemsUpdatedVersion;
private bool _suppressSelectionChanged;
private bool _scrollOnNextSelectionChange;
private ListItemViewModel? _stickySelectedItem;
private ListItemViewModel? _lastPushedToVm;
internal ListViewModel? ViewModel internal ListViewModel? ViewModel
{ {
get => (ListViewModel?)GetValue(ViewModelProperty); get => (ListViewModel?)GetValue(ViewModelProperty);
@@ -45,13 +55,7 @@ public sealed partial class ListPage : Page,
public static readonly DependencyProperty ViewModelProperty = public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(nameof(ViewModel), typeof(ListViewModel), typeof(ListPage), new PropertyMetadata(null, OnViewModelChanged)); DependencyProperty.Register(nameof(ViewModel), typeof(ListViewModel), typeof(ListPage), new PropertyMetadata(null, OnViewModelChanged));
private ListViewBase ItemView private ListViewBase ItemView => ViewModel?.IsGridView == true ? ItemsGrid : ItemsList;
{
get
{
return ViewModel?.IsGridView == true ? ItemsGrid : ItemsList;
}
}
public ListPage() public ListPage()
{ {
@@ -82,10 +86,17 @@ public sealed partial class ListPage : Page,
// may return an incorrect index because item containers are not yet rendered. // may return an incorrect index because item containers are not yet rendered.
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => _ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
{ {
var firstUsefulIndex = GetFirstSelectableIndex(); // Only do this if we truly have no selection.
if (firstUsefulIndex != -1) if (ItemView.SelectedItem is null)
{ {
ItemView.SelectedIndex = firstUsefulIndex; var firstUsefulIndex = GetFirstSelectableIndex();
if (firstUsefulIndex != -1)
{
using (SuppressSelectionChangedScope())
{
ItemView.SelectedIndex = firstUsefulIndex;
}
}
} }
}); });
} }
@@ -118,7 +129,6 @@ public sealed partial class ListPage : Page,
if (ViewModel is not null) if (ViewModel is not null)
{ {
ViewModel.PropertyChanged -= ViewModel_PropertyChanged;
ViewModel.ItemsUpdated -= Page_ItemsUpdated; ViewModel.ItemsUpdated -= Page_ItemsUpdated;
} }
@@ -175,6 +185,9 @@ public sealed partial class ListPage : Page,
} }
else else
{ {
// Click-driven selection should scroll into view (but only once).
_scrollOnNextSelectionChange = true;
ViewModel?.UpdateSelectedItemCommand.Execute(item); ViewModel?.UpdateSelectedItemCommand.Execute(item);
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>(); WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
} }
@@ -196,51 +209,59 @@ public sealed partial class ListPage : Page,
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")] [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
private void Items_SelectionChanged(object sender, SelectionChangedEventArgs e) private void Items_SelectionChanged(object sender, SelectionChangedEventArgs e)
{ {
if (_suppressSelectionChanged)
{
return;
}
var vm = ViewModel; var vm = ViewModel;
var li = ItemView.SelectedItem as ListItemViewModel; var li = ItemView.SelectedItem as ListItemViewModel;
_ = Task.Run(() =>
// Transient null/separator selection can happen during in-place updates.
// Do not push null into the VM; Page_ItemsUpdated will repair selection.
if (li is null || IsSeparator(li))
{ {
vm?.UpdateSelectedItemCommand.Execute(li); return;
}); }
// There's mysterious behavior here, where the selection seemingly _stickySelectedItem = li;
// changes to _nothing_ when we're backspacing to a single character.
// And at that point, seemingly the item that's getting removed is not // Do not Task.Run (it reorders selection updates).
// a member of FilteredItems. Very bizarre. vm?.UpdateSelectedItemCommand.Execute(li);
//
// Might be able to fix in the future by stashing the removed item // Only scroll when explicitly requested by navigation/click handlers.
// here, then in Page_ItemsUpdated trying to select that cached item if if (_scrollOnNextSelectionChange)
// it's in the list (otherwise, clear the cache), but that seems
// aggressively BODGY for something that mostly just works today.
if (ItemView.SelectedItem is not null && !IsSeparator(ItemView.SelectedItem))
{ {
var items = ItemView.Items; _scrollOnNextSelectionChange = false;
var firstUsefulIndex = GetFirstSelectableIndex();
var shouldScroll = false;
if (e.RemovedItems.Count > 0) var scrollTarget = li;
// If the previous item is a separator, also scroll it into view to provide
// better context for the user
var index = ItemView.Items.IndexOf(li);
if (index > 0)
{ {
shouldScroll = true; var prevItem = ItemView.Items[index - 1] as ListItemViewModel;
} if (prevItem?.Type == ListItemType.SectionHeader)
else if (ItemView.SelectedIndex > firstUsefulIndex) {
{ scrollTarget = prevItem;
shouldScroll = true; }
} }
if (shouldScroll) if (scrollTarget is not null)
{ {
ItemView.ScrollIntoView(ItemView.SelectedItem); ItemView.ScrollIntoView(scrollTarget);
} }
}
// Automation notification for screen readers // Automation notification for screen readers
var listViewPeer = Microsoft.UI.Xaml.Automation.Peers.ListViewAutomationPeer.CreatePeerForElement(ItemView); var listViewPeer = ListViewAutomationPeer.CreatePeerForElement(ItemView);
if (listViewPeer is not null && li is not null) if (listViewPeer is not null)
{ {
UIHelper.AnnounceActionForAccessibility( UIHelper.AnnounceActionForAccessibility(
ItemsList, ItemsList,
li.Title, li.Title,
"CommandPaletteSelectedItemChanged"); "CommandPaletteSelectedItemChanged");
}
} }
} }
@@ -251,7 +272,12 @@ public sealed partial class ListPage : Page,
{ {
if (ItemView.SelectedItem != item) if (ItemView.SelectedItem != item)
{ {
ItemView.SelectedItem = item; _scrollOnNextSelectionChange = true;
using (SuppressSelectionChangedScope())
{
ItemView.SelectedItem = item;
}
} }
ViewModel?.UpdateSelectedItemCommand.Execute(item); ViewModel?.UpdateSelectedItemCommand.Execute(item);
@@ -264,7 +290,7 @@ public sealed partial class ListPage : Page,
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>( WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage( new OpenContextMenuMessage(
element, element,
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft, FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos, pos,
ContextMenuFilterLocation.Top)); ContextMenuFilterLocation.Top));
}); });
@@ -274,7 +300,7 @@ public sealed partial class ListPage : Page,
private void Items_Loaded(object sender, RoutedEventArgs e) private void Items_Loaded(object sender, RoutedEventArgs e)
{ {
// Find the ScrollViewer in the ItemView (ItemsList or ItemsGrid) // Find the ScrollViewer in the ItemView (ItemsList or ItemsGrid)
var listViewScrollViewer = FindScrollViewer(this.ItemView); var listViewScrollViewer = FindScrollViewer(ItemView);
if (listViewScrollViewer is not null) if (listViewScrollViewer is not null)
{ {
@@ -300,12 +326,37 @@ public sealed partial class ListPage : Page,
} }
} }
// Message-driven navigation should count as keyboard.
private void MarkKeyboardNavigation() => _lastInputSource = InputSource.Keyboard;
private void PushSelectionToVm()
{
if (ViewModel is null)
{
return;
}
if (ItemView.SelectedItem is not ListItemViewModel li || IsSeparator(li))
{
ViewModel.UpdateSelectedItemCommand.Execute(null);
return;
}
if (ReferenceEquals(_lastPushedToVm, li))
{
return;
}
_lastPushedToVm = li;
_stickySelectedItem = li;
ViewModel.UpdateSelectedItemCommand.Execute(li);
}
public void Receive(NavigateNextCommand message) public void Receive(NavigateNextCommand message)
{ {
// Note: We may want to just have the notion of a 'SelectedCommand' in our VM MarkKeyboardNavigation();
// And then have these commands manipulate that state being bound to the UI instead _scrollOnNextSelectionChange = true;
// We may want to see how other non-list UIs need to behave to make this decision
// At least it's decoupled from the SearchBox now :)
if (ViewModel?.IsGridView == true) if (ViewModel?.IsGridView == true)
{ {
// For grid views, use spatial navigation (down) // For grid views, use spatial navigation (down)
@@ -316,10 +367,15 @@ public sealed partial class ListPage : Page,
// For list views, use simple linear navigation // For list views, use simple linear navigation
NavigateDown(); NavigateDown();
} }
PushSelectionToVm();
} }
public void Receive(NavigatePreviousCommand message) public void Receive(NavigatePreviousCommand message)
{ {
MarkKeyboardNavigation();
_scrollOnNextSelectionChange = true;
if (ViewModel?.IsGridView == true) if (ViewModel?.IsGridView == true)
{ {
// For grid views, use spatial navigation (up) // For grid views, use spatial navigation (up)
@@ -329,14 +385,20 @@ public sealed partial class ListPage : Page,
{ {
NavigateUp(); NavigateUp();
} }
PushSelectionToVm();
} }
public void Receive(NavigateLeftCommand message) public void Receive(NavigateLeftCommand message)
{ {
MarkKeyboardNavigation();
_scrollOnNextSelectionChange = true;
// For grid views, use spatial navigation. For list views, just move up. // For grid views, use spatial navigation. For list views, just move up.
if (ViewModel?.IsGridView == true) if (ViewModel?.IsGridView == true)
{ {
HandleGridArrowNavigation(VirtualKey.Left); HandleGridArrowNavigation(VirtualKey.Left);
PushSelectionToVm();
} }
else else
{ {
@@ -347,10 +409,14 @@ public sealed partial class ListPage : Page,
public void Receive(NavigateRightCommand message) public void Receive(NavigateRightCommand message)
{ {
MarkKeyboardNavigation();
_scrollOnNextSelectionChange = true;
// For grid views, use spatial navigation. For list views, just move down. // For grid views, use spatial navigation. For list views, just move down.
if (ViewModel?.IsGridView == true) if (ViewModel?.IsGridView == true)
{ {
HandleGridArrowNavigation(VirtualKey.Right); HandleGridArrowNavigation(VirtualKey.Right);
PushSelectionToVm();
} }
else else
{ {
@@ -385,6 +451,9 @@ public sealed partial class ListPage : Page,
public void Receive(NavigatePageDownCommand message) public void Receive(NavigatePageDownCommand message)
{ {
MarkKeyboardNavigation();
_scrollOnNextSelectionChange = true;
var indexes = CalculateTargetIndexPageUpDownScrollTo(true); var indexes = CalculateTargetIndexPageUpDownScrollTo(true);
if (indexes is null) if (indexes is null)
{ {
@@ -394,15 +463,16 @@ public sealed partial class ListPage : Page,
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex) if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
{ {
ItemView.SelectedIndex = indexes.Value.TargetIndex; ItemView.SelectedIndex = indexes.Value.TargetIndex;
if (ItemView.SelectedItem is not null)
{
ItemView.ScrollIntoView(ItemView.SelectedItem);
}
} }
PushSelectionToVm();
} }
public void Receive(NavigatePageUpCommand message) public void Receive(NavigatePageUpCommand message)
{ {
MarkKeyboardNavigation();
_scrollOnNextSelectionChange = true;
var indexes = CalculateTargetIndexPageUpDownScrollTo(false); var indexes = CalculateTargetIndexPageUpDownScrollTo(false);
if (indexes is null) if (indexes is null)
{ {
@@ -412,11 +482,9 @@ public sealed partial class ListPage : Page,
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex) if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
{ {
ItemView.SelectedIndex = indexes.Value.TargetIndex; ItemView.SelectedIndex = indexes.Value.TargetIndex;
if (ItemView.SelectedItem is not null)
{
ItemView.ScrollIntoView(ItemView.SelectedItem);
}
} }
PushSelectionToVm();
} }
/// <summary> /// <summary>
@@ -519,8 +587,8 @@ public sealed partial class ListPage : Page,
} }
var targetIndex = isPageDown var targetIndex = isPageDown
? Math.Min(itemCount - 1, currentIndex + Math.Max(1, itemsPerPage)) ? Math.Min(itemCount - 1, currentIndex + Math.Max(1, itemsPerPage))
: Math.Max(0, currentIndex - Math.Max(1, itemsPerPage)); : Math.Max(0, currentIndex - Math.Max(1, itemsPerPage));
return (currentIndex, targetIndex); return (currentIndex, targetIndex);
} }
@@ -531,13 +599,11 @@ public sealed partial class ListPage : Page,
{ {
if (e.OldValue is ListViewModel old) if (e.OldValue is ListViewModel old)
{ {
old.PropertyChanged -= @this.ViewModel_PropertyChanged;
old.ItemsUpdated -= @this.Page_ItemsUpdated; old.ItemsUpdated -= @this.Page_ItemsUpdated;
} }
if (e.NewValue is ListViewModel page) if (e.NewValue is ListViewModel page)
{ {
page.PropertyChanged += @this.ViewModel_PropertyChanged;
page.ItemsUpdated += @this.Page_ItemsUpdated; page.ItemsUpdated += @this.Page_ItemsUpdated;
} }
else if (e.NewValue is null) else if (e.NewValue is null)
@@ -549,83 +615,141 @@ public sealed partial class ListPage : Page,
// Called after we've finished updating the whole list for either a // Called after we've finished updating the whole list for either a
// GetItems or a change in the filter. // GetItems or a change in the filter.
private void Page_ItemsUpdated(ListViewModel sender, object args) private void Page_ItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args)
{ {
// If for some reason, we don't have a selected item, fix that. var version = Interlocked.Increment(ref _itemsUpdatedVersion);
// var forceFirstItem = args.ForceFirstItem;
// It's important to do this here, because once there's no selection
// (which can happen as the list updates) we won't get an // Try to handle selection immediately — items should already be available
// ItemView_SelectionChanged again to give us another chance to change // since FilteredItems is a direct ObservableCollection bound as ItemsSource.
// the selection from null -> something. Better to just update the if (!TrySetSelectionAfterUpdate(sender, version, forceFirstItem))
// selection once, at the end of all the updating.
// The selection logic must be deferred to the DispatcherQueue
// to ensure the UI has processed the updated ItemsSource binding,
// preventing ItemView.Items from appearing empty/null immediately after update.
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
{ {
var items = ItemView.Items; // Fallback: binding hasn't propagated yet, defer to next tick.
_ = DispatcherQueue.TryEnqueue(
Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
() =>
{
if (version != Volatile.Read(ref _itemsUpdatedVersion))
{
return;
}
// If the list is null or empty, clears the selection and return TrySetSelectionAfterUpdate(sender, version, forceFirstItem);
if (items is null || items.Count == 0) });
}
}
/// <summary>
/// Applies selection after an items update. Returns false if ItemView.Items
/// is not yet populated (caller should defer and retry).
/// </summary>
/// <param name="forceFirstItem">
/// When true, always select the first selectable item and scroll to top
/// (used for filter changes and top-level fetches).
/// </param>
private bool TrySetSelectionAfterUpdate(ListViewModel sender, long version, bool forceFirstItem)
{
if (version != Volatile.Read(ref _itemsUpdatedVersion))
{
return true; // superseded by a newer update, nothing to do
}
var vm = ViewModel;
if (vm is null)
{
return true;
}
// Use the stable source of truth, not ItemView.Items (which can be transiently empty)
if (vm.FilteredItems.Count == 0)
{
using (SuppressSelectionChangedScope())
{ {
ItemView.SelectedIndex = -1; ItemView.SelectedIndex = -1;
return; _stickySelectedItem = null;
_lastPushedToVm = null;
} }
// Finds the first item that is not a separator PushSelectionToVm();
var firstUsefulIndex = GetFirstSelectableIndex(); return true;
}
// If there is only separators in the list, don't select anything. // If ItemView.Items hasn't caught up with the ObservableCollection yet,
if (firstUsefulIndex == -1) // signal the caller to defer and retry.
var items = ItemView.Items;
if (items is null || items.Count == 0)
{
return false;
}
var firstUsefulIndex = GetFirstSelectableIndex();
if (firstUsefulIndex == -1)
{
using (SuppressSelectionChangedScope())
{ {
ItemView.SelectedIndex = -1; ItemView.SelectedIndex = -1;
_stickySelectedItem = null;
return; _lastPushedToVm = null;
} }
var shouldUpdateSelection = false; PushSelectionToVm();
return true;
}
// If it's a top level list update we force the reset to the top useful item var shouldUpdateSelection = forceFirstItem;
if (!sender.IsNested)
if (!shouldUpdateSelection)
{
// Check if selection needs repair (item gone, null, or separator).
if (ItemView.SelectedItem is null)
{ {
shouldUpdateSelection = true; shouldUpdateSelection = true;
} }
// No current selection or current selection is null
else if (ItemView.SelectedItem is null)
{
shouldUpdateSelection = true;
}
// The current selected item is a separator
else if (IsSeparator(ItemView.SelectedItem)) else if (IsSeparator(ItemView.SelectedItem))
{ {
shouldUpdateSelection = true; shouldUpdateSelection = true;
} }
// The selected item does not exist in the new list
else if (!items.Contains(ItemView.SelectedItem)) else if (!items.Contains(ItemView.SelectedItem))
{ {
shouldUpdateSelection = true; shouldUpdateSelection = true;
} }
if (shouldUpdateSelection)
{
if (firstUsefulIndex != -1)
{
ItemView.SelectedIndex = firstUsefulIndex;
}
}
});
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
var prop = e.PropertyName;
if (prop == nameof(ViewModel.FilteredItems))
{
Debug.WriteLine($"ViewModel.FilteredItems {ItemView.SelectedItem}");
} }
if (shouldUpdateSelection)
{
using (SuppressSelectionChangedScope())
{
if (!forceFirstItem &&
_stickySelectedItem is not null &&
items.Contains(_stickySelectedItem) &&
!IsSeparator(_stickySelectedItem))
{
// Preserve sticky selection for nested dynamic updates.
ItemView.SelectedItem = _stickySelectedItem;
}
else
{
// Select the first interactive item.
ItemView.SelectedItem = items[firstUsefulIndex];
}
// Prevent any pending "scroll on selection" logic from fighting this.
_scrollOnNextSelectionChange = false;
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
{
if (version != Volatile.Read(ref _itemsUpdatedVersion))
{
return;
}
ResetScrollToTop();
});
}
}
PushSelectionToVm();
return true;
} }
private static ScrollViewer? FindScrollViewer(DependencyObject parent) private static ScrollViewer? FindScrollViewer(DependencyObject parent)
@@ -653,7 +777,6 @@ public sealed partial class ListPage : Page,
{ {
if (ItemView.Items.Count == 0) if (ItemView.Items.Count == 0)
{ {
// No items, goodbye.
return; return;
} }
@@ -750,7 +873,6 @@ public sealed partial class ListPage : Page,
if (bestIndex != currentIndex) if (bestIndex != currentIndex)
{ {
ItemView.SelectedIndex = bestIndex; ItemView.SelectedIndex = bestIndex;
ItemView.ScrollIntoView(ItemView.SelectedItem);
} }
return; return;
@@ -773,7 +895,6 @@ public sealed partial class ListPage : Page,
if (fallback != currentIndex) if (fallback != currentIndex)
{ {
ItemView.SelectedIndex = fallback; ItemView.SelectedIndex = fallback;
ItemView.ScrollIntoView(ItemView.SelectedItem);
} }
} }
@@ -797,7 +918,12 @@ public sealed partial class ListPage : Page,
if (ItemView.SelectedItem != item) if (ItemView.SelectedItem != item)
{ {
ItemView.SelectedItem = item; _scrollOnNextSelectionChange = true;
using (SuppressSelectionChangedScope())
{
ItemView.SelectedItem = item;
}
} }
if (!e.TryGetPosition(element, out var pos)) if (!e.TryGetPosition(element, out var pos))
@@ -811,7 +937,7 @@ public sealed partial class ListPage : Page,
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>( WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage( new OpenContextMenuMessage(
element, element,
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft, FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos, pos,
ContextMenuFilterLocation.Top)); ContextMenuFilterLocation.Top));
}); });
@@ -844,6 +970,7 @@ public sealed partial class ListPage : Page,
case VirtualKey.Up: case VirtualKey.Up:
case VirtualKey.Down: case VirtualKey.Down:
_lastInputSource = InputSource.Keyboard; _lastInputSource = InputSource.Keyboard;
_scrollOnNextSelectionChange = true;
HandleGridArrowNavigation(e.Key); HandleGridArrowNavigation(e.Key);
e.Handled = true; e.Handled = true;
break; break;
@@ -1027,6 +1154,31 @@ public sealed partial class ListPage : Page,
private bool IsSeparator(object? item) => item is ListItemViewModel li && !li.IsInteractive; private bool IsSeparator(object? item) => item is ListItemViewModel li && !li.IsInteractive;
private bool IsSectionHeader(object? item) => item is ListItemViewModel li && li.Type == ListItemType.SectionHeader;
private void ResetScrollToTop()
{
var scroll = FindScrollViewer(ItemView);
if (scroll is null)
{
return;
}
// disableAnimation: true prevents a visible jump animation
scroll.ChangeView(horizontalOffset: null, verticalOffset: 0, zoomFactor: null, disableAnimation: true);
}
private IDisposable SuppressSelectionChangedScope()
{
_suppressSelectionChanged = true;
return new ActionOnDispose(() => _suppressSelectionChanged = false);
}
private sealed partial class ActionOnDispose(Action action) : IDisposable
{
public void Dispose() => action();
}
private enum InputSource private enum InputSource
{ {
None, None,

View File

@@ -277,49 +277,64 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
public void Receive(ShowDetailsMessage message) public void Receive(ShowDetailsMessage message)
{ {
if (ViewModel is not null && if (ViewModel is null || ViewModel.CurrentPage is null)
ViewModel.CurrentPage is not null)
{ {
if (ViewModel.CurrentPage.PageContext.TryGetTarget(out var pageContext)) return;
}
var details = message.Details;
var wasVisible = ViewModel.IsDetailsVisible;
// 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.
//
// Always debounce through the DispatcherQueue
// timer so the UI settles between updates. Use immediate=true for
// the first show so the panel appears without delay; subsequent
// updates during rapid navigation are coalesced.
_debounceTimer.Debounce(ShowDetails, interval: TimeSpan.FromMilliseconds(100), immediate: !wasVisible);
void ShowDetails()
{
// Since immediate=true means we're called synchronously from this method, we need to check
// if we're on the UI thread and re-queue if not.
if (!_queue.HasThreadAccess)
{ {
Task.Factory.StartNew( var enqueued = _queue.TryEnqueue(ShowDetails);
() => if (!enqueued)
{ {
// TERRIBLE HACK TODO GH #245 Logger.LogError("Failed to enqueue show details action on UI thread");
// 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: return;
// 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 try
// the current theme {
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage))); ViewModel.Details = details;
}, PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage)));
interval: TimeSpan.FromMilliseconds(50), ViewModel.IsDetailsVisible = true;
immediate: ViewModel.IsDetailsVisible == false); }
ViewModel.IsDetailsVisible = true; catch (Exception ex)
}, {
CancellationToken.None, Logger.LogError("Failed to show detail", ex);
TaskCreationOptions.None,
pageContext.Scheduler);
} }
} }
} }
public void Receive(HideDetailsMessage message) => HideDetails(); public void Receive(HideDetailsMessage message)
{
// Debounce the hide through the same timer used for show. If a
// ShowDetailsMessage arrives before this fires, it cancels the
// pending hide - preventing the panel from flickering closed and
// reopened during rapid item navigation.
_debounceTimer.Debounce(
() => HideDetails(),
interval: TimeSpan.FromMilliseconds(150),
immediate: false);
}
public void Receive(LaunchUriMessage message) => _ = global::Windows.System.Launcher.LaunchUriAsync(message.Uri); public void Receive(LaunchUriMessage message) => _ = global::Windows.System.Launcher.LaunchUriAsync(message.Uri);

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation // Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
@@ -17,6 +17,9 @@ namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass] [TestClass]
public partial class MainListPageResultFactoryTests public partial class MainListPageResultFactoryTests
{ {
private static readonly Separator _resultsSeparator = new("Results");
private static readonly Separator _fallbacksSeparator = new("Fallbacks");
private sealed partial class MockListItem : IListItem private sealed partial class MockListItem : IListItem
{ {
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
@@ -82,18 +85,22 @@ public partial class MainListPageResultFactoryTests
scoredFallback, scoredFallback,
apps, apps,
fallbacks, fallbacks,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 10); appResultLimit: 10);
// Expected order: // Expected order:
// "Results" section header
// 100: F1, SF1, A1 // 100: F1, SF1, A1
// 60: SF2 // 60: SF2
// 55: A2 // 55: A2
// 50: F2 // 50: F2
// "Fallbacks" section header
// Then fallbacks in original order: FB1, FB2 // Then fallbacks in original order: FB1, FB2
var titles = result.Select(r => r.Title).ToArray(); var titles = result.Select(r => r.Title).ToArray();
#pragma warning disable CA1861 // Avoid constant arrays as arguments #pragma warning disable CA1861 // Avoid constant arrays as arguments
CollectionAssert.AreEqual( CollectionAssert.AreEqual(
new[] { "F1", "SF1", "A1", "SF2", "A2", "F2", "Fallbacks", "FB1", "FB2" }, new[] { "Results", "F1", "SF1", "A1", "SF2", "A2", "F2", "Fallbacks", "FB1", "FB2" },
titles); titles);
#pragma warning restore CA1861 // Avoid constant arrays as arguments #pragma warning restore CA1861 // Avoid constant arrays as arguments
} }
@@ -113,11 +120,14 @@ public partial class MainListPageResultFactoryTests
null, null,
apps, apps,
null, null,
_resultsSeparator,
_fallbacksSeparator,
2); 2);
Assert.AreEqual(2, result.Length); Assert.AreEqual(3, result.Length);
Assert.AreEqual("A1", result[0].Title); Assert.AreEqual("Results", result[0].Title);
Assert.AreEqual("A2", result[1].Title); Assert.AreEqual("A1", result[1].Title);
Assert.AreEqual("A2", result[2].Title);
} }
[TestMethod] [TestMethod]
@@ -135,10 +145,13 @@ public partial class MainListPageResultFactoryTests
null, null,
apps, apps,
null, null,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 1); appResultLimit: 1);
Assert.AreEqual(1, result.Length); Assert.AreEqual(2, result.Length);
Assert.AreEqual("A1", result[0].Title); Assert.AreEqual("Results", result[0].Title);
Assert.AreEqual("A1", result[1].Title);
} }
[TestMethod] [TestMethod]
@@ -155,6 +168,8 @@ public partial class MainListPageResultFactoryTests
null, null,
apps, apps,
null, null,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 0); appResultLimit: 0);
Assert.AreEqual(0, result.Length); Assert.AreEqual(0, result.Length);
@@ -181,12 +196,15 @@ public partial class MainListPageResultFactoryTests
null, null,
apps, apps,
null, null,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 1); appResultLimit: 1);
Assert.AreEqual(3, result.Length); Assert.AreEqual(4, result.Length);
Assert.AreEqual("F1", result[0].Title); Assert.AreEqual("Results", result[0].Title);
Assert.AreEqual("A1", result[1].Title); Assert.AreEqual("F1", result[1].Title);
Assert.AreEqual("F2", result[2].Title); Assert.AreEqual("A1", result[2].Title);
Assert.AreEqual("F2", result[3].Title);
} }
[TestMethod] [TestMethod]
@@ -203,6 +221,8 @@ public partial class MainListPageResultFactoryTests
null, null,
null, null,
fallbacks, fallbacks,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 10); appResultLimit: 10);
Assert.AreEqual(3, result.Length); Assert.AreEqual(3, result.Length);
@@ -219,6 +239,8 @@ public partial class MainListPageResultFactoryTests
null, null,
null, null,
null, null,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 10); appResultLimit: 10);
Assert.IsNotNull(result); Assert.IsNotNull(result);

View File

@@ -0,0 +1,746 @@
// 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;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
[TestClass]
public class ListHelpersInPlaceUpdateTests
{
// Use a reference-type wrapper so tests work with the `where T : class` constraint
// and we can verify identity (same instance) in removedItems tests.
private sealed class Item(string name)
{
public string Name { get; } = name;
public override string ToString() => Name;
public override bool Equals(object? obj) => obj is Item other && Name == other.Name;
public override int GetHashCode() => Name.GetHashCode();
}
private static Item[] MakeItems(params string[] names) =>
names.Select(n => new Item(n)).ToArray();
private static void AssertSequence(IList<Item> actual, params string[] expected)
{
var actualNames = actual.Select(i => i.Name).ToArray();
CollectionAssert.AreEqual(expected, actualNames, $"Expected [{string.Join(", ", expected)}] but got [{string.Join(", ", actualNames)}]");
}
private static void AssertRemovedContainsExactly(List<Item> removedItems, IList<Item> originalItems, IList<Item> newItems)
{
// removedItems should contain exactly the items from original that are not in newItems
var newSet = new HashSet<Item>(newItems);
var expectedRemoved = originalItems.Where(i => !newSet.Contains(i)).ToList();
// Same count
Assert.AreEqual(expectedRemoved.Count, removedItems.Count, $"Expected {expectedRemoved.Count} removed items but got {removedItems.Count}");
// Same instances (by reference, since we're checking cleanup correctness)
foreach (var expected in expectedRemoved)
{
Assert.IsTrue(removedItems.Contains(expected), $"Expected '{expected.Name}' in removedItems but it was missing");
}
}
[TestMethod]
public void IdenticalLists_NoChanges()
{
var items = MakeItems("A", "B", "C");
var original = new ObservableCollection<Item>(items);
var newContents = items.ToList(); // same items, same order
ListHelpers.InPlaceUpdateList(original, newContents, out var removed);
AssertSequence(original, "A", "B", "C");
Assert.AreEqual(0, removed.Count);
}
[TestMethod]
public void EmptyToNonEmpty_AddsAll()
{
var original = new ObservableCollection<Item>();
var newItems = MakeItems("A", "B", "C");
ListHelpers.InPlaceUpdateList(original, newItems, out var removed);
AssertSequence(original, "A", "B", "C");
Assert.AreEqual(0, removed.Count);
}
[TestMethod]
public void NonEmptyToEmpty_RemovesAll()
{
var items = MakeItems("A", "B", "C");
var original = new ObservableCollection<Item>(items);
ListHelpers.InPlaceUpdateList(original, [], out var removed);
Assert.AreEqual(0, original.Count);
Assert.AreEqual(3, removed.Count);
}
[TestMethod]
public void SingleItem_Replace()
{
var a = new Item("A");
var b = new Item("B");
var original = new ObservableCollection<Item> { a };
ListHelpers.InPlaceUpdateList(original, [b], out var removed);
AssertSequence(original, "B");
Assert.AreEqual(1, removed.Count);
Assert.AreSame(a, removed[0]);
}
[TestMethod]
public void FilterDown_RemovesNonMatching()
{
var items = MakeItems("A", "B", "C", "D", "E");
var original = new ObservableCollection<Item>(items);
var filtered = new[] { items[0], items[2], items[4] }; // A, C, E
ListHelpers.InPlaceUpdateList(original, filtered, out var removed);
AssertSequence(original, "A", "C", "E");
Assert.AreEqual(2, removed.Count); // B, D removed
AssertRemovedContainsExactly(removed, items, filtered);
}
[TestMethod]
public void FilterDown_EveryOtherItem()
{
var items = MakeItems("A", "B", "C", "D", "E", "F", "G", "H");
var original = new ObservableCollection<Item>(items);
var filtered = new[] { items[1], items[3], items[5], items[7] }; // B, D, F, H
ListHelpers.InPlaceUpdateList(original, filtered, out var removed);
AssertSequence(original, "B", "D", "F", "H");
Assert.AreEqual(4, removed.Count);
AssertRemovedContainsExactly(removed, items, filtered);
}
[TestMethod]
public void Expand_InsertsNewItems()
{
var items = MakeItems("A", "C", "E");
var original = new ObservableCollection<Item>(items);
var b = new Item("B");
var d = new Item("D");
var expanded = new[] { items[0], b, items[1], d, items[2] }; // A, B, C, D, E
ListHelpers.InPlaceUpdateList(original, expanded, out var removed);
AssertSequence(original, "A", "B", "C", "D", "E");
Assert.AreEqual(0, removed.Count);
}
[TestMethod]
public void Reversed_ReordersCorrectly()
{
var items = MakeItems("A", "B", "C", "D", "E");
var original = new ObservableCollection<Item>(items);
var reversed = items.Reverse().ToArray();
ListHelpers.InPlaceUpdateList(original, reversed, out var removed);
AssertSequence(original, "E", "D", "C", "B", "A");
Assert.AreEqual(0, removed.Count);
}
[TestMethod]
public void MoveFirstToLast()
{
var items = MakeItems("A", "B", "C", "D");
var original = new ObservableCollection<Item>(items);
var reordered = new[] { items[1], items[2], items[3], items[0] }; // B, C, D, A
ListHelpers.InPlaceUpdateList(original, reordered, out var removed);
AssertSequence(original, "B", "C", "D", "A");
Assert.AreEqual(0, removed.Count);
}
[TestMethod]
public void MoveLastToFirst()
{
var items = MakeItems("A", "B", "C", "D");
var original = new ObservableCollection<Item>(items);
var reordered = new[] { items[3], items[0], items[1], items[2] }; // D, A, B, C
ListHelpers.InPlaceUpdateList(original, reordered, out var removed);
AssertSequence(original, "D", "A", "B", "C");
Assert.AreEqual(0, removed.Count);
}
[TestMethod]
public void NoOverlap_ReplacesAll()
{
var oldItems = MakeItems("A", "B", "C");
var newItems = MakeItems("X", "Y", "Z");
var original = new ObservableCollection<Item>(oldItems);
ListHelpers.InPlaceUpdateList(original, newItems, out var removed);
AssertSequence(original, "X", "Y", "Z");
Assert.AreEqual(3, removed.Count);
AssertRemovedContainsExactly(removed, oldItems, newItems);
}
[TestMethod]
public void NoOverlap_DifferentSizes_OriginalLarger()
{
var oldItems = MakeItems("A", "B", "C", "D", "E");
var newItems = MakeItems("X", "Y");
var original = new ObservableCollection<Item>(oldItems);
ListHelpers.InPlaceUpdateList(original, newItems, out var removed);
AssertSequence(original, "X", "Y");
Assert.AreEqual(5, removed.Count);
}
[TestMethod]
public void NoOverlap_DifferentSizes_NewLarger()
{
var oldItems = MakeItems("A", "B");
var newItems = MakeItems("X", "Y", "Z", "W");
var original = new ObservableCollection<Item>(oldItems);
ListHelpers.InPlaceUpdateList(original, newItems, out var removed);
AssertSequence(original, "X", "Y", "Z", "W");
Assert.AreEqual(2, removed.Count);
}
[TestMethod]
public void MixedRemoveAndReorder()
{
var items = MakeItems("A", "X", "Y", "C", "B");
var original = new ObservableCollection<Item>(items);
// Keep A, B, C but reorder; remove X, Y
var newList = new[] { items[0], items[4], items[3] }; // A, B, C
ListHelpers.InPlaceUpdateList(original, newList, out var removed);
AssertSequence(original, "A", "B", "C");
Assert.AreEqual(2, removed.Count);
AssertRemovedContainsExactly(removed, items, newList);
}
[TestMethod]
public void MixedAddRemoveReorder()
{
var items = MakeItems("A", "B", "C", "D");
var original = new ObservableCollection<Item>(items);
var e = new Item("E");
// Remove B, D; add E; reorder to C, A, E
var newList = new[] { items[2], items[0], e }; // C, A, E
ListHelpers.InPlaceUpdateList(original, newList, out var removed);
AssertSequence(original, "C", "A", "E");
Assert.AreEqual(2, removed.Count); // B, D
AssertRemovedContainsExactly(removed, items, newList);
}
[TestMethod]
public void ItemsBetweenCurrentAndFoundAreInNewList_NotRemovedIncorrectly()
{
// This is the scenario that caused the icon bug:
// Items between the current position and the found target
// appear later in newList and must NOT be put in removedItems.
var items = MakeItems("A", "B", "C", "D", "E");
var original = new ObservableCollection<Item>(items);
// Reverse: items B, C, D are between position 0 and E's position
// but all appear in newList
var reversed = new[] { items[4], items[3], items[2], items[1], items[0] };
ListHelpers.InPlaceUpdateList(original, reversed, out var removed);
AssertSequence(original, "E", "D", "C", "B", "A");
Assert.AreEqual(0, removed.Count, "No items should be removed when all items are reused");
// Verify all original instances are still in the collection (not cleaned up)
foreach (var item in items)
{
Assert.IsTrue(original.Contains(item), $"Item '{item.Name}' should still be in the collection (same instance)");
}
}
[TestMethod]
public void RemovedItems_NeverContainsItemsStillInNewList()
{
// Simulate the exact FetchItems scenario: reuse ViewModel instances
var a = new Item("A");
var b = new Item("B");
var c = new Item("C");
var d = new Item("D");
var e = new Item("E");
var original = new ObservableCollection<Item> { a, b, c, d, e };
// New list reuses same instances but in different order, minus some
var newList = new Item[] { e, c, a }; // reversed subset
ListHelpers.InPlaceUpdateList(original, newList, out var removed);
AssertSequence(original, "E", "C", "A");
// Critical: removed should only contain b and d
Assert.AreEqual(2, removed.Count);
Assert.IsTrue(removed.Contains(b), "B should be in removedItems");
Assert.IsTrue(removed.Contains(d), "D should be in removedItems");
// Critical: removed must NOT contain items still in the list
Assert.IsFalse(removed.Contains(a), "A is still in use — must not be in removedItems");
Assert.IsFalse(removed.Contains(c), "C is still in use — must not be in removedItems");
Assert.IsFalse(removed.Contains(e), "E is still in use — must not be in removedItems");
}
[TestMethod]
public void WorksWithPlainList()
{
var items = MakeItems("A", "B", "C", "D");
var original = new List<Item>(items);
var newList = new[] { items[2], items[0] }; // C, A
ListHelpers.InPlaceUpdateList(original, newList, out var removed);
AssertSequence(original, "C", "A");
Assert.AreEqual(2, removed.Count);
}
[TestMethod]
public void TwoArgOverload_ProducesCorrectResult()
{
var items = MakeItems("A", "B", "C");
var original = new ObservableCollection<Item>(items);
var newList = new[] { items[2], items[0] }; // C, A
ListHelpers.InPlaceUpdateList(original, newList);
AssertSequence(original, "C", "A");
}
[TestMethod]
public void AcceptsLazyEnumerable()
{
var items = MakeItems("A", "B", "C");
var original = new ObservableCollection<Item>(items);
// Pass a lazy IEnumerable (not materialized)
IEnumerable<Item> lazy = items.Reverse().Where(_ => true);
ListHelpers.InPlaceUpdateList(original, lazy, out var removed);
AssertSequence(original, "C", "B", "A");
Assert.AreEqual(0, removed.Count);
}
[TestMethod]
public void IncrementalSearch_ProgressiveFiltering()
{
// Simulate typing a search query character by character
var all = MakeItems("Apple", "Banana", "Avocado", "Blueberry", "Apricot");
var original = new ObservableCollection<Item>(all);
// First keystroke "A" — filter to A items
var filtered1 = new[] { all[0], all[2], all[4] }; // Apple, Avocado, Apricot
ListHelpers.InPlaceUpdateList(original, filtered1, out var removed1);
AssertSequence(original, "Apple", "Avocado", "Apricot");
Assert.AreEqual(2, removed1.Count);
// Second keystroke "Ap" — filter further
var filtered2 = new[] { all[0], all[4] }; // Apple, Apricot
ListHelpers.InPlaceUpdateList(original, filtered2, out var removed2);
AssertSequence(original, "Apple", "Apricot");
Assert.AreEqual(1, removed2.Count);
// Clear search — back to all
ListHelpers.InPlaceUpdateList(original, all, out var removed3);
AssertSequence(original, "Apple", "Banana", "Avocado", "Blueberry", "Apricot");
Assert.AreEqual(0, removed3.Count);
}
[TestMethod]
public void PageNavigation_CompleteReplacement()
{
// Simulate navigating from one extension page to another
var page1 = MakeItems("P1A", "P1B", "P1C", "P1D");
var page2 = MakeItems("P2A", "P2B", "P2C");
var original = new ObservableCollection<Item>(page1);
ListHelpers.InPlaceUpdateList(original, page2, out var removed1);
AssertSequence(original, "P2A", "P2B", "P2C");
Assert.AreEqual(4, removed1.Count);
// Navigate back
ListHelpers.InPlaceUpdateList(original, page1, out var removed2);
AssertSequence(original, "P1A", "P1B", "P1C", "P1D");
Assert.AreEqual(3, removed2.Count);
}
[TestMethod]
public void StableItems_SameInstancePreserved()
{
var a = new Item("A");
var b = new Item("B");
var c = new Item("C");
var original = new ObservableCollection<Item> { a, b, c };
// Remove middle item
ListHelpers.InPlaceUpdateList(original, [a, c]);
Assert.AreSame(a, original[0], "A should be the same instance");
Assert.AreSame(c, original[1], "C should be the same instance");
}
[TestMethod]
public void ZeroOverlap_UsesReplaceNotInsertRemove()
{
// Track notifications to verify Replace path is used
var oldItems = MakeItems("A", "B", "C");
var newItems = MakeItems("X", "Y", "Z");
var original = new ObservableCollection<Item>(oldItems);
var notifications = new List<System.Collections.Specialized.NotifyCollectionChangedAction>();
original.CollectionChanged += (_, e) => notifications.Add(e.Action);
ListHelpers.InPlaceUpdateList(original, newItems, out var removed);
AssertSequence(original, "X", "Y", "Z");
Assert.AreEqual(3, removed.Count);
// All notifications should be Replace (not Add/Remove pairs)
Assert.IsTrue(
notifications.All(a => a == System.Collections.Specialized.NotifyCollectionChangedAction.Replace),
$"Expected all Replace but got: [{string.Join(", ", notifications)}]");
}
[TestMethod]
public void ZeroOverlap_ShrinkingList_ReplaceThenRemove()
{
var oldItems = MakeItems("A", "B", "C", "D", "E");
var newItems = MakeItems("X", "Y");
var original = new ObservableCollection<Item>(oldItems);
var notifications = new List<System.Collections.Specialized.NotifyCollectionChangedAction>();
original.CollectionChanged += (_, e) => notifications.Add(e.Action);
ListHelpers.InPlaceUpdateList(original, newItems, out var removed);
AssertSequence(original, "X", "Y");
Assert.AreEqual(5, removed.Count);
// 2 Replace + 3 Remove
var replaces = notifications.Count(a => a == System.Collections.Specialized.NotifyCollectionChangedAction.Replace);
var removes = notifications.Count(a => a == System.Collections.Specialized.NotifyCollectionChangedAction.Remove);
Assert.AreEqual(2, replaces);
Assert.AreEqual(3, removes);
}
[TestMethod]
public void ZeroOverlap_GrowingList_ReplaceThenAdd()
{
var oldItems = MakeItems("A", "B");
var newItems = MakeItems("X", "Y", "Z", "W");
var original = new ObservableCollection<Item>(oldItems);
var notifications = new List<System.Collections.Specialized.NotifyCollectionChangedAction>();
original.CollectionChanged += (_, e) => notifications.Add(e.Action);
ListHelpers.InPlaceUpdateList(original, newItems, out var removed);
AssertSequence(original, "X", "Y", "Z", "W");
Assert.AreEqual(2, removed.Count);
// 2 Replace + 2 Add
var replaces = notifications.Count(a => a == System.Collections.Specialized.NotifyCollectionChangedAction.Replace);
var adds = notifications.Count(a => a == System.Collections.Specialized.NotifyCollectionChangedAction.Add);
Assert.AreEqual(2, replaces);
Assert.AreEqual(2, adds);
}
[TestMethod]
public void TwoArg_IdenticalLists_NoChanges()
{
var items = MakeItems("A", "B", "C");
var original = new ObservableCollection<Item>(items);
ListHelpers.InPlaceUpdateList(original, items.ToList());
AssertSequence(original, "A", "B", "C");
}
[TestMethod]
public void TwoArg_EmptyToNonEmpty()
{
var original = new ObservableCollection<Item>();
ListHelpers.InPlaceUpdateList(original, MakeItems("A", "B", "C"));
AssertSequence(original, "A", "B", "C");
}
[TestMethod]
public void TwoArg_NonEmptyToEmpty()
{
var original = new ObservableCollection<Item>(MakeItems("A", "B", "C"));
ListHelpers.InPlaceUpdateList(original, Array.Empty<Item>());
Assert.AreEqual(0, original.Count);
}
[TestMethod]
public void TwoArg_FilterDown()
{
var items = MakeItems("A", "B", "C", "D", "E");
var original = new ObservableCollection<Item>(items);
ListHelpers.InPlaceUpdateList(original, new[] { items[0], items[2], items[4] });
AssertSequence(original, "A", "C", "E");
}
[TestMethod]
public void TwoArg_FilterDown_EveryOtherItem()
{
var items = MakeItems("A", "B", "C", "D", "E", "F", "G", "H");
var original = new ObservableCollection<Item>(items);
ListHelpers.InPlaceUpdateList(original, new[] { items[1], items[3], items[5], items[7] });
AssertSequence(original, "B", "D", "F", "H");
}
[TestMethod]
public void TwoArg_Expand()
{
var items = MakeItems("A", "C", "E");
var original = new ObservableCollection<Item>(items);
var b = new Item("B");
var d = new Item("D");
ListHelpers.InPlaceUpdateList(original, new[] { items[0], b, items[1], d, items[2] });
AssertSequence(original, "A", "B", "C", "D", "E");
}
[TestMethod]
public void TwoArg_Reversed()
{
var items = MakeItems("A", "B", "C", "D", "E");
var original = new ObservableCollection<Item>(items);
ListHelpers.InPlaceUpdateList(original, items.Reverse());
AssertSequence(original, "E", "D", "C", "B", "A");
}
[TestMethod]
public void TwoArg_MoveFirstToLast()
{
var items = MakeItems("A", "B", "C", "D");
var original = new ObservableCollection<Item>(items);
ListHelpers.InPlaceUpdateList(original, new[] { items[1], items[2], items[3], items[0] });
AssertSequence(original, "B", "C", "D", "A");
}
[TestMethod]
public void TwoArg_MoveLastToFirst()
{
var items = MakeItems("A", "B", "C", "D");
var original = new ObservableCollection<Item>(items);
ListHelpers.InPlaceUpdateList(original, new[] { items[3], items[0], items[1], items[2] });
AssertSequence(original, "D", "A", "B", "C");
}
[TestMethod]
public void TwoArg_NoOverlap_ReplacesAll()
{
var original = new ObservableCollection<Item>(MakeItems("A", "B", "C"));
ListHelpers.InPlaceUpdateList(original, MakeItems("X", "Y", "Z"));
AssertSequence(original, "X", "Y", "Z");
}
[TestMethod]
public void TwoArg_NoOverlap_OriginalLarger()
{
var original = new ObservableCollection<Item>(MakeItems("A", "B", "C", "D", "E"));
ListHelpers.InPlaceUpdateList(original, MakeItems("X", "Y"));
AssertSequence(original, "X", "Y");
}
[TestMethod]
public void TwoArg_NoOverlap_NewLarger()
{
var original = new ObservableCollection<Item>(MakeItems("A", "B"));
ListHelpers.InPlaceUpdateList(original, MakeItems("X", "Y", "Z", "W"));
AssertSequence(original, "X", "Y", "Z", "W");
}
[TestMethod]
public void TwoArg_MixedRemoveAndReorder()
{
var items = MakeItems("A", "X", "Y", "C", "B");
var original = new ObservableCollection<Item>(items);
ListHelpers.InPlaceUpdateList(original, new[] { items[0], items[4], items[3] });
AssertSequence(original, "A", "B", "C");
}
[TestMethod]
public void TwoArg_MixedAddRemoveReorder()
{
var items = MakeItems("A", "B", "C", "D");
var original = new ObservableCollection<Item>(items);
var e = new Item("E");
ListHelpers.InPlaceUpdateList(original, new[] { items[2], items[0], e });
AssertSequence(original, "C", "A", "E");
}
[TestMethod]
public void TwoArg_IncrementalSearch()
{
var all = MakeItems("Apple", "Banana", "Avocado", "Blueberry", "Apricot");
var original = new ObservableCollection<Item>(all);
// "A" filter
ListHelpers.InPlaceUpdateList(original, new[] { all[0], all[2], all[4] });
AssertSequence(original, "Apple", "Avocado", "Apricot");
// "Ap" filter
ListHelpers.InPlaceUpdateList(original, new[] { all[0], all[4] });
AssertSequence(original, "Apple", "Apricot");
// Clear
ListHelpers.InPlaceUpdateList(original, all);
AssertSequence(original, "Apple", "Banana", "Avocado", "Blueberry", "Apricot");
}
[TestMethod]
public void TwoArg_PageNavigation()
{
var page1 = MakeItems("P1A", "P1B", "P1C", "P1D");
var page2 = MakeItems("P2A", "P2B", "P2C");
var original = new ObservableCollection<Item>(page1);
ListHelpers.InPlaceUpdateList(original, page2);
AssertSequence(original, "P2A", "P2B", "P2C");
ListHelpers.InPlaceUpdateList(original, page1);
AssertSequence(original, "P1A", "P1B", "P1C", "P1D");
}
[TestMethod]
public void TwoArg_WorksWithPlainList()
{
var items = MakeItems("A", "B", "C", "D");
var original = new List<Item>(items);
ListHelpers.InPlaceUpdateList(original, new[] { items[2], items[0] });
AssertSequence(original, "C", "A");
}
[TestMethod]
public void TwoArg_AcceptsLazyEnumerable()
{
var items = MakeItems("A", "B", "C");
var original = new ObservableCollection<Item>(items);
IEnumerable<Item> lazy = items.Reverse().Where(_ => true);
ListHelpers.InPlaceUpdateList(original, lazy);
AssertSequence(original, "C", "B", "A");
}
[TestMethod]
public void TwoArg_SingleItemReplace()
{
var original = new ObservableCollection<Item> { new Item("A") };
ListHelpers.InPlaceUpdateList(original, new[] { new Item("B") });
AssertSequence(original, "B");
}
[TestMethod]
public void BothOverloads_ProduceSameResult_FilterAndReorder()
{
var items = MakeItems("A", "B", "C", "D", "E", "F");
var newList = new[] { items[4], items[2], items[0], new Item("G") }; // E, C, A, G
var original1 = new ObservableCollection<Item>(items);
var original2 = new ObservableCollection<Item>(items);
ListHelpers.InPlaceUpdateList(original1, newList);
ListHelpers.InPlaceUpdateList(original2, newList, out _);
var names1 = original1.Select(i => i.Name).ToArray();
var names2 = original2.Select(i => i.Name).ToArray();
CollectionAssert.AreEqual(names1, names2, "Both overloads should produce identical results");
}
[TestMethod]
public void BothOverloads_ProduceSameResult_CompleteReversal()
{
var items = MakeItems("A", "B", "C", "D", "E");
var reversed = items.Reverse().ToArray();
var original1 = new ObservableCollection<Item>(items);
var original2 = new ObservableCollection<Item>(items);
ListHelpers.InPlaceUpdateList(original1, reversed);
ListHelpers.InPlaceUpdateList(original2, reversed, out _);
var names1 = original1.Select(i => i.Name).ToArray();
var names2 = original2.Select(i => i.Name).ToArray();
CollectionAssert.AreEqual(names1, names2, "Both overloads should produce identical results");
}
[TestMethod]
public void BothOverloads_ProduceSameResult_NoOverlap()
{
var oldItems = MakeItems("A", "B", "C", "D");
var newItems = MakeItems("W", "X", "Y");
var original1 = new ObservableCollection<Item>(oldItems);
var original2 = new ObservableCollection<Item>(oldItems);
ListHelpers.InPlaceUpdateList(original1, newItems);
ListHelpers.InPlaceUpdateList(original2, newItems, out _);
var names1 = original1.Select(i => i.Name).ToArray();
var names2 = original2.Select(i => i.Name).ToArray();
CollectionAssert.AreEqual(names1, names2, "Both overloads should produce identical results");
}
}

View File

@@ -2,6 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
namespace Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class ListHelpers public partial class ListHelpers
@@ -58,84 +60,91 @@ public partial class ListHelpers
} }
/// <summary> /// <summary>
/// Modifies the contents of `original` in-place, to match those of /// Modifies the contents of <paramref name="original"/> in-place, to match those of
/// `newContents`. The canonical use being: /// <paramref name="newContents"/>.
/// ```cs /// <example>
/// The canonical use being:
/// <code>
/// ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(ItemsToFilter, TextToFilterOn)); /// ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(ItemsToFilter, TextToFilterOn));
/// ``` /// </code>
/// </example>
/// </summary> /// </summary>
/// <typeparam name="T">Any type that can be compared for equality</typeparam> /// <typeparam name="T">Any type that can be compared for equality</typeparam>
/// <param name="original">Collection to modify</param> /// <param name="original">Collection to modify</param>
/// <param name="newContents">The enumerable which `original` should match</param> /// <param name="newContents">The enumerable which <c>original</c> should match</param>
public static void InPlaceUpdateList<T>(IList<T> original, IEnumerable<T> newContents) public static void InPlaceUpdateList<T>(IList<T> original, IEnumerable<T> newContents)
where T : class where T : class
{ {
InPlaceUpdateList(original, newContents, out _); // Materialize once to avoid re-enumeration
} var newList = newContents as IList<T> ?? newContents.ToList();
var numberOfNew = newList.Count;
/// <summary> // Detect if we can use Move() for better ObservableCollection performance
/// Modifies the contents of `original` in-place, to match those of var observableCollection = original as ObservableCollection<T>;
/// `newContents`. The canonical use being:
/// ```cs
/// ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(ItemsToFilter, TextToFilterOn));
/// ```
/// </summary>
/// <typeparam name="T">Any type that can be compared for equality</typeparam>
/// <param name="original">Collection to modify</param>
/// <param name="newContents">The enumerable which `original` should match</param>
/// <param name="removedItems">List of items that were removed from the original collection</param>
public static void InPlaceUpdateList<T>(IList<T> original, IEnumerable<T> newContents, out List<T> removedItems)
where T : class
{
removedItems = [];
// we're not changing newContents - stash this so we don't re-evaluate it every time
var numberOfNew = newContents.Count();
// Short circuit - new contents should just be empty // Short circuit - new contents should just be empty
if (numberOfNew == 0) if (numberOfNew == 0)
{ {
removedItems.AddRange(original); if (observableCollection is not null)
original.Clear(); {
// Clear() is observable collection causes a reset notification, which causes
// the ListView to discard all containers and recreate them, which is expensive and
// causes ListView to flash.
while (observableCollection.Count > 0)
{
observableCollection.RemoveAt(observableCollection.Count - 1);
}
}
else
{
original.Clear();
}
return; return;
} }
// Simple forward-scan merge. No HashSet needed because we don't track
// removed items — the icon-bug guard is unnecessary, and items removed
// mid-merge that appear later in newList will simply be re-inserted.
var i = 0; var i = 0;
foreach (var newItem in newContents) for (var newIndex = 0; newIndex < numberOfNew; newIndex++)
{ {
var newItem = newList[newIndex];
if (i >= original.Count) if (i >= original.Count)
{ {
break; break;
} }
// Search for this item in the remaining portion of original
var foundIndex = -1;
for (var j = i; j < original.Count; j++) for (var j = i; j < original.Count; j++)
{ {
var og_2 = original[j]; if (original[j]?.Equals(newItem) ?? false)
var areEqual_2 = og_2?.Equals(newItem) ?? false;
if (areEqual_2)
{ {
for (var k = i; k < j; k++) foundIndex = j;
{
// This item from the original list was not in the new list. Remove it.
removedItems.Add(original[i]);
original.RemoveAt(i);
}
break; break;
} }
} }
var og = original[i]; if (foundIndex >= 0)
var areEqual = og?.Equals(newItem) ?? false;
// Is this new item already in the list?
if (areEqual)
{ {
// It is already in the list // Remove all items between i and foundIndex
for (var k = foundIndex - 1; k >= i; k--)
{
original.RemoveAt(k);
foundIndex--;
}
// If the found item isn't at position i yet, move it there
if (foundIndex > i)
{
MoveItem(original, observableCollection, foundIndex, i);
}
} }
else else
{ {
// it isn't. Add it. // Not found - insert new item at position i
original.Insert(i, newItem); original.Insert(i, newItem);
} }
@@ -145,19 +154,283 @@ public partial class ListHelpers
// Remove any extra trailing items from the destination // Remove any extra trailing items from the destination
while (original.Count > numberOfNew) while (original.Count > numberOfNew)
{ {
// RemoveAtEnd
removedItems.Add(original[original.Count - 1]);
original.RemoveAt(original.Count - 1); original.RemoveAt(original.Count - 1);
} }
// Add any extra trailing items from the source // Add any extra trailing items from the source
if (original.Count < numberOfNew) while (i < numberOfNew)
{ {
var remaining = newContents.Skip(original.Count); original.Add(newList[i]);
foreach (var item in remaining) i++;
}
}
/// <summary>
/// Modifies the contents of <paramref name="original"/> in-place, to match those of
/// <paramref name="newContents"/>.
/// <example>
/// The canonical use being:
/// <code>
/// ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(ItemsToFilter, TextToFilterOn), out var removedItems);
/// </code>
/// </example>
/// </summary>
/// <typeparam name="T">Any type that can be compared for equality</typeparam>
/// <param name="original">Collection to modify</param>
/// <param name="newContents">The enumerable which <c>original</c> should match</param>
/// <param name="removedItems">List of items that were removed from the original collection</param>
public static void InPlaceUpdateList<T>(IList<T> original, IEnumerable<T> newContents, out List<T> removedItems)
where T : class
{
removedItems = [];
// Materialize once to avoid re-enumeration
var newList = newContents as IList<T> ?? newContents.ToList();
var numberOfNew = newList.Count;
// Short circuit - new contents should just be empty
if (numberOfNew == 0)
{
while (original.Count > 0)
{ {
original.Add(item); removedItems.Add(original[^1]);
original.RemoveAt(original.Count - 1);
} }
return;
}
// Detect if we can use Move() for better ObservableCollection performance
var observableCollection = original as ObservableCollection<T>;
// Build a set of new items for O(1) existence checks.
var newSet = new HashSet<T>(numberOfNew);
for (var i = 0; i < numberOfNew; i++)
{
newSet.Add(newList[i]);
}
// When there is zero overlap (e.g. navigating between pages), use
// indexed replacement instead of Insert + Remove. Each Replace reuses
// the container slot and fires one notification instead of two.
var hasOverlap = false;
for (var i = 0; i < original.Count; i++)
{
if (newSet.Contains(original[i]))
{
hasOverlap = true;
break;
}
}
if (!hasOverlap)
{
var minLen = Math.Min(original.Count, numberOfNew);
for (var i = 0; i < minLen; i++)
{
removedItems.Add(original[i]);
original[i] = newList[i]; // Replace — single notification, container reused
}
while (original.Count > numberOfNew)
{
removedItems.Add(original[^1]);
original.RemoveAt(original.Count - 1);
}
for (var i = minLen; i < numberOfNew; i++)
{
original.Add(newList[i]);
}
return;
}
// Large collections benefit from pre-filtering (O(n) removal shrinks the
// working set), which outweighs the extra pass. Small collections are
// faster with lazy removal during the merge. Threshold determined empirically.
if (original.Count >= 5000)
{
MergeWithPreRemoval(original, newList, numberOfNew, newSet, observableCollection, removedItems);
}
else
{
MergeWithLazyRemoval(original, newList, numberOfNew, newSet, observableCollection, removedItems);
}
}
/// <summary>
/// Fast path for small/medium collections. Removes non-matching items lazily
/// during the forward-scan merge, avoiding a separate pre-removal pass.
/// </summary>
private static void MergeWithLazyRemoval<T>(
IList<T> original,
IList<T> newList,
int numberOfNew,
HashSet<T> newSet,
ObservableCollection<T>? observableCollection,
List<T>? removedItems)
where T : class
{
var i = 0;
for (var newIndex = 0; newIndex < numberOfNew; newIndex++)
{
var newItem = newList[newIndex];
if (i >= original.Count)
{
break;
}
// Search for this item in the remaining portion of original
var foundIndex = -1;
for (var j = i; j < original.Count; j++)
{
if (original[j]?.Equals(newItem) ?? false)
{
foundIndex = j;
break;
}
}
if (foundIndex >= 0)
{
// Remove only items between i and foundIndex that are NOT in newList.
// Items still needed later stay in the collection, avoiding
// unnecessary Remove+Insert cycles and extra UI notifications.
for (var k = foundIndex - 1; k >= i; k--)
{
if (!newSet.Contains(original[k]))
{
removedItems?.Add(original[k]);
original.RemoveAt(k);
foundIndex--;
}
}
// If the found item isn't at position i yet, move it there
if (foundIndex > i)
{
MoveItem(original, observableCollection, foundIndex, i);
}
}
else
{
// Not found - insert new item at position i
original.Insert(i, newItem);
}
i++;
}
// Remove any extra trailing items from the destination
while (original.Count > numberOfNew)
{
removedItems?.Add(original[^1]);
original.RemoveAt(original.Count - 1);
}
// Add any extra trailing items from the source
while (i < numberOfNew)
{
original.Add(newList[i]);
i++;
}
}
/// <summary>
/// Path for large collections. Pre-removes non-matching items to shrink the
/// working set before merging, making linear searches faster.
/// </summary>
private static void MergeWithPreRemoval<T>(
IList<T> original,
IList<T> newList,
int numberOfNew,
HashSet<T> newSet,
ObservableCollection<T>? observableCollection,
List<T>? removedItems)
where T : class
{
// Pre-remove items that are not in newList. Iterating backwards keeps
// earlier indices stable and shrinks the working set for the merge loop.
for (var i = original.Count - 1; i >= 0; i--)
{
if (!newSet.Contains(original[i]))
{
removedItems?.Add(original[i]);
original.RemoveAt(i);
}
}
// Forward-scan merge: move or insert items to match newList order.
// After pre-removal, original only contains items that exist in newList,
// so the merge loop is simple — just Move or Insert.
for (var i = 0; i < numberOfNew; i++)
{
var newItem = newList[i];
// If we've run out of original items, append the rest
if (i >= original.Count)
{
original.Add(newItem);
continue;
}
// Already correct?
if (original[i]?.Equals(newItem) ?? false)
{
continue;
}
// Find the item later in the original list
var foundIndex = -1;
for (var j = i + 1; j < original.Count; j++)
{
if (original[j]?.Equals(newItem) ?? false)
{
foundIndex = j;
break;
}
}
if (foundIndex >= 0)
{
MoveItem(original, observableCollection, foundIndex, i);
continue;
}
// Not found: insert new item at i
original.Insert(i, newItem);
}
// Remove any extra trailing items from the destination
while (original.Count > numberOfNew)
{
removedItems?.Add(original[^1]);
original.RemoveAt(original.Count - 1);
}
}
/// <summary>
/// Moves an item from <paramref name="fromIndex"/> to <paramref name="toIndex"/>.
/// Uses ObservableCollection.Move() when available for a single notification,
/// otherwise falls back to RemoveAt + Insert.
/// </summary>
private static void MoveItem<T>(
IList<T> original,
ObservableCollection<T>? observableCollection,
int fromIndex,
int toIndex)
{
if (observableCollection is not null)
{
observableCollection.Move(fromIndex, toIndex);
}
else
{
var item = original[fromIndex];
original.RemoveAt(fromIndex);
original.Insert(toIndex, item);
} }
} }
} }