mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 01:36:31 +02:00
CmdPal: Lightning-fast mode (#45764)
## Summary of the Pull Request
This PR unlocks lightning-fast mode for Command Palette:
- Hides visual and motion distractions when updating the result list:
- Ensures the first interactable result item is selected as early as
possible after the result list is updated, reducing flashing and
blinking caused by the selection highlight moving around.
- Removes the list item selection indicator animation (unfortunately by
removing the pill altogether for now) and prevents it from temporarily
appearing on other items as the selection moves.
- Adds a new "Results" section header above the home page results when
no other section is present.
- This ensures the first item on the home page has consistent visuals
and styling, preventing offsets and excessive visual changes when
elements are replaced in place.
- Improves update performance and container reuse:
- Fixes the `removed` output parameter in `ListHelper.UpdateInPlace` to
only include items that were actually removed (items that were merely
moved to a different position should not be reported as removed).
- Adds unit tests to prevent regression.
- Updates `ListHelper.UpdateInPlace` for `ObservableCollection` to use
`Move` instead of `Remove`/`Add`, and avoids `Clear` to prevent
`ListView` resets (which force recreation of all item containers and are
expensive).
- Adds a simple cache for list page item view models to reduce
unnecessary recreation during forward incremental search.
- `ListViewModel` and `FetchItems` have no notion of item lifetime or
incremental search phase, so the cache intentionally remains simple
rather than clever.
- Updates ListPage templates to make them a little lighter:
- Tag template uses OneTime, instead of OneWay - since Tag is immutable
- Replaces ItemsControl with ItemsRepeater for Tag list on list items
- Increases the debounce for showing the details pane and adds a
debounce for hiding it. This improves performance when browsing the list
and prevents the details pane animation from bouncing left and right
## Pictures? Moving!
https://github.com/user-attachments/assets/36428d20-cf46-4321-83c0-d94d6d4a2299
<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist
- [x] Closes: #44407
- [x] Closes: #45691
This commit is contained in:
@@ -36,6 +36,11 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
|
||||
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>[]? _filteredApps;
|
||||
|
||||
@@ -171,9 +176,40 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
// filtered results.
|
||||
if (string.IsNullOrWhiteSpace(SearchText))
|
||||
{
|
||||
return _tlcManager.TopLevelCommands
|
||||
.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
|
||||
.ToArray();
|
||||
var allCommands = _tlcManager.TopLevelCommands;
|
||||
|
||||
// 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
|
||||
{
|
||||
@@ -190,6 +226,8 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
validScoredFallbacks,
|
||||
_filteredApps,
|
||||
validFallbacks,
|
||||
_resultsSeparator,
|
||||
_fallbacksSeparator,
|
||||
AppResultLimit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ internal static class MainListPageResultFactory
|
||||
IList<RoScored<IListItem>>? scoredFallbackItems,
|
||||
IList<RoScored<IListItem>>? filteredApps,
|
||||
IList<RoScored<IListItem>>? fallbackItems,
|
||||
IListItem resultsSeparator,
|
||||
IListItem fallbacksSeparator,
|
||||
int appResultLimit)
|
||||
{
|
||||
if (appResultLimit < 0)
|
||||
@@ -40,8 +42,13 @@ internal static class MainListPageResultFactory
|
||||
int nonEmptyFallbackCount = fallbackItems?.Count ?? 0;
|
||||
|
||||
// Allocate the exact size of the result array.
|
||||
// We'll add an extra slot for the fallbacks section header if needed.
|
||||
int totalCount = len1 + len2 + len3 + nonEmptyFallbackCount + (nonEmptyFallbackCount > 0 ? 1 : 0);
|
||||
// We'll add an extra slot for the fallbacks section header if needed,
|
||||
// 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];
|
||||
|
||||
@@ -49,6 +56,12 @@ internal static class MainListPageResultFactory
|
||||
int idx1 = 0, idx2 = 0, idx3 = 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
|
||||
// priority is: list1 > list2 > list3 when scores are equal.
|
||||
while (idx1 < len1 && idx2 < len2 && idx3 < len3)
|
||||
@@ -132,7 +145,7 @@ internal static class MainListPageResultFactory
|
||||
// Create the fallbacks section header
|
||||
if (fallbackItems.Count > 0)
|
||||
{
|
||||
result[writePos++] = new Separator(Properties.Resources.fallbacks);
|
||||
result[writePos++] = fallbacksSeparator;
|
||||
}
|
||||
|
||||
for (int i = 0; i < fallbackItems.Count; i++)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.CmdPal.Common;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Models;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
@@ -16,9 +19,10 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class ListViewModel : PageViewModel, IDisposable
|
||||
{
|
||||
// private readonly HashSet<ListItemViewModel> _itemCache = [];
|
||||
private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
|
||||
|
||||
private readonly Dictionary<IListItem, ListItemViewModel> _vmCache = new(new ProxyReferenceEqualityComparer());
|
||||
|
||||
// TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items?
|
||||
|
||||
// Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change
|
||||
@@ -37,7 +41,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
private InterlockedBoolean _isLoading;
|
||||
private bool _isFetching;
|
||||
|
||||
public event TypedEventHandler<ListViewModel, object>? ItemsUpdated;
|
||||
public event TypedEventHandler<ListViewModel, ItemsUpdatedEventArgs>? ItemsUpdated;
|
||||
|
||||
public bool ShowEmptyContent =>
|
||||
IsInitialized &&
|
||||
@@ -80,6 +84,9 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
private ListItemViewModel? _lastSelectedItem;
|
||||
|
||||
// For cancelling a deferred SafeSlowInit when the user navigates rapidly
|
||||
private CancellationTokenSource? _selectedItemCts;
|
||||
|
||||
public override bool IsInitialized
|
||||
{
|
||||
get => base.IsInitialized; protected set
|
||||
@@ -113,10 +120,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
protected override void OnSearchTextBoxUpdated(string searchTextBox)
|
||||
{
|
||||
//// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view
|
||||
//// and manage filtering below, but we should be smarter about this and understand caching and other requirements...
|
||||
//// Investigate if we re-use src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\ListHelpers.cs InPlaceUpdateList and FilterList?
|
||||
|
||||
// Dynamic pages will handler their own filtering. They will tell us if
|
||||
// something needs to change, by raising ItemsChanged.
|
||||
if (_isDynamic)
|
||||
@@ -162,7 +165,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
ApplyFilterUnderLock();
|
||||
}
|
||||
|
||||
ItemsUpdated?.Invoke(this, EventArgs.Empty);
|
||||
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(true));
|
||||
UpdateEmptyContent();
|
||||
_isLoading.Clear();
|
||||
}
|
||||
@@ -198,12 +201,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
var cancellationToken = _fetchItemsCancellationTokenSource.Token;
|
||||
|
||||
// TEMPORARY: just plop all the items into a single group
|
||||
// see 9806fe5d8 for the last commit that had this with sections
|
||||
_isFetching = true;
|
||||
|
||||
// Collect all the items into new viewmodels
|
||||
Collection<ListItemViewModel> newViewModels = [];
|
||||
List<ListItemViewModel> newViewModels = [];
|
||||
|
||||
try
|
||||
{
|
||||
@@ -221,11 +222,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO we can probably further optimize this by also keeping a
|
||||
// HashSet of every ExtensionObject we currently have, and only
|
||||
// building new viewmodels for the ones we haven't already built.
|
||||
var showsTitle = GridProperties?.ShowTitle ?? true;
|
||||
var showsSubtitle = GridProperties?.ShowSubtitle ?? true;
|
||||
var created = 0;
|
||||
var reused = 0;
|
||||
foreach (var item in newItems)
|
||||
{
|
||||
// Check for cancellation during item processing
|
||||
@@ -234,17 +234,33 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
ListItemViewModel viewModel = new(item, new(this));
|
||||
if (_vmCache.TryGetValue(item, out var existing))
|
||||
{
|
||||
existing.LayoutShowsTitle = showsTitle;
|
||||
existing.LayoutShowsSubtitle = showsSubtitle;
|
||||
newViewModels.Add(existing);
|
||||
reused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var viewModel = new ListItemViewModel(item, new(this));
|
||||
|
||||
// If an item fails to load, silently ignore it.
|
||||
if (viewModel.SafeFastInit())
|
||||
{
|
||||
viewModel.LayoutShowsTitle = showsTitle;
|
||||
viewModel.LayoutShowsSubtitle = showsSubtitle;
|
||||
|
||||
_vmCache[item] = viewModel;
|
||||
newViewModels.Add(viewModel);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
CoreLogger.LogInfo($"[ListViewModel] FetchItems: {created} created, {reused} reused, {_vmCache.Count} cached");
|
||||
#endif
|
||||
|
||||
// Check for cancellation before initializing first twenty items
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -271,13 +287,22 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
List<ListItemViewModel> removedItems = [];
|
||||
List<ListItemViewModel> removedItems;
|
||||
lock (_listLock)
|
||||
{
|
||||
// Now that we have new ViewModels for everything from the
|
||||
// extension, smartly update our list of VMs
|
||||
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
|
||||
|
||||
_vmCache.Clear();
|
||||
foreach (var vm in newViewModels)
|
||||
{
|
||||
if (vm.Model.Unsafe is { } li)
|
||||
{
|
||||
_vmCache[li] = vm;
|
||||
}
|
||||
}
|
||||
|
||||
// DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
|
||||
// you'll clean up list items that we've now transferred into
|
||||
// .Items
|
||||
@@ -288,9 +313,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
{
|
||||
removedItem.SafeCleanup();
|
||||
}
|
||||
|
||||
// TODO: Iterate over everything in Items, and prune items from the
|
||||
// cache if we don't need them anymore
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -342,13 +364,14 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
{
|
||||
// A dynamic list? Even better! Just stick everything into
|
||||
// FilteredItems. The extension already did any filtering it cared about.
|
||||
ListHelpers.InPlaceUpdateList(FilteredItems, Items.Where(i => !i.IsInErrorState));
|
||||
var snapshot = Items.Where(i => !i.IsInErrorState).ToList();
|
||||
ListHelpers.InPlaceUpdateList(FilteredItems, snapshot);
|
||||
}
|
||||
|
||||
UpdateEmptyContent();
|
||||
}
|
||||
|
||||
ItemsUpdated?.Invoke(this, EventArgs.Empty);
|
||||
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(!IsNested));
|
||||
_isLoading.Clear();
|
||||
});
|
||||
}
|
||||
@@ -485,25 +508,45 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
private void SetSelectedItem(ListItemViewModel item)
|
||||
{
|
||||
if (!item.SafeSlowInit())
|
||||
_lastSelectedItem = item;
|
||||
_lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged;
|
||||
|
||||
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
|
||||
|
||||
// Cancel any in-flight slow init from a previous selection and defer
|
||||
// the expensive work (extension IPC for MoreCommands, details) so
|
||||
// rapid arrow-key navigation skips intermediate items entirely.
|
||||
_selectedItemCts?.Cancel();
|
||||
var cts = _selectedItemCts = new CancellationTokenSource();
|
||||
var ct = cts.Token;
|
||||
|
||||
_ = Task.Run(
|
||||
() =>
|
||||
{
|
||||
// Even if initialization fails, we need to hide any previously shown details
|
||||
DoOnUiThread(() =>
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
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(
|
||||
() =>
|
||||
if (!item.SafeSlowInit())
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
|
||||
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));
|
||||
@@ -515,10 +558,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
TextToSuggest = item.TextToSuggest;
|
||||
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
|
||||
});
|
||||
|
||||
_lastSelectedItem = item;
|
||||
_lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged;
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
@@ -557,21 +598,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
private void ClearSelectedItem()
|
||||
{
|
||||
// GH #322:
|
||||
// For inexplicable reasons, if you try updating the command bar and
|
||||
// the details on the same UI thread tick as updating the list, we'll
|
||||
// explode
|
||||
DoOnUiThread(
|
||||
() =>
|
||||
{
|
||||
_selectedItemCts?.Cancel();
|
||||
|
||||
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
|
||||
|
||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||
|
||||
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
|
||||
|
||||
TextToSuggest = string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
public override void InitializeProperties()
|
||||
@@ -763,6 +795,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
_fetchItemsCancellationTokenSource?.Cancel();
|
||||
_fetchItemsCancellationTokenSource?.Dispose();
|
||||
_fetchItemsCancellationTokenSource = null;
|
||||
|
||||
_selectedItemCts?.Cancel();
|
||||
_selectedItemCts?.Dispose();
|
||||
_selectedItemCts = null;
|
||||
}
|
||||
|
||||
protected override void UnsafeCleanup()
|
||||
@@ -775,6 +811,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
_cancellationTokenSource?.Cancel();
|
||||
filterCancellationTokenSource?.Cancel();
|
||||
_fetchItemsCancellationTokenSource?.Cancel();
|
||||
_selectedItemCts?.Cancel();
|
||||
|
||||
lock (_listLock)
|
||||
{
|
||||
@@ -801,4 +838,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
model.ItemsChanged -= Model_ItemsChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProxyReferenceEqualityComparer : IEqualityComparer<IListItem>
|
||||
{
|
||||
public bool Equals(IListItem? x, IListItem? y) => ReferenceEquals(x, y);
|
||||
|
||||
public int GetHashCode(IListItem obj) => RuntimeHelpers.GetHashCode(obj);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// 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.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
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>
|
||||
/// Looks up a localized string similar to Show details.
|
||||
/// </summary>
|
||||
|
||||
@@ -264,4 +264,8 @@
|
||||
<value>Show details</value>
|
||||
<comment>Name for the command that shows details of an item</comment>
|
||||
</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>
|
||||
@@ -201,12 +201,70 @@
|
||||
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
|
||||
</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
|
||||
x:Key="ListDefaultContainerStyle"
|
||||
x:Key="ListSingleRowItemContainerStyle"
|
||||
BasedOn="{StaticResource DefaultListViewItemStyle}"
|
||||
TargetType="ListViewItem">
|
||||
<Setter Property="MinHeight" Value="{StaticResource SingleRowListViewItemHeight}" />
|
||||
<Setter Property="Height" Value="{StaticResource SingleRowListViewItemHeight}" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Template" Value="{StaticResource ListViewItemWithoutVisualIndicatorTemplate}" />
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
@@ -246,14 +304,17 @@
|
||||
</Style>
|
||||
|
||||
<DataTemplate x:Key="TagTemplate" x:DataType="viewModels:TagViewModel">
|
||||
<!--
|
||||
Tags are immutable, so we don't have to worry about binding mode.
|
||||
-->
|
||||
<cpcontrols:Tag
|
||||
AutomationProperties.Name="{x:Bind Text, Mode=OneWay}"
|
||||
BackgroundColor="{x:Bind Background, Mode=OneWay}"
|
||||
AutomationProperties.Name="{x:Bind Text}"
|
||||
BackgroundColor="{x:Bind Background}"
|
||||
FontSize="12"
|
||||
ForegroundColor="{x:Bind Foreground, Mode=OneWay}"
|
||||
Icon="{x:Bind Icon, Mode=OneWay}"
|
||||
Text="{x:Bind Text, Mode=OneWay}"
|
||||
ToolTipService.ToolTip="{x:Bind ToolTip, Mode=OneWay}" />
|
||||
ForegroundColor="{x:Bind Foreground}"
|
||||
Icon="{x:Bind Icon}"
|
||||
Text="{x:Bind Text}"
|
||||
ToolTipService.ToolTip="{x:Bind ToolTip}" />
|
||||
</DataTemplate>
|
||||
|
||||
<cmdpalUI:ListItemTemplateSelector
|
||||
@@ -265,7 +326,7 @@
|
||||
|
||||
<cmdpalUI:ListItemContainerStyleSelector
|
||||
x:Key="ListItemContainerStyleSelector"
|
||||
Default="{StaticResource ListDefaultContainerStyle}"
|
||||
Default="{StaticResource ListSingleRowItemContainerStyle}"
|
||||
Section="{StaticResource ListSectionContainerStyle}"
|
||||
Separator="{StaticResource ListSeparatorContainerStyle}" />
|
||||
|
||||
@@ -310,10 +371,7 @@
|
||||
Title and subtitle are intentionally in a nested Grid instead in the outer container,
|
||||
to avoid pushing the following element (tags) out of bounds.
|
||||
-->
|
||||
<Grid
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
ColumnSpacing="12">
|
||||
<Grid Grid.Column="1" ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
@@ -345,10 +403,11 @@
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
8px right margin is added to visually match the spacing between then icon,
|
||||
and the left margin of the list, as there's
|
||||
An 8px right margin is added to visually match the spacing between the icon
|
||||
and the left margin of the list.
|
||||
ItemRepeater is a lightweight control (compared to ItemsControl).
|
||||
-->
|
||||
<ItemsControl
|
||||
<ItemsRepeater
|
||||
Grid.Column="2"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"
|
||||
@@ -357,12 +416,10 @@
|
||||
ItemTemplate="{StaticResource TagTemplate}"
|
||||
ItemsSource="{x:Bind Tags, Mode=OneWay}"
|
||||
Visibility="{x:Bind HasTags, Mode=OneWay}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<ItemsRepeater.Layout>
|
||||
<StackLayout Orientation="Horizontal" Spacing="4" />
|
||||
</ItemsRepeater.Layout>
|
||||
</ItemsRepeater>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
using System.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.UI.Helpers;
|
||||
using Microsoft.CmdPal.UI.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels;
|
||||
@@ -12,6 +13,7 @@ using Microsoft.CmdPal.UI.ViewModels.Commands;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Automation.Peers;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
@@ -35,6 +37,14 @@ public sealed partial class ListPage : Page,
|
||||
{
|
||||
private InputSource _lastInputSource;
|
||||
|
||||
private int _itemsUpdatedVersion;
|
||||
private bool _suppressSelectionChanged;
|
||||
|
||||
private bool _scrollOnNextSelectionChange;
|
||||
|
||||
private ListItemViewModel? _stickySelectedItem;
|
||||
private ListItemViewModel? _lastPushedToVm;
|
||||
|
||||
internal ListViewModel? ViewModel
|
||||
{
|
||||
get => (ListViewModel?)GetValue(ViewModelProperty);
|
||||
@@ -45,13 +55,7 @@ public sealed partial class ListPage : Page,
|
||||
public static readonly DependencyProperty ViewModelProperty =
|
||||
DependencyProperty.Register(nameof(ViewModel), typeof(ListViewModel), typeof(ListPage), new PropertyMetadata(null, OnViewModelChanged));
|
||||
|
||||
private ListViewBase ItemView
|
||||
{
|
||||
get
|
||||
{
|
||||
return ViewModel?.IsGridView == true ? ItemsGrid : ItemsList;
|
||||
}
|
||||
}
|
||||
private ListViewBase ItemView => ViewModel?.IsGridView == true ? ItemsGrid : ItemsList;
|
||||
|
||||
public ListPage()
|
||||
{
|
||||
@@ -81,12 +85,19 @@ public sealed partial class ListPage : Page,
|
||||
// Must dispatch the selection to run at a lower priority; otherwise, GetFirstSelectableIndex
|
||||
// may return an incorrect index because item containers are not yet rendered.
|
||||
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
// Only do this if we truly have no selection.
|
||||
if (ItemView.SelectedItem is null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
ViewModel.PropertyChanged -= ViewModel_PropertyChanged;
|
||||
ViewModel.ItemsUpdated -= Page_ItemsUpdated;
|
||||
}
|
||||
|
||||
@@ -175,6 +185,9 @@ public sealed partial class ListPage : Page,
|
||||
}
|
||||
else
|
||||
{
|
||||
// Click-driven selection should scroll into view (but only once).
|
||||
_scrollOnNextSelectionChange = true;
|
||||
|
||||
ViewModel?.UpdateSelectedItemCommand.Execute(item);
|
||||
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
|
||||
}
|
||||
@@ -196,45 +209,54 @@ 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")]
|
||||
private void Items_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppressSelectionChanged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var vm = ViewModel;
|
||||
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))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_stickySelectedItem = li;
|
||||
|
||||
// Do not Task.Run (it reorders selection updates).
|
||||
vm?.UpdateSelectedItemCommand.Execute(li);
|
||||
});
|
||||
|
||||
// There's mysterious behavior here, where the selection seemingly
|
||||
// changes to _nothing_ when we're backspacing to a single character.
|
||||
// And at that point, seemingly the item that's getting removed is not
|
||||
// a member of FilteredItems. Very bizarre.
|
||||
//
|
||||
// Might be able to fix in the future by stashing the removed item
|
||||
// here, then in Page_ItemsUpdated trying to select that cached item if
|
||||
// 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))
|
||||
// Only scroll when explicitly requested by navigation/click handlers.
|
||||
if (_scrollOnNextSelectionChange)
|
||||
{
|
||||
var items = ItemView.Items;
|
||||
var firstUsefulIndex = GetFirstSelectableIndex();
|
||||
var shouldScroll = false;
|
||||
_scrollOnNextSelectionChange = 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)
|
||||
{
|
||||
scrollTarget = prevItem;
|
||||
}
|
||||
else if (ItemView.SelectedIndex > firstUsefulIndex)
|
||||
{
|
||||
shouldScroll = true;
|
||||
}
|
||||
|
||||
if (shouldScroll)
|
||||
if (scrollTarget is not null)
|
||||
{
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
ItemView.ScrollIntoView(scrollTarget);
|
||||
}
|
||||
}
|
||||
|
||||
// Automation notification for screen readers
|
||||
var listViewPeer = Microsoft.UI.Xaml.Automation.Peers.ListViewAutomationPeer.CreatePeerForElement(ItemView);
|
||||
if (listViewPeer is not null && li is not null)
|
||||
var listViewPeer = ListViewAutomationPeer.CreatePeerForElement(ItemView);
|
||||
if (listViewPeer is not null)
|
||||
{
|
||||
UIHelper.AnnounceActionForAccessibility(
|
||||
ItemsList,
|
||||
@@ -242,7 +264,6 @@ public sealed partial class ListPage : Page,
|
||||
"CommandPaletteSelectedItemChanged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Items_RightTapped(object sender, RightTappedRoutedEventArgs e)
|
||||
{
|
||||
@@ -250,9 +271,14 @@ public sealed partial class ListPage : Page,
|
||||
element.DataContext is ListItemViewModel item)
|
||||
{
|
||||
if (ItemView.SelectedItem != item)
|
||||
{
|
||||
_scrollOnNextSelectionChange = true;
|
||||
|
||||
using (SuppressSelectionChangedScope())
|
||||
{
|
||||
ItemView.SelectedItem = item;
|
||||
}
|
||||
}
|
||||
|
||||
ViewModel?.UpdateSelectedItemCommand.Execute(item);
|
||||
|
||||
@@ -264,7 +290,7 @@ public sealed partial class ListPage : Page,
|
||||
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
|
||||
new OpenContextMenuMessage(
|
||||
element,
|
||||
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft,
|
||||
FlyoutPlacementMode.BottomEdgeAlignedLeft,
|
||||
pos,
|
||||
ContextMenuFilterLocation.Top));
|
||||
});
|
||||
@@ -274,7 +300,7 @@ public sealed partial class ListPage : Page,
|
||||
private void Items_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Find the ScrollViewer in the ItemView (ItemsList or ItemsGrid)
|
||||
var listViewScrollViewer = FindScrollViewer(this.ItemView);
|
||||
var listViewScrollViewer = FindScrollViewer(ItemView);
|
||||
|
||||
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)
|
||||
{
|
||||
// Note: We may want to just have the notion of a 'SelectedCommand' in our VM
|
||||
// And then have these commands manipulate that state being bound to the UI instead
|
||||
// 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 :)
|
||||
MarkKeyboardNavigation();
|
||||
_scrollOnNextSelectionChange = true;
|
||||
|
||||
if (ViewModel?.IsGridView == true)
|
||||
{
|
||||
// For grid views, use spatial navigation (down)
|
||||
@@ -316,10 +367,15 @@ public sealed partial class ListPage : Page,
|
||||
// For list views, use simple linear navigation
|
||||
NavigateDown();
|
||||
}
|
||||
|
||||
PushSelectionToVm();
|
||||
}
|
||||
|
||||
public void Receive(NavigatePreviousCommand message)
|
||||
{
|
||||
MarkKeyboardNavigation();
|
||||
_scrollOnNextSelectionChange = true;
|
||||
|
||||
if (ViewModel?.IsGridView == true)
|
||||
{
|
||||
// For grid views, use spatial navigation (up)
|
||||
@@ -329,14 +385,20 @@ public sealed partial class ListPage : Page,
|
||||
{
|
||||
NavigateUp();
|
||||
}
|
||||
|
||||
PushSelectionToVm();
|
||||
}
|
||||
|
||||
public void Receive(NavigateLeftCommand message)
|
||||
{
|
||||
MarkKeyboardNavigation();
|
||||
_scrollOnNextSelectionChange = true;
|
||||
|
||||
// For grid views, use spatial navigation. For list views, just move up.
|
||||
if (ViewModel?.IsGridView == true)
|
||||
{
|
||||
HandleGridArrowNavigation(VirtualKey.Left);
|
||||
PushSelectionToVm();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -347,10 +409,14 @@ public sealed partial class ListPage : Page,
|
||||
|
||||
public void Receive(NavigateRightCommand message)
|
||||
{
|
||||
MarkKeyboardNavigation();
|
||||
_scrollOnNextSelectionChange = true;
|
||||
|
||||
// For grid views, use spatial navigation. For list views, just move down.
|
||||
if (ViewModel?.IsGridView == true)
|
||||
{
|
||||
HandleGridArrowNavigation(VirtualKey.Right);
|
||||
PushSelectionToVm();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -385,6 +451,9 @@ public sealed partial class ListPage : Page,
|
||||
|
||||
public void Receive(NavigatePageDownCommand message)
|
||||
{
|
||||
MarkKeyboardNavigation();
|
||||
_scrollOnNextSelectionChange = true;
|
||||
|
||||
var indexes = CalculateTargetIndexPageUpDownScrollTo(true);
|
||||
if (indexes is null)
|
||||
{
|
||||
@@ -394,15 +463,16 @@ public sealed partial class ListPage : Page,
|
||||
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
|
||||
{
|
||||
ItemView.SelectedIndex = indexes.Value.TargetIndex;
|
||||
if (ItemView.SelectedItem is not null)
|
||||
{
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
PushSelectionToVm();
|
||||
}
|
||||
|
||||
public void Receive(NavigatePageUpCommand message)
|
||||
{
|
||||
MarkKeyboardNavigation();
|
||||
_scrollOnNextSelectionChange = true;
|
||||
|
||||
var indexes = CalculateTargetIndexPageUpDownScrollTo(false);
|
||||
if (indexes is null)
|
||||
{
|
||||
@@ -412,11 +482,9 @@ public sealed partial class ListPage : Page,
|
||||
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
|
||||
{
|
||||
ItemView.SelectedIndex = indexes.Value.TargetIndex;
|
||||
if (ItemView.SelectedItem is not null)
|
||||
{
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
PushSelectionToVm();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -531,13 +599,11 @@ public sealed partial class ListPage : Page,
|
||||
{
|
||||
if (e.OldValue is ListViewModel old)
|
||||
{
|
||||
old.PropertyChanged -= @this.ViewModel_PropertyChanged;
|
||||
old.ItemsUpdated -= @this.Page_ItemsUpdated;
|
||||
}
|
||||
|
||||
if (e.NewValue is ListViewModel page)
|
||||
{
|
||||
page.PropertyChanged += @this.ViewModel_PropertyChanged;
|
||||
page.ItemsUpdated += @this.Page_ItemsUpdated;
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// ItemView_SelectionChanged again to give us another chance to change
|
||||
// the selection from null -> something. Better to just update the
|
||||
// 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;
|
||||
var version = Interlocked.Increment(ref _itemsUpdatedVersion);
|
||||
var forceFirstItem = args.ForceFirstItem;
|
||||
|
||||
// If the list is null or empty, clears the selection and return
|
||||
// Try to handle selection immediately — items should already be available
|
||||
// since FilteredItems is a direct ObservableCollection bound as ItemsSource.
|
||||
if (!TrySetSelectionAfterUpdate(sender, version, forceFirstItem))
|
||||
{
|
||||
// Fallback: binding hasn't propagated yet, defer to next tick.
|
||||
_ = DispatcherQueue.TryEnqueue(
|
||||
Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
if (version != Volatile.Read(ref _itemsUpdatedVersion))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TrySetSelectionAfterUpdate(sender, version, forceFirstItem);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
_stickySelectedItem = null;
|
||||
_lastPushedToVm = null;
|
||||
}
|
||||
|
||||
PushSelectionToVm();
|
||||
return true;
|
||||
}
|
||||
|
||||
// If ItemView.Items hasn't caught up with the ObservableCollection yet,
|
||||
// signal the caller to defer and retry.
|
||||
var items = ItemView.Items;
|
||||
if (items is null || items.Count == 0)
|
||||
{
|
||||
ItemView.SelectedIndex = -1;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Finds the first item that is not a separator
|
||||
var firstUsefulIndex = GetFirstSelectableIndex();
|
||||
|
||||
// If there is only separators in the list, don't select anything.
|
||||
if (firstUsefulIndex == -1)
|
||||
{
|
||||
using (SuppressSelectionChangedScope())
|
||||
{
|
||||
ItemView.SelectedIndex = -1;
|
||||
|
||||
return;
|
||||
_stickySelectedItem = null;
|
||||
_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
|
||||
if (!sender.IsNested)
|
||||
var shouldUpdateSelection = forceFirstItem;
|
||||
|
||||
if (!shouldUpdateSelection)
|
||||
{
|
||||
// Check if selection needs repair (item gone, null, or separator).
|
||||
if (ItemView.SelectedItem is null)
|
||||
{
|
||||
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))
|
||||
{
|
||||
shouldUpdateSelection = true;
|
||||
}
|
||||
|
||||
// The selected item does not exist in the new list
|
||||
else if (!items.Contains(ItemView.SelectedItem))
|
||||
{
|
||||
shouldUpdateSelection = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdateSelection)
|
||||
{
|
||||
if (firstUsefulIndex != -1)
|
||||
using (SuppressSelectionChangedScope())
|
||||
{
|
||||
ItemView.SelectedIndex = firstUsefulIndex;
|
||||
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];
|
||||
}
|
||||
|
||||
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
// Prevent any pending "scroll on selection" logic from fighting this.
|
||||
_scrollOnNextSelectionChange = false;
|
||||
|
||||
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
var prop = e.PropertyName;
|
||||
if (prop == nameof(ViewModel.FilteredItems))
|
||||
if (version != Volatile.Read(ref _itemsUpdatedVersion))
|
||||
{
|
||||
Debug.WriteLine($"ViewModel.FilteredItems {ItemView.SelectedItem}");
|
||||
return;
|
||||
}
|
||||
|
||||
ResetScrollToTop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
PushSelectionToVm();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ScrollViewer? FindScrollViewer(DependencyObject parent)
|
||||
@@ -653,7 +777,6 @@ public sealed partial class ListPage : Page,
|
||||
{
|
||||
if (ItemView.Items.Count == 0)
|
||||
{
|
||||
// No items, goodbye.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -750,7 +873,6 @@ public sealed partial class ListPage : Page,
|
||||
if (bestIndex != currentIndex)
|
||||
{
|
||||
ItemView.SelectedIndex = bestIndex;
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -773,7 +895,6 @@ public sealed partial class ListPage : Page,
|
||||
if (fallback != currentIndex)
|
||||
{
|
||||
ItemView.SelectedIndex = fallback;
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,9 +917,14 @@ public sealed partial class ListPage : Page,
|
||||
}
|
||||
|
||||
if (ItemView.SelectedItem != item)
|
||||
{
|
||||
_scrollOnNextSelectionChange = true;
|
||||
|
||||
using (SuppressSelectionChangedScope())
|
||||
{
|
||||
ItemView.SelectedItem = item;
|
||||
}
|
||||
}
|
||||
|
||||
if (!e.TryGetPosition(element, out var pos))
|
||||
{
|
||||
@@ -811,7 +937,7 @@ public sealed partial class ListPage : Page,
|
||||
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
|
||||
new OpenContextMenuMessage(
|
||||
element,
|
||||
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft,
|
||||
FlyoutPlacementMode.BottomEdgeAlignedLeft,
|
||||
pos,
|
||||
ContextMenuFilterLocation.Top));
|
||||
});
|
||||
@@ -844,6 +970,7 @@ public sealed partial class ListPage : Page,
|
||||
case VirtualKey.Up:
|
||||
case VirtualKey.Down:
|
||||
_lastInputSource = InputSource.Keyboard;
|
||||
_scrollOnNextSelectionChange = true;
|
||||
HandleGridArrowNavigation(e.Key);
|
||||
e.Handled = true;
|
||||
break;
|
||||
@@ -1027,6 +1154,31 @@ public sealed partial class ListPage : Page,
|
||||
|
||||
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
|
||||
{
|
||||
None,
|
||||
|
||||
@@ -277,49 +277,64 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
public void Receive(ShowDetailsMessage message)
|
||||
{
|
||||
if (ViewModel is not null &&
|
||||
ViewModel.CurrentPage is not null)
|
||||
if (ViewModel is null || ViewModel.CurrentPage is null)
|
||||
{
|
||||
if (ViewModel.CurrentPage.PageContext.TryGetTarget(out var pageContext))
|
||||
{
|
||||
Task.Factory.StartNew(
|
||||
() =>
|
||||
{
|
||||
// TERRIBLE HACK TODO GH #245
|
||||
// There's weird wacky bugs with debounce currently.
|
||||
if (!ViewModel.IsDetailsVisible)
|
||||
{
|
||||
ViewModel.Details = message.Details;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage)));
|
||||
ViewModel.IsDetailsVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
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.
|
||||
_debounceTimer.Debounce(
|
||||
() =>
|
||||
//
|
||||
// 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()
|
||||
{
|
||||
ViewModel.Details = message.Details;
|
||||
// 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)
|
||||
{
|
||||
var enqueued = _queue.TryEnqueue(ShowDetails);
|
||||
if (!enqueued)
|
||||
{
|
||||
Logger.LogError("Failed to enqueue show details action on UI thread");
|
||||
}
|
||||
|
||||
// Trigger a re-evaluation of whether we have a hero image based on
|
||||
// the current theme
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ViewModel.Details = details;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage)));
|
||||
},
|
||||
interval: TimeSpan.FromMilliseconds(50),
|
||||
immediate: ViewModel.IsDetailsVisible == false);
|
||||
ViewModel.IsDetailsVisible = true;
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.None,
|
||||
pageContext.Scheduler);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to show detail", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// 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.
|
||||
|
||||
@@ -17,6 +17,9 @@ namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||
[TestClass]
|
||||
public partial class MainListPageResultFactoryTests
|
||||
{
|
||||
private static readonly Separator _resultsSeparator = new("Results");
|
||||
private static readonly Separator _fallbacksSeparator = new("Fallbacks");
|
||||
|
||||
private sealed partial class MockListItem : IListItem
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
@@ -82,18 +85,22 @@ public partial class MainListPageResultFactoryTests
|
||||
scoredFallback,
|
||||
apps,
|
||||
fallbacks,
|
||||
_resultsSeparator,
|
||||
_fallbacksSeparator,
|
||||
appResultLimit: 10);
|
||||
|
||||
// Expected order:
|
||||
// "Results" section header
|
||||
// 100: F1, SF1, A1
|
||||
// 60: SF2
|
||||
// 55: A2
|
||||
// 50: F2
|
||||
// "Fallbacks" section header
|
||||
// Then fallbacks in original order: FB1, FB2
|
||||
var titles = result.Select(r => r.Title).ToArray();
|
||||
#pragma warning disable CA1861 // Avoid constant arrays as arguments
|
||||
CollectionAssert.AreEqual(
|
||||
new[] { "F1", "SF1", "A1", "SF2", "A2", "F2", "Fallbacks", "FB1", "FB2" },
|
||||
new[] { "Results", "F1", "SF1", "A1", "SF2", "A2", "F2", "Fallbacks", "FB1", "FB2" },
|
||||
titles);
|
||||
#pragma warning restore CA1861 // Avoid constant arrays as arguments
|
||||
}
|
||||
@@ -113,11 +120,14 @@ public partial class MainListPageResultFactoryTests
|
||||
null,
|
||||
apps,
|
||||
null,
|
||||
_resultsSeparator,
|
||||
_fallbacksSeparator,
|
||||
2);
|
||||
|
||||
Assert.AreEqual(2, result.Length);
|
||||
Assert.AreEqual("A1", result[0].Title);
|
||||
Assert.AreEqual("A2", result[1].Title);
|
||||
Assert.AreEqual(3, result.Length);
|
||||
Assert.AreEqual("Results", result[0].Title);
|
||||
Assert.AreEqual("A1", result[1].Title);
|
||||
Assert.AreEqual("A2", result[2].Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -135,10 +145,13 @@ public partial class MainListPageResultFactoryTests
|
||||
null,
|
||||
apps,
|
||||
null,
|
||||
_resultsSeparator,
|
||||
_fallbacksSeparator,
|
||||
appResultLimit: 1);
|
||||
|
||||
Assert.AreEqual(1, result.Length);
|
||||
Assert.AreEqual("A1", result[0].Title);
|
||||
Assert.AreEqual(2, result.Length);
|
||||
Assert.AreEqual("Results", result[0].Title);
|
||||
Assert.AreEqual("A1", result[1].Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -155,6 +168,8 @@ public partial class MainListPageResultFactoryTests
|
||||
null,
|
||||
apps,
|
||||
null,
|
||||
_resultsSeparator,
|
||||
_fallbacksSeparator,
|
||||
appResultLimit: 0);
|
||||
|
||||
Assert.AreEqual(0, result.Length);
|
||||
@@ -181,12 +196,15 @@ public partial class MainListPageResultFactoryTests
|
||||
null,
|
||||
apps,
|
||||
null,
|
||||
_resultsSeparator,
|
||||
_fallbacksSeparator,
|
||||
appResultLimit: 1);
|
||||
|
||||
Assert.AreEqual(3, result.Length);
|
||||
Assert.AreEqual("F1", result[0].Title);
|
||||
Assert.AreEqual("A1", result[1].Title);
|
||||
Assert.AreEqual("F2", result[2].Title);
|
||||
Assert.AreEqual(4, result.Length);
|
||||
Assert.AreEqual("Results", result[0].Title);
|
||||
Assert.AreEqual("F1", result[1].Title);
|
||||
Assert.AreEqual("A1", result[2].Title);
|
||||
Assert.AreEqual("F2", result[3].Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -203,6 +221,8 @@ public partial class MainListPageResultFactoryTests
|
||||
null,
|
||||
null,
|
||||
fallbacks,
|
||||
_resultsSeparator,
|
||||
_fallbacksSeparator,
|
||||
appResultLimit: 10);
|
||||
|
||||
Assert.AreEqual(3, result.Length);
|
||||
@@ -219,6 +239,8 @@ public partial class MainListPageResultFactoryTests
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
_resultsSeparator,
|
||||
_fallbacksSeparator,
|
||||
appResultLimit: 10);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class ListHelpers
|
||||
@@ -58,84 +60,91 @@ public partial class ListHelpers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modifies the contents of `original` in-place, to match those of
|
||||
/// `newContents`. The canonical use being:
|
||||
/// ```cs
|
||||
/// 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));
|
||||
/// ```
|
||||
/// </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 `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)
|
||||
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>
|
||||
/// Modifies the contents of `original` in-place, to match those of
|
||||
/// `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();
|
||||
// Detect if we can use Move() for better ObservableCollection performance
|
||||
var observableCollection = original as ObservableCollection<T>;
|
||||
|
||||
// Short circuit - new contents should just be empty
|
||||
if (numberOfNew == 0)
|
||||
{
|
||||
removedItems.AddRange(original);
|
||||
if (observableCollection is not null)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
foreach (var newItem in newContents)
|
||||
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++)
|
||||
{
|
||||
var og_2 = original[j];
|
||||
var areEqual_2 = og_2?.Equals(newItem) ?? false;
|
||||
if (areEqual_2)
|
||||
if (original[j]?.Equals(newItem) ?? false)
|
||||
{
|
||||
for (var k = i; k < j; k++)
|
||||
{
|
||||
// This item from the original list was not in the new list. Remove it.
|
||||
removedItems.Add(original[i]);
|
||||
original.RemoveAt(i);
|
||||
}
|
||||
|
||||
foundIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var og = original[i];
|
||||
var areEqual = og?.Equals(newItem) ?? false;
|
||||
|
||||
// Is this new item already in the list?
|
||||
if (areEqual)
|
||||
if (foundIndex >= 0)
|
||||
{
|
||||
// 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
|
||||
{
|
||||
// it isn't. Add it.
|
||||
// Not found - insert new item at position i
|
||||
original.Insert(i, newItem);
|
||||
}
|
||||
|
||||
@@ -145,20 +154,284 @@ public partial class ListHelpers
|
||||
// Remove any extra trailing items from the destination
|
||||
while (original.Count > numberOfNew)
|
||||
{
|
||||
// RemoveAtEnd
|
||||
removedItems.Add(original[original.Count - 1]);
|
||||
original.RemoveAt(original.Count - 1);
|
||||
}
|
||||
|
||||
// Add any extra trailing items from the source
|
||||
if (original.Count < numberOfNew)
|
||||
while (i < numberOfNew)
|
||||
{
|
||||
var remaining = newContents.Skip(original.Count);
|
||||
foreach (var item in remaining)
|
||||
{
|
||||
original.Add(item);
|
||||
original.Add(newList[i]);
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user